50 Commits

Author SHA1 Message Date
a406440126 refactoring integration tests 2025-10-16 22:46:49 +02:00
ef88d2925e Implemented role-based access control, Updated e2e authentication tests and Playwright setup. 2025-10-15 21:56:11 +02:00
94214125fd Adding authentication integration test 2025-08-30 20:26:24 +02:00
17b08be077 Refactored Database management to allow isolated integration tests 2025-08-30 19:36:19 +02:00
db56363b1f Working version of playwright 2025-08-30 18:51:42 +02:00
3bd503d4d2 Added callback support 2025-08-29 19:17:24 +02:00
292a477298 Added Hooks implementation 2025-08-28 23:24:28 +02:00
eb8d6a99a2 Refactored JsonViewer 2025-08-27 23:06:12 +02:00
765c715d63 Added jsonviewer as an autonomous component 2025-08-27 21:38:47 +02:00
e90e7b01dd I can display an single workflow item 2025-08-26 22:47:16 +02:00
fe5668fbed Working version of EntrySelector 2025-08-26 21:58:42 +02:00
63058ef4a9 Added traceability 2025-08-25 23:20:10 +02:00
957a92f903 Added Workflow entry selector 2025-08-24 00:00:09 +02:00
33970c9c97 Added lazy loading when showing grid to improve performance 2025-08-23 22:26:14 +02:00
8eca1da3ca Updated .idea for Sheerka. Updated requirements for Arpeggio 2025-08-09 16:57:53 +02:00
97a5989390 removed .idea folder 2025-08-09 16:44:54 +02:00
e73709c859 Adding integration tests for workflow engine 2025-08-06 18:22:16 +02:00
f0d98d23ff I can finally chain Processor calls 2025-08-05 19:45:25 +02:00
64e7c44a7d Added other Jira resources 2025-08-05 10:42:26 +02:00
3a1870a160 Refactored properties component 2025-08-05 00:18:31 +02:00
c2fcfbb2ab I can save form (but user interaction is broken) 2025-08-04 18:36:28 +02:00
e74639c042 Properties Details correctly reacts on user interaction 2025-08-04 16:40:48 +02:00
badc2e28b0 Dialog box at the bottom. Property layout fully operationnel 2025-08-04 10:34:11 +02:00
4ac3eb2dfa The new layout is ok. We can work on the content 2025-08-03 18:11:26 +02:00
2bd998fe69 Refactoring Properties component 2025-08-03 11:10:17 +02:00
c694f42c07 The correct tab is shown on undo - redo 2025-08-02 15:27:11 +02:00
6949bb2814 Fixed WorkflowDesignerState 2025-08-02 01:22:45 +02:00
14f079d5f9 Added undo functionalities for all commands 2025-08-01 19:25:34 +02:00
3ca23449e4 Fixed unit tests 2025-08-01 18:55:40 +02:00
a6f765c624 Updated gitignore to remove .idea files 2025-08-01 09:16:03 +02:00
43e7dd5f00 Remove .idea directory from tracking 2025-08-01 09:15:17 +02:00
37c91d0d5d Another implementation of undo/redo 2025-07-31 22:54:09 +02:00
Kodjo Sossouvi
72f5f30da6 Working on undo redo 2025-07-25 17:22:18 +02:00
fb82365980 Improving functionalities and adding unit tests 2025-07-25 09:50:36 +02:00
aa8aa8f58c Working on undo redo capabilities 2025-07-24 23:41:27 +02:00
1ceddfac7c Fixed double shift implementation. Added implicit renaming 2025-07-24 21:42:13 +02:00
Kodjo Sossouvi
34f959812b Added extra fields management for Jira search 2025-07-24 17:52:42 +02:00
Kodjo Sossouvi
48b5c057f0 Fixed Jira icon color. Jira input details is now dynamic 2025-07-23 17:25:53 +02:00
Kodjo Sossouvi
0d7b94a045 Added Jira connectivity testing. Added alert management in AdminForm 2025-07-22 18:23:01 +02:00
e793aeda95 I can have different types of JIRA query 2025-07-21 23:21:40 +02:00
a0cf5aff0c Working implementation of DefaultDataPresenter 2025-07-20 19:17:55 +02:00
d064a553dd Adding Jira DataProcessor 2025-07-14 16:57:14 +02:00
6f17f6ee1f Fixed unit tests 2025-07-14 15:22:56 +02:00
ed793995fb Fixed unit tests 2025-07-13 18:11:17 +02:00
f3deeaefd1 Adding unit tests to WorkflowPlayer.py 2025-07-13 12:23:25 +02:00
fdf05edec3 Adding unit tests to WorkflowPlayer.py 2025-07-12 18:40:36 +02:00
bdd954b243 Improving error management 2025-07-12 17:45:30 +02:00
2754312141 Adding visual return when error 2025-07-12 09:52:56 +02:00
d0f7536fa0 Adding error management 2025-07-11 19:03:08 +02:00
2b288348e2 Adding error management 2025-07-11 18:34:04 +02:00
126 changed files with 7478 additions and 586 deletions

13
.gitignore vendored
View File

@@ -8,9 +8,20 @@ htmlcov
.venv
tests/settings_from_unit_testing.json
tests/TestDBEngineRoot
tests/*.png
src/*.png
tests/*.html
tests/*.txt
test-results
.sesskey
tools.db
.mytools_db
.idea/MyManagingTools.iml
.idea/misc.xml
.idea/dataSources.xml
.idea/sqldialects.xml
.idea_bak
**/*.prof
# Created by .ignore support plugin (hsz.mobi)
### Python template
@@ -196,4 +207,4 @@ fabric.properties
.idea/caches/build_file_checksums.ser
# idea folder, uncomment if you don't need it
# .idea
# .idea

3
.idea/.gitignore generated vendored
View File

@@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml

View File

@@ -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>

View File

@@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

7
.idea/misc.xml generated
View File

@@ -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>

2
.idea/vcs.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -1,4 +1,5 @@
.PHONY: test
.PHONY: test install-playwright install-playwright-deps test-e2e test-e2e-headed test-regression
test:
pytest
@@ -7,6 +8,34 @@ coverage:
coverage run --source=src -m pytest
coverage html
install-playwright:
@echo "Installing Playwright and pytest-playwright via pip..."
pip install playwright pytest-playwright
@echo "Installing Playwright browsers..."
playwright install
@echo "Installing system dependencies for Ubuntu/WSL2..."
playwright install-deps
@echo "Playwright installation complete!"
install-playwright-deps:
@echo "Installing system dependencies for Playwright on Ubuntu/WSL2..."
playwright install-deps
test-e2e:
pytest tests/test_e2e_regression.py -m "e2e" --browser chromium
test-e2e-headed:
pytest -m "e2e" --browser chromium --headed
test-e2e-headed-slowmo:
pytest -m "priority1" --browser chromium --headed --slowmo=2000
test-regression:
pytest tests/test_e2e_regression.py -m "regression" --browser chromium
test-all-browsers:
pytest tests/test_e2e_regression.py -m "e2e" --browser chromium --browser firefox --browser webkit
clean:
rm -rf build
rm -rf htmlcov
@@ -18,7 +47,12 @@ clean:
rm -rf Untitled*.ipynb
rm -rf .ipynb_checkpoints
rm -rf src/tools.db
rm -rf src/*.out
rm -rf src/*.prof
rm -rf tests/debug*.txt
rm -rf tests/debug*.html
rm -rf tests/debug*.png
find . -name '.sesskey' -exec rm -rf {} +
find . -name '.pytest_cache' -exec rm -rf {} +
find . -name '__pycache__' -exec rm -rf {} +
find . -name 'debug.txt' -exec rm -rf {}
find . -name 'debug.txt' -exec rm -rf {}

View File

@@ -14,7 +14,7 @@ python main.py
```shell
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):
```shell
@@ -34,4 +34,25 @@ docker-compose down
1. **Rebuild**:
```shell
docker-compose build
```
```
# Profiling
```shell
cd src
python -m cProfile -o profile.out main.py
snakeviz profile.out # 'pip install snakeviz' if snakeviz is not installed
```
# End to end testing
```shell
make install-playwright
```
Alternatively, you can install Playwright and pytest-playwright via pip:
```shell
pip install playwright pytest-playwright
playwright install
playwright install-deps # may be required on Linux
playwright --version
```

11
pytest.ini Normal file
View File

@@ -0,0 +1,11 @@
[pytest]
python_files = test_*.py
python_classes = Test*
python_functions = test_*
testpaths = tests
pythonpath = src tests
addopts = --tb=short -v
markers =
e2e: marks tests as end-to-end tests
smoke: marks tests as smoke tests
regression: marks tests as regression tests

View File

@@ -2,6 +2,7 @@ annotated-types==0.7.0
anyio==4.6.0
apsw==3.50.2.0
apswutils==0.1.0
Arpeggio==2.0.2
beautifulsoup4==4.12.3
certifi==2024.8.30
charset-normalizer==3.4.2
@@ -9,6 +10,8 @@ click==8.1.7
et-xmlfile==1.1.0
fastcore==1.8.5
fastlite==0.2.1
gprof2dot==2025.4.14
greenlet==3.2.4
h11==0.14.0
httpcore==1.0.5
httptools==0.6.1
@@ -25,27 +28,37 @@ oauthlib==3.2.2
openpyxl==3.1.5
packaging==24.1
pandas==2.2.3
pandas-stubs==2.3.2.250827
playwright==1.55.0
pluggy==1.5.0
pydantic==2.11.5
pydantic-settings==2.9.1
pydantic_core==2.33.2
pyee==13.0.0
Pygments==2.19.1
pytest==8.3.3
pytest-base-url==2.1.0
pytest-mock==3.14.1
pytest-playwright==0.7.0
python-dateutil==2.9.0.post0
python-dotenv==1.0.1
python-fasthtml==0.12.21
python-multipart==0.0.10
python-slugify==8.0.4
pytz==2024.2
PyYAML==6.0.2
requests==2.32.3
rich==14.0.0
shellingham==1.5.4
six==1.16.0
snakeviz==2.2.2
sniffio==1.3.1
soupsieve==2.6
sqlite-minutils==3.37.0.post3
sse-starlette==2.3.6
starlette==0.38.5
text-unidecode==1.3
tornado==6.5.2
typer==0.16.0
typing-inspection==0.4.1
typing_extensions==4.13.2

View File

@@ -8,6 +8,7 @@
--datagrid-resize-zindex: 1;
--color-splitter: color-mix(in oklab, var(--color-base-content) 50%, #0000);
--color-splitter-active: color-mix(in oklab, var(--color-base-content) 50%, #ffff);
--color-btn-hover: color-mix(in oklab, var(--btn-color, var(--color-base-200)), #000 7%);
}
.mmt-tooltip-container {
@@ -36,6 +37,19 @@
transition: opacity 0.2s ease, visibility 0s linear 0.2s;
}
.mmt-btn {
user-select: none;
border-style: solid;
}
.mmt-btn:hover {
background-color: var(--color-btn-hover);
}
.mmt-btn-disabled {
opacity: 0.5;
/*cursor: not-allowed;*/
}
/* When parent is hovered, show the child elements with this class */
*:hover > .mmt-visible-on-hover {
@@ -63,6 +77,8 @@
width: 24px;
min-width: 24px;
height: 24px;
margin-top: auto;
margin-bottom: auto;
}
.icon-24 svg {

View File

@@ -1,6 +1,11 @@
const tooltipElementId = "mmt-app"
function bindTooltipsWithDelegation() {
// To display the tooltip, the attribute 'data-tooltip' is mandatory => it contains the text to tooltip
// Then
// the 'truncate' to show only when the text is truncated
// the class 'mmt-tooltip' for force the display
const elementId = tooltipElementId
console.debug("bindTooltips on element " + elementId);
@@ -20,11 +25,19 @@ function bindTooltipsWithDelegation() {
// Add a single mouseenter and mouseleave listener to the parent element
element.addEventListener("mouseenter", (event) => {
//console.debug("Entering element", event.target)
const cell = event.target.closest("[data-tooltip]");
if (!cell) return;
if (!cell) {
// console.debug(" No 'data-tooltip' attribute found. Stopping.");
return;
}
const no_tooltip = element.hasAttribute("mmt-no-tooltip");
if (no_tooltip) return;
if (no_tooltip) {
// console.debug(" Attribute 'mmt-no-tooltip' found. Cancelling.");
return;
}
const content = cell.querySelector(".truncate") || cell;
const isOverflowing = content.scrollWidth > content.clientWidth;

View File

@@ -22,6 +22,7 @@ class AuthManager:
session["username"] = user_data["username"]
session["user_email"] = user_data["email"]
session["is_admin"] = bool(user_data["is_admin"])
session["roles"] = UserDAO.get_user_roles_by_id(user_data["id"])
@staticmethod
def logout_user(session) -> None:
@@ -130,4 +131,3 @@ class AuthManager:
"user_email": "admin@mmt.com",
"is_admin": True
}

View File

@@ -1,4 +1,4 @@
from core.utils import get_user_id
from core.utils import get_user_id, get_unique_id
class BaseComponent:
@@ -6,9 +6,10 @@ class BaseComponent:
Base class for all components that need to have a session and an id
"""
def __init__(self, session, _id=None, **kwargs):
def __init__(self, session, _id=None, role_name=None, **kwargs):
self._session = session
self._id = _id or self.create_component_id(session)
self._role_name = role_name
def get_id(self):
return self._id
@@ -34,6 +35,17 @@ class BaseComponent:
@staticmethod
def create_component_id(session):
pass
def render(self):
pass
def __ft__(self):
if (not self._session["is_admin"] and
self._role_name is not None and
self._role_name not in self._session["roles"]):
return None
return self.render()
class BaseComponentSingleton(BaseComponent):
@@ -43,11 +55,20 @@ class BaseComponentSingleton(BaseComponent):
COMPONENT_INSTANCE_ID = None
def __init__(self, session, _id=None, settings_manager=None, tabs_manager=None, **kwargs):
super().__init__(session, _id, **kwargs)
def __init__(self, session, _id=None, role_name=None, settings_manager=None, tabs_manager=None, **kwargs):
super().__init__(session, _id, role_name,**kwargs)
self._settings_manager = settings_manager
self.tabs_manager = tabs_manager
@classmethod
def create_component_id(cls, session):
return f"{cls.COMPONENT_INSTANCE_ID}{session['user_id']}"
class BaseComponentMultipleInstance(BaseComponent):
COMPONENT_INSTANCE_ID = None
@classmethod
def create_component_id(cls, session):
component_id = cls.COMPONENT_INSTANCE_ID or cls.__name__
return get_unique_id(f"{component_id}{session['user_id']}")

View File

@@ -1,18 +1,18 @@
from fasthtml.components import *
from components.BaseComponent import BaseComponent
from components.addstuff.constants import ADD_STUFF_INSTANCE_ID
from components.addstuff.constants import ADD_STUFF_INSTANCE_ID, ADD_STUFF_ROLE
from components.repositories.components.Repositories import Repositories
from core.instance_manager import InstanceManager
class AddStuffMenu(BaseComponent):
def __init__(self, session: dict, _id: str, settings_manager=None, tabs_manager=None):
super().__init__(session, _id)
super().__init__(session, _id, ADD_STUFF_ROLE)
self.tabs_manager = tabs_manager # MyTabs component id
self.repositories = InstanceManager.get(session, Repositories.create_component_id(session), Repositories)
def __ft__(self):
def render(self):
return Div(
Div("Add stuff...", tabindex="0"),
Ul(

View File

@@ -1 +1,2 @@
ADD_STUFF_INSTANCE_ID = "__AddStuff__"
ADD_STUFF_ROLE = "add_stuff"

View File

@@ -48,4 +48,28 @@ def post(session, _id: str, content: str):
def post(session, _id: str):
logger.debug(f"Entering {Routes.ImportHolidays} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.import_holidays()
return instance.import_holidays()
@rt(Routes.ConfigureJira)
def get(session, _id: str, boundaries: str):
logger.debug(f"Entering {Routes.ConfigureJira} - GET with args {debug_session(session)}, {_id=}, {boundaries=}")
instance = InstanceManager.get(session, _id)
return instance.show_configure_jira(json.loads(boundaries) if boundaries else None)
@rt(Routes.ConfigureJira)
def post(session, _id: str, args: dict):
logger.debug(f"Entering {Routes.ConfigureJira} - POST with args {debug_session(session)}, {_id=}, {args=}")
instance = InstanceManager.get(session, _id)
return instance.update_jira_settings(args)
@rt(Routes.ConfigureJiraCancel)
def post(session, _id: str):
logger.debug(f"Entering {Routes.ConfigureJiraCancel} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.cancel_jira_settings()
@rt(Routes.ConfigureJiraTest)
def post(session, _id: str, args: dict):
logger.debug(f"Entering {Routes.ConfigureJiraTest} with args {debug_session(session)}, {_id=}, {args=}")
instance = InstanceManager.get(session, _id)
return instance.test_jira_settings(args)

View File

@@ -23,9 +23,16 @@ class AiBuddySettingsEntry:
self.ollama_port = port
@dataclass()
class JiraSettingsEntry:
user_name: str = ""
api_token: str = ""
@dataclass
class AdminSettings:
ai_buddy: AiBuddySettingsEntry = field(default_factory=AiBuddySettingsEntry)
jira: JiraSettingsEntry = field(default_factory=JiraSettingsEntry)
class AdminDbManager:
@@ -37,3 +44,8 @@ class AdminDbManager:
ADMIN_SETTINGS_ENTRY,
AdminSettings,
"ai_buddy")
self.jira = NestedSettingsManager(session,
settings_manager,
ADMIN_SETTINGS_ENTRY,
AdminSettings,
"jira")

View File

@@ -0,0 +1,31 @@
from fastcore.basics import NotStr
icon_jira = NotStr("""<svg name="jira" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>.a{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:2}</style>
</defs>
<path class="a" d="M5.5,22.9722h0a8.7361,8.7361,0,0,0,8.7361,8.7361h2.0556v2.0556A8.7361,8.7361,0,0,0,25.0278,42.5h0V22.9722Z"/>
<path class="a" d="M14.2361,14.2361h0a8.7361,8.7361,0,0,0,8.7361,8.7361h2.0556v2.0556a8.7361,8.7361,0,0,0,8.7361,8.7361h0V14.2361Z"/>
<path class="a" d="M22.9722,5.5h0a8.7361,8.7361,0,0,0,8.7361,8.7361h2.0556v2.0556A8.7361,8.7361,0,0,0,42.5,25.0278h0V5.5Z"/>
</svg>""")
icon_msg_info = NotStr("""<svg name="info" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="h-6 w-6 shrink-0 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
""")
icon_msg_success = NotStr("""<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
""")
icon_msg_warning = NotStr("""<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
""")
icon_msg_error = NotStr("""<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
""")

View File

@@ -38,7 +38,39 @@ class AdminCommandManager(BaseCommandManager):
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", boundaries: getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
}
def show_configure_jira(self):
return {
"hx-get": f"{ROUTE_ROOT}{Routes.ConfigureJira}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", boundaries: getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
}
def save_configure_jira(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.ConfigureJira}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}"}}',
# The form adds the rest
}
def cancel_configure_jira(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.ConfigureJiraCancel}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}"}}',
}
def test_jira(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.ConfigureJiraTest}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}"}}',
}
class ImportHolidaysCommandManager(BaseCommandManager):
def __init__(self, owner):

View File

@@ -4,20 +4,22 @@ from ai.mcp_client import MPC_CLIENTS_IDS
from ai.mcp_tools import MCPServerTools
from components.BaseComponent import BaseComponent
from components.admin.admin_db_manager import AdminDbManager
from components.admin.assets.icons import icon_jira
from components.admin.commands import AdminCommandManager
from components.admin.components.AdminForm import AdminFormItem, AdminFormType, AdminForm
from components.admin.components.AdminForm import AdminFormItem, AdminFormType, AdminForm, AdminButton, AdminMessageType
from components.admin.components.ImportHolidays import ImportHolidays
from components.admin.constants import ADMIN_INSTANCE_ID, ADMIN_AI_BUDDY_INSTANCE_ID, ADMIN_IMPORT_HOLIDAYS_INSTANCE_ID
from components.admin.constants import ADMIN_INSTANCE_ID, ADMIN_AI_BUDDY_INSTANCE_ID, ADMIN_JIRA_INSTANCE_ID, ADMIN_ROLE
from components.aibuddy.assets.icons import icon_brain_ok
from components.hoildays.assets.icons import icon_holidays
from components.tabs.components.MyTabs import MyTabs
from components_helpers import mk_ellipsis, mk_icon
from core.instance_manager import InstanceManager
from core.jira import Jira
class Admin(BaseComponent):
def __init__(self, session, _id: str = None, settings_manager=None, tabs_manager: MyTabs = None):
super().__init__(session, _id)
super().__init__(session, _id, ADMIN_ROLE)
self.settings_manager = settings_manager
self.tabs_manager = tabs_manager
self.commands = AdminCommandManager(self)
@@ -35,7 +37,7 @@ class Admin(BaseComponent):
hooks = {
"on_ok": self.commands.save_ai_buddy(),
"on_cancel": self.commands.cancel_ai_buddy(),
"ok_title": "Apply"
"ok_title": "Apply",
}
form = InstanceManager.get(self._session,
AdminForm.create_component_id(self._session, prefix=self._id),
@@ -59,8 +61,33 @@ class Admin(BaseComponent):
return self._add_tab(ADMIN_AI_BUDDY_INSTANCE_ID, "Admin - Import Holidays", form)
def show_configure_jira(self, boundaries):
fields = [
AdminFormItem('user_name', "Email", "Email used to connect to JIRA.", AdminFormType.TEXT),
AdminFormItem("api_token", "API Key", "API Key to connect to JIRA.", AdminFormType.TEXT),
]
hooks = {
"on_ok": self.commands.save_configure_jira(),
"on_cancel": self.commands.cancel_configure_jira(),
"ok_title": "Apply",
"extra_buttons": [AdminButton("Test", self.commands.test_jira)]
}
form = InstanceManager.get(self._session,
AdminForm.create_component_id(self._session, prefix=self._id),
AdminForm,
owner=self,
title="Jira Configuration Page",
obj=self.db.jira,
form_fields=fields,
hooks=hooks,
key=ADMIN_JIRA_INSTANCE_ID,
boundaries=boundaries
)
return self._add_tab(ADMIN_JIRA_INSTANCE_ID, "Admin - Jira Configuration", form)
def update_ai_buddy_settings(self, values: dict):
values = self.manage_lists(values)
values = AdminForm.get_fields_values(values)
self.db.ai_buddy.update(values, ignore_missing=True)
return self.tabs_manager.render()
@@ -69,7 +96,28 @@ class Admin(BaseComponent):
self.tabs_manager.remove_tab(tab_id)
return self.tabs_manager.render()
def __ft__(self):
def update_jira_settings(self, values: dict):
values = AdminForm.get_fields_values(values)
self.db.jira.update(values, ignore_missing=True)
return self.tabs_manager.render()
def cancel_jira_settings(self):
tab_id = self.tabs_manager.get_tab_id(ADMIN_JIRA_INSTANCE_ID)
self.tabs_manager.remove_tab(tab_id)
return self.tabs_manager.render()
def test_jira_settings(self, values: dict):
values = AdminForm.get_fields_values(values)
jira = Jira(values["user_name"], values["api_token"])
form = self.tabs_manager.get_tab_content_by_key(ADMIN_JIRA_INSTANCE_ID)
res = jira.test()
if res.status_code == 200:
form.set_message("Success !", AdminMessageType.SUCCESS)
else:
form.set_message(f"Error {res.status_code} - {res.text}", AdminMessageType.ERROR)
return self.tabs_manager.render()
def render(self):
return Div(
Div(cls="divider"),
Div(mk_ellipsis("Admin", cls="text-sm font-medium mb-1 mr-3")),
@@ -84,6 +132,11 @@ class Admin(BaseComponent):
mk_ellipsis("holidays", cls="text-sm", **self.commands.show_import_holidays()),
cls="flex p-0 min-h-0 truncate",
),
Div(
mk_icon(icon_jira, can_select=False),
mk_ellipsis("jira", cls="text-sm", **self.commands.show_configure_jira()),
cls="flex p-0 min-h-0 truncate",
),
#
# cls=""),
# Script(f"bindAdmin('{self._id}')"),
@@ -97,40 +150,3 @@ class Admin(BaseComponent):
@staticmethod
def create_component_id(session):
return f"{ADMIN_INSTANCE_ID}{session['user_id']}"
@staticmethod
def manage_lists(data_dict):
"""
Processes a dictionary of key-value pairs to reorganize keys based on specific
criteria. If a key ends with its corresponding string value, the method extracts
the prefix of the key (the portion of the key before the value) and groups the
value under this prefix in a list. Otherwise, the original key-value pair is
preserved in the resulting dictionary.
:param data_dict: Dictionary where each key is a string and its corresponding
value can be of any type.
:type data_dict: dict
:return: A dictionary where the keys have been categorized into groups
based on whether they end with the same string value, reorganized into
lists, while preserving other key-value pairs as they are.
:rtype: dict
"""
result_dict = {}
for key, value in data_dict.items():
# Check if the value is a string and the key ends with the value
if isinstance(value, str) and key.endswith(value):
# Find the beginning part of the key (before the value)
prefix = key.replace(value, '').rstrip('_')
# Add the value to the list under the prefix key
if prefix not in result_dict:
result_dict[prefix] = []
result_dict[prefix].append(value)
else:
result_dict[key] = value
return result_dict

View File

@@ -1,10 +1,12 @@
from dataclasses import dataclass
from typing import Any
from typing import Any, Callable
from fasthtml.components import *
from assets.icons import icon_error
from components.BaseComponent import BaseComponent
from components_helpers import apply_boundaries, mk_dialog_buttons, safe_get_dialog_buttons_parameters
from components.admin.assets.icons import icon_msg_success, icon_msg_info, icon_msg_error, icon_msg_warning
from components_helpers import apply_boundaries, mk_dialog_buttons, safe_get_dialog_buttons_parameters, mk_icon
from core.utils import get_unique_id
@@ -18,6 +20,14 @@ class AdminFormType:
TEXTAREA = "textarea"
class AdminMessageType:
NONE = "none"
SUCCESS = "success"
ERROR = "error"
INFO = "info"
WARNING = "warning"
@dataclass
class AdminFormItem:
name: str
@@ -27,6 +37,12 @@ class AdminFormItem:
possible_values: list[str] = None
@dataclass
class AdminButton:
title: str
on_click: Callable = None
class AdminForm(BaseComponent):
def __init__(self, session, _id, owner, title: str, obj: Any, form_fields: list[AdminFormItem], hooks=None, key=None,
boundaries=None):
@@ -38,6 +54,21 @@ class AdminForm(BaseComponent):
self.title = title
self.obj = obj
self.form_fields = form_fields
self.message = None
def set_message(self, message, msg_type: AdminMessageType.NONE):
if msg_type == AdminMessageType.NONE:
self.message = message
else:
if msg_type == AdminMessageType.SUCCESS:
icon = icon_msg_success
elif msg_type == AdminMessageType.ERROR:
icon = icon_msg_error
elif msg_type == AdminMessageType.WARNING:
icon = icon_msg_warning
else:
icon = icon_msg_info
self.message = Div(icon, Span(message), role=msg_type, cls=f"alert alert-{msg_type} mr-2")
def mk_input(self, item: AdminFormItem):
return Input(
@@ -62,7 +93,7 @@ class AdminForm(BaseComponent):
cls="checkbox checkbox-xs",
checked=value in current_values
),
cls="checkbox-item") for value in item.possible_values]
return Div(*checkbox_items, cls="adm-items-group")
@@ -95,9 +126,20 @@ class AdminForm(BaseComponent):
else:
return self.mk_input(item)
def mk_extra_buttons(self):
extra_buttons = self._hooks.get("extra_buttons", None)
if not extra_buttons:
return None
return Div(
*[Button(btn.title, cls="btn btn-ghost btn-sm", **btn.on_click()) for btn in extra_buttons],
cls="flex justify-end"
)
def __ft__(self):
return Form(
Fieldset(Legend(self.title, cls="fieldset-legend"),
Div(self.message),
*[
Div(
Label(item.title, cls="label"),
@@ -107,6 +149,7 @@ class AdminForm(BaseComponent):
for item in self.form_fields
],
self.mk_extra_buttons(),
mk_dialog_buttons(**safe_get_dialog_buttons_parameters(self._hooks)),
**apply_boundaries(self._boundaries),
cls="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4"
@@ -119,3 +162,40 @@ class AdminForm(BaseComponent):
suffix = get_unique_id()
return f"{prefix}{suffix}"
@staticmethod
def get_fields_values(data_dict):
"""
Processes a dictionary of key-value pairs to reorganize keys based on specific
criteria. If a key ends with its corresponding string value, the method extracts
the prefix of the key (the portion of the key before the value) and groups the
value under this prefix in a list. Otherwise, the original key-value pair is
preserved in the resulting dictionary.
:param data_dict: Dictionary where each key is a string and its corresponding
value can be of any type.
:type data_dict: dict
:return: A dictionary where the keys have been categorized into groups
based on whether they end with the same string value, reorganized into
lists, while preserving other key-value pairs as they are.
:rtype: dict
"""
result_dict = {}
for key, value in data_dict.items():
# Check if the value is a string and the key ends with the value
if isinstance(value, str) and key.endswith(value):
# Find the beginning part of the key (before the value)
prefix = key.replace(value, '').rstrip('_')
# Add the value to the list under the prefix key
if prefix not in result_dict:
result_dict[prefix] = []
result_dict[prefix].append(value)
else:
result_dict[key] = value
return result_dict

View File

@@ -1,6 +1,8 @@
ADMIN_INSTANCE_ID = "__Admin__"
ADMIN_ROLE = "admin"
ADMIN_AI_BUDDY_INSTANCE_ID = "__AdminAIBuddy__"
ADMIN_IMPORT_HOLIDAYS_INSTANCE_ID = "__AdminImportHolidays__"
ADMIN_JIRA_INSTANCE_ID = "__AdminJira__"
ROUTE_ROOT = "/admin"
ADMIN_SETTINGS_ENTRY = "Admin"
@@ -8,4 +10,7 @@ class Routes:
AiBuddy = "/ai-buddy"
AiBuddyCancel = "/ai-buddy-cancel"
ImportHolidays = "/import-holidays"
PasteHolidays = "/paste-holidays"
PasteHolidays = "/paste-holidays"
ConfigureJira = "/configure-jira"
ConfigureJiraCancel = "/configure-jira-cancel"
ConfigureJiraTest = "/configure-jira-test"

View File

@@ -39,7 +39,9 @@ function bindAIBuddy(elementId) {
event.preventDefault();
makeAIRequest();
}
});
document.addEventListener('keyup', (event) => {
if (event.key === 'Shift') {
const currentTime = new Date().getTime();
if (currentTime - lastShiftPress <= doublePressDelay) {

View File

@@ -21,7 +21,7 @@ from core.settings_management import GenericDbManager
class AIBuddy(BaseComponent):
def __init__(self, session, _id: str = None, settings_manager=None, tabs_manager=None):
super().__init__(session, _id)
super().__init__(session, _id, AI_BUDDY_ROLE)
self.settings_manager = settings_manager
self.db = GenericDbManager(session, settings_manager, AI_BUDDY_SETTINGS_ENTRY, AIBuddySettings)
self.tabs_manager = tabs_manager
@@ -153,7 +153,7 @@ class AIBuddy(BaseComponent):
for name, tool in available_tools.items()
]
def __ft__(self):
def render(self):
return Div(
Div(cls="divider"),
Div(

View File

@@ -1,4 +1,5 @@
AI_BUDDY_INSTANCE_ID = "__AIBuddy__"
AI_BUDDY_ROLE = "ai_buddy"
ROUTE_ROOT = "/ai"

View File

@@ -2,7 +2,7 @@ from fasthtml.components import *
from components.BaseComponent import BaseComponent
from components.applications.commands import Commands
from components.applications.constants import APPLICATION_INSTANCE_ID
from components.applications.constants import APPLICATION_INSTANCE_ID, APPLICATION_ROLE
from components.hoildays.assets.icons import icon_holidays
from components.hoildays.components.HolidaysViewer import HolidaysViewer
from components.hoildays.constants import HOLIDAYS_VIEWER_INSTANCE_ID
@@ -12,7 +12,7 @@ from core.instance_manager import InstanceManager
class Applications(BaseComponent):
def __init__(self, session: dict, _id: str, settings_manager=None, tabs_manager=None):
super().__init__(session, _id)
super().__init__(session, _id, APPLICATION_ROLE)
self.tabs_manager = tabs_manager
self.settings_manager = settings_manager
self.commands = Commands(self)
@@ -30,7 +30,7 @@ class Applications(BaseComponent):
raise NotImplementedError(app_name)
def __ft__(self):
def render(self):
return Div(
Div(cls="divider"),

View File

@@ -1,4 +1,5 @@
APPLICATION_INSTANCE_ID = "__Applications__"
APPLICATION_ROLE = "applications"
ROUTE_ROOT = "/apps"

View File

@@ -136,3 +136,10 @@ def post(session, _id: str, state: str, args: str = None):
logger.debug(f"Entering on_state_changed with args {_id=}, {state=}, {args=}")
instance = InstanceManager.get(session, _id)
return instance.manage_state_changed(state, args)
@rt(Routes.GetPage)
def get(session, _id: str, page_index: int):
logger.debug(f"Entering {Routes.GetPage} with args {_id=}, {page_index=}")
instance = InstanceManager.get(session, _id)
return instance.mk_body_content_page(page_index)

View File

@@ -1,6 +1,6 @@
function bindDatagrid(datagridId, allowColumnsReordering) {
bindScrollbars(datagridId);
makeResizable(datagridId)
manageScrollbars(datagridId, true);
makeResizable(datagridId);
}
function bindScrollbars(datagridId) {
@@ -21,7 +21,7 @@ function bindScrollbars(datagridId) {
const table = datagrid.querySelector(".dt2-table");
if (!verticalScrollbar || !verticalWrapper || !horizontalScrollbar || !horizontalWrapper || !body || !table) {
console.error("Essential scrollbar or content elements are missing in the datagrid.");
console.error("Essential scrollbars or content elements are missing in the datagrid.");
return;
}
@@ -176,6 +176,224 @@ function bindScrollbars(datagridId) {
});
}
function manageScrollbars(datagridId, binding) {
console.debug("manageScrollbars on element " + datagridId + " with binding=" + binding);
const datagrid = document.getElementById(datagridId);
if (!datagrid) {
console.error(`Datagrid with id "${datagridId}" not found.`);
return;
}
const verticalScrollbar = datagrid.querySelector(".dt2-scrollbars-vertical");
const verticalWrapper = datagrid.querySelector(".dt2-scrollbars-vertical-wrapper");
const horizontalScrollbar = datagrid.querySelector(".dt2-scrollbars-horizontal");
const horizontalWrapper = datagrid.querySelector(".dt2-scrollbars-horizontal-wrapper");
const body = datagrid.querySelector(".dt2-body");
const table = datagrid.querySelector(".dt2-table");
if (!verticalScrollbar || !verticalWrapper || !horizontalScrollbar || !horizontalWrapper || !body || !table) {
console.error("Essential scrollbars or content elements are missing in the datagrid.");
return;
}
const computeScrollbarVisibility = () => {
// Determine if the content is clipped
const isVerticalRequired = body.scrollHeight > body.clientHeight;
const isHorizontalRequired = table.scrollWidth > table.clientWidth;
// Show or hide the scrollbar wrappers
requestAnimationFrame(() => {
verticalWrapper.style.display = isVerticalRequired ? "block" : "none";
horizontalWrapper.style.display = isHorizontalRequired ? "block" : "none";
});
};
const computeScrollbarSize = () => {
// Vertical scrollbar height
const visibleHeight = body.clientHeight;
const totalHeight = body.scrollHeight;
const wrapperHeight = verticalWrapper.offsetHeight;
let scrollbarHeight = 0;
if (totalHeight > 0) {
scrollbarHeight = (visibleHeight / totalHeight) * wrapperHeight;
}
// Horizontal scrollbar width
const visibleWidth = table.clientWidth;
const totalWidth = table.scrollWidth;
const wrapperWidth = horizontalWrapper.offsetWidth;
let scrollbarWidth = 0;
if (totalWidth > 0) {
scrollbarWidth = (visibleWidth / totalWidth) * wrapperWidth;
}
requestAnimationFrame(() => {
verticalScrollbar.style.height = `${scrollbarHeight}px`;
horizontalScrollbar.style.width = `${scrollbarWidth}px`;
});
};
const updateVerticalScrollbarForMouseWheel = () => {
const maxScrollTop = body.scrollHeight - body.clientHeight;
const wrapperHeight = verticalWrapper.offsetHeight;
if (maxScrollTop > 0) {
const scrollRatio = wrapperHeight / body.scrollHeight;
verticalScrollbar.style.top = `${body.scrollTop * scrollRatio}px`;
}
};
if (binding) {
// Clean up existing managers if they exist
if (datagrid._managers) {
// Remove drag events
if (datagrid._managers.dragManager) {
verticalScrollbar.removeEventListener("mousedown", datagrid._managers.dragManager.verticalMouseDown);
horizontalScrollbar.removeEventListener("mousedown", datagrid._managers.dragManager.horizontalMouseDown);
document.removeEventListener("mousemove", datagrid._managers.dragManager.mouseMove);
document.removeEventListener("mouseup", datagrid._managers.dragManager.mouseUp);
}
// Remove wheel events
if (datagrid._managers.wheelManager) {
body.removeEventListener("wheel", datagrid._managers.wheelManager.handleWheelScrolling);
}
// Remove resize events
if (datagrid._managers.resizeManager) {
window.removeEventListener("resize", datagrid._managers.resizeManager.handleResize);
}
}
// Create managers
const dragManager = {
isDragging: false,
startY: 0,
startX: 0,
updateVerticalScrollbar: (deltaX, deltaY) => {
const wrapperHeight = verticalWrapper.offsetHeight;
const scrollbarHeight = verticalScrollbar.offsetHeight;
const maxScrollTop = body.scrollHeight - body.clientHeight;
const scrollRatio = maxScrollTop / (wrapperHeight - scrollbarHeight);
let newTop = parseFloat(verticalScrollbar.style.top || "0") + deltaY;
newTop = Math.max(0, Math.min(newTop, wrapperHeight - scrollbarHeight));
verticalScrollbar.style.top = `${newTop}px`;
body.scrollTop = newTop * scrollRatio;
},
updateHorizontalScrollbar: (deltaX, deltaY) => {
const wrapperWidth = horizontalWrapper.offsetWidth;
const scrollbarWidth = horizontalScrollbar.offsetWidth;
const maxScrollLeft = table.scrollWidth - table.clientWidth;
const scrollRatio = maxScrollLeft / (wrapperWidth - scrollbarWidth);
let newLeft = parseFloat(horizontalScrollbar.style.left || "0") + deltaX;
newLeft = Math.max(0, Math.min(newLeft, wrapperWidth - scrollbarWidth));
horizontalScrollbar.style.left = `${newLeft}px`;
table.scrollLeft = newLeft * scrollRatio;
},
verticalMouseDown: (e) => {
disableTooltip();
dragManager.isDragging = true;
dragManager.startY = e.clientY;
dragManager.startX = e.clientX;
document.body.style.userSelect = "none";
verticalScrollbar.classList.add("dt2-dragging");
},
horizontalMouseDown: (e) => {
disableTooltip();
dragManager.isDragging = true;
dragManager.startY = e.clientY;
dragManager.startX = e.clientX;
document.body.style.userSelect = "none";
horizontalScrollbar.classList.add("dt2-dragging");
},
mouseMove: (e) => {
if (dragManager.isDragging) {
const deltaY = e.clientY - dragManager.startY;
const deltaX = e.clientX - dragManager.startX;
// Determine which scrollbar is being dragged
if (verticalScrollbar.classList.contains("dt2-dragging")) {
dragManager.updateVerticalScrollbar(deltaX, deltaY);
} else if (horizontalScrollbar.classList.contains("dt2-dragging")) {
dragManager.updateHorizontalScrollbar(deltaX, deltaY);
}
// Reset start points for next update
dragManager.startY = e.clientY;
dragManager.startX = e.clientX;
}
},
mouseUp: () => {
dragManager.isDragging = false;
document.body.style.userSelect = "";
verticalScrollbar.classList.remove("dt2-dragging");
horizontalScrollbar.classList.remove("dt2-dragging");
enableTooltip();
}
};
const wheelManager = {
handleWheelScrolling: (event) => {
const deltaX = event.deltaX;
const deltaY = event.deltaY;
// Scroll the body and table content
body.scrollTop += deltaY; // Vertical scrolling
table.scrollLeft += deltaX; // Horizontal scrolling
// Update the vertical scrollbar position
updateVerticalScrollbarForMouseWheel();
// Prevent default behavior to fully manage the scroll
event.preventDefault();
}
};
const resizeManager = {
handleResize: () => {
computeScrollbarVisibility();
computeScrollbarSize();
updateVerticalScrollbarForMouseWheel();
}
};
// Store managers on datagrid for cleanup
datagrid._managers = {
dragManager,
wheelManager,
resizeManager
};
// Bind events
verticalScrollbar.addEventListener("mousedown", dragManager.verticalMouseDown);
horizontalScrollbar.addEventListener("mousedown", dragManager.horizontalMouseDown);
document.addEventListener("mousemove", dragManager.mouseMove);
document.addEventListener("mouseup", dragManager.mouseUp);
body.addEventListener("wheel", wheelManager.handleWheelScrolling, {passive: false});
window.addEventListener("resize", resizeManager.handleResize);
}
// Always execute computations
computeScrollbarVisibility();
computeScrollbarSize();
}
function makeResizable(datagridId) {
console.debug("makeResizable on element " + datagridId);
@@ -494,4 +712,5 @@ function onAfterSettle(datagridId, event) {
if (response.includes("hx-on::before-settle")) {
bindDatagrid(datagridId)
}
}
}

View File

@@ -1,4 +1,5 @@
import copy
import html
import logging
from io import BytesIO
from typing import Literal, Any
@@ -20,9 +21,10 @@ from components.datagrid_new.db_management import DataGridDbManager
from components.datagrid_new.settings import DataGridRowState, DataGridColumnState, \
DataGridFooterConf, DataGridState, DataGridSettings, DatagridView
from components_helpers import mk_icon, mk_ellipsis
from core.fasthtml_helper import MyDiv, mk_my_ellipsis, MySpan, mk_my_icon
from core.instance_manager import InstanceManager
from core.settings_management import SettingsManager
from core.utils import get_unique_id, make_safe_id
from core.utils import get_unique_id, make_safe_id, timed
logger = logging.getLogger("DataGrid")
@@ -59,6 +61,8 @@ class DataGrid(BaseComponent):
self._state: DataGridState = self._db.load_state()
self._settings: DataGridSettings = grid_settings or self._db.load_settings()
self._df: DataFrame | None = self._db.load_dataframe()
self._fast_access = self._init_fast_access(self._df)
self._total_rows = len(self._df) if self._df is not None else 0
# update boundaries if possible
self.set_boundaries(boundaries)
@@ -118,14 +122,23 @@ class DataGrid(BaseComponent):
else:
return ColumnType.Text # Default to Text if no match
def _init_columns(_df):
columns = [DataGridColumnState(make_safe_id(col_id),
col_index,
col_id,
_get_column_type(self._df[make_safe_id(col_id)].dtype))
for col_index, col_id in enumerate(_df.columns)]
if self._state.row_index:
columns.insert(0, DataGridColumnState(make_safe_id(ROW_INDEX_ID), -1, " ", ColumnType.RowIndex))
return columns
self._df = df.copy()
self._df.columns = self._df.columns.map(make_safe_id) # make sure column names are trimmed
self._state.rows = [DataGridRowState(row_id) for row_id in self._df.index]
self._state.columns = [DataGridColumnState(make_safe_id(col_id),
col_index,
col_id,
_get_column_type(self._df[make_safe_id(col_id)].dtype))
for col_index, col_id in enumerate(df.columns)]
self._state.columns = _init_columns(df) # use df not self._df to keep the original title
self._fast_access = self._init_fast_access(self._df)
self._total_rows = len(self._df) if self._df is not None else 0
if save_state:
self._db.save_all(None, self._state, self._df)
@@ -205,6 +218,7 @@ class DataGrid(BaseComponent):
self._state.columns = new_columns_states
self._fast_access = self._init_fast_access(self._df)
self._views.recompute_need_save()
self._db.save_all(self._settings, self._state, self._df if new_column else None)
@@ -439,7 +453,7 @@ class DataGrid(BaseComponent):
_mk_keyboard_management(),
Div(
self.mk_table_header(),
self.mk_table_body(),
self.mk_table_body_page(),
self.mk_table_footer(),
cls="dt2-inner-table"),
cls="dt2-table",
@@ -479,20 +493,18 @@ class DataGrid(BaseComponent):
id=f"th_{self._id}"
)
def mk_table_body(self):
df = self._get_filtered_df()
def mk_table_body_page(self):
"""
This function is used to update the table body when the vertical scrollbar reaches the end
A new page is added when requested
"""
max_height = self._compute_body_max_height()
return Div(
*[Div(
*[self.mk_body_cell(col_pos, row_index, col_def) for col_pos, col_def in enumerate(self._state.columns)],
cls="dt2-row",
data_row=f"{row_index}",
id=f"tr_{self._id}-{row_index}",
) for row_index in df.index],
*self.mk_body_content_page(0),
cls="dt2-body",
style=f"max-height:{max_height}px;",
id=f"tb_{self._id}"
id=f"tb_{self._id}",
)
def mk_table_footer(self):
@@ -507,34 +519,55 @@ class DataGrid(BaseComponent):
id=f"tf_{self._id}"
)
def mk_body_content_page(self, page_index: int):
df = self._get_filtered_df()
start = page_index * DATAGRID_PAGE_SIZE
end = start + DATAGRID_PAGE_SIZE
if self._total_rows > end:
last_row = df.index[end - 1]
else:
last_row = None
rows = [Div(
*[self.mk_body_cell(col_pos, row_index, col_def) for col_pos, col_def in enumerate(self._state.columns)],
cls="dt2-row",
data_row=f"{row_index}",
id=f"tr_{self._id}-{row_index}",
**self.commands.get_page(page_index + 1) if row_index == last_row else {}
) for row_index in df.index[start:end]]
rows.append(Script(f"manageScrollbars('{self._id}', false);"), )
return rows
def mk_body_cell(self, col_pos, row_index, col_def: DataGridColumnState):
if not col_def.usable:
return None
if not col_def.visible:
return Div(cls="dt2-col-hidden")
return MyDiv(cls="dt2-col-hidden")
content = self.mk_body_cell_content(col_pos, row_index, col_def)
return Div(content,
data_col=col_def.col_id,
style=f"width:{col_def.width}px;",
cls="dt2-cell")
return MyDiv(content,
data_col=col_def.col_id,
style=f"width:{col_def.width}px;",
cls="dt2-cell")
def mk_body_cell_content(self, col_pos, row_index, col_def: DataGridColumnState):
def mk_bool(value):
return Div(mk_icon(icon_checked if value else icon_unchecked, can_select=False),
cls="dt2-cell-content-checkbox")
def mk_bool(_value):
return MyDiv(mk_my_icon(icon_checked if _value else icon_unchecked, can_select=False),
cls="dt2-cell-content-checkbox")
def mk_text(value):
return mk_ellipsis(value, cls="dt2-cell-content-text")
def mk_text(_value):
return mk_my_ellipsis(_value, cls="dt2-cell-content-text")
def mk_number(value):
return mk_ellipsis(value, cls="dt2-cell-content-number")
def mk_number(_value):
return mk_my_ellipsis(_value, cls="dt2-cell-content-number")
def process_cell_content(value):
value_str = str(value)
def process_cell_content(_value):
value_str = html.escape(str(_value))
if FILTER_INPUT_CID not in self._state.filtered or (
keyword := self._state.filtered[FILTER_INPUT_CID]) is None:
@@ -545,21 +578,22 @@ class DataGrid(BaseComponent):
return value_str
len_keyword = len(keyword)
res = [Span(value_str[:index])] if index > 0 else []
res += [Span(value_str[index:index + len_keyword], cls="dt2-highlight-1")]
res += [Span(value_str[index + len_keyword:])] if len(value_str) > len_keyword else []
res = [MySpan(value_str[:index])] if index > 0 else []
res += [MySpan(value_str[index:index + len_keyword], cls="dt2-highlight-1")]
res += [MySpan(value_str[index + len_keyword:])] if len(value_str) > len_keyword else []
return tuple(res)
column_type = col_def.type
value = self._fast_access[col_def.col_id][row_index]
if column_type == ColumnType.Bool:
content = mk_bool(self._df.iloc[row_index, col_def.col_index])
content = mk_bool(value)
elif column_type == ColumnType.Number:
content = mk_number(process_cell_content(self._df.iloc[row_index, col_def.col_index]))
content = mk_number(process_cell_content(value))
elif column_type == ColumnType.RowIndex:
content = mk_number(row_index)
else:
content = mk_text(process_cell_content(self._df.iloc[row_index, col_def.col_index]))
content = mk_text(process_cell_content(value))
return content
@@ -822,6 +856,31 @@ class DataGrid(BaseComponent):
return True
@staticmethod
def _init_fast_access(df):
"""
Generates a fast-access dictionary for a DataFrame.
This method converts the columns of the provided DataFrame into NumPy arrays
and stores them as values in a dictionary, using the column names as keys.
This allows for efficient access to the data stored in the DataFrame.
Args:
df (DataFrame): The input pandas DataFrame whose columns are to be converted
into a dictionary of NumPy arrays.
Returns:
dict: A dictionary where the keys are the column names of the input DataFrame
and the values are the corresponding column values as NumPy arrays.
"""
if df is None:
return {}
res = {col: df[col].to_numpy() for col in df.columns}
res[ROW_INDEX_ID] = df.index.to_numpy()
return res
@timed
def __ft__(self):
return Div(
Div(
@@ -844,7 +903,7 @@ class DataGrid(BaseComponent):
@staticmethod
def new(session, data, index=None):
datagrid = DataGrid(session, DataGrid.create_component_id(session))
#dataframe = DataFrame(data, index=index)
# dataframe = DataFrame(data, index=index)
dataframe = DataFrame(data)
datagrid.init_from_dataframe(dataframe)
return datagrid

View File

@@ -91,12 +91,21 @@ class DataGridCommandManager(BaseCommandManager):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.OnClick}",
"hx-target": f"#tsm_{self._id}",
"hx-trigger" : "click",
"hx-trigger": "click",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{_id: "{self._id}", cell_id:getCellId(event), modifier:getClickModifier(event), boundaries: getCellBoundaries(event)}}',
"hx-on::before-request": f'validateOnClickRequest("{self._id}", event)',
}
def get_page(self, page_index=0):
return {
"hx-get": f"{ROUTE_ROOT}{Routes.GetPage}",
"hx-target": f"#tb_{self._id}",
"hx-swap": "beforeend",
"hx-vals": f'{{"_id": "{self._id}", "page_index": "{page_index}"}}',
"hx-trigger": f"intersect root:#tb_{self._id} once",
}
def _get_hide_show_columns_attrs(self, mode, col_defs: list, new_value, cls=""):
str_col_names = ", ".join(f"'{col_def.title}'" for col_def in col_defs)
tooltip_msg = f"{mode} column{'s' if len(col_defs) > 1 else ''} {str_col_names}"
@@ -109,38 +118,6 @@ class DataGridCommandManager(BaseCommandManager):
"data_tooltip": tooltip_msg,
"cls": self.merge_class(cls, "mmt-tooltip")
}
#
# @staticmethod
# def merge(*items):
# """
# Merges multiple dictionaries into a single dictionary by combining their key-value pairs.
# If a key exists in multiple dictionaries and its value is a string, the values are concatenated.
# If the key's value is not a string, an error is raised.
#
# :param items: dictionaries to be merged. If all items are None, None is returned.
# :return: A single dictionary containing the merged key-value pairs from all input dictionaries.
# :raises NotImplementedError: If a key's value is not a string and exists in multiple input dictionaries.
# """
# if all(item is None for item in items):
# return None
#
# res = {}
# for item in [item for item in items if item is not None]:
#
# for key, value in item.items():
# if not key in res:
# res[key] = value
# else:
# if isinstance(res[key], str):
# res[key] += " " + value
# else:
# raise NotImplementedError("")
#
# return res
#
# @staticmethod
# def merge_class(cls1, cls2):
# return (cls1 + " " + cls2) if cls2 else cls1
class FilterAllCommands(BaseCommandManager):
@@ -165,4 +142,4 @@ class FilterAllCommands(BaseCommandManager):
"hx_vals": f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}',
"data_tooltip": "Reset filter",
"cls": self.merge_class(cls, "mmt-tooltip"),
}
}

View File

@@ -17,6 +17,9 @@ CONTAINER_HEIGHT = "container_height"
DATAGRID_STATE_FOOTER = "footer"
DATAGRID_PAGE_SIZE = 50
ROW_INDEX_ID = "__row_index__"
class Routes:
Filter = "/filter" # request the filtering in the grid
@@ -33,6 +36,7 @@ class Routes:
UpdateView = "/update_view"
ShowFooterMenu = "/show_footer_menu"
UpdateState = "/update_state"
GetPage = "/page"
class ColumnType(Enum):
@@ -44,11 +48,13 @@ class ColumnType(Enum):
Choice = "Choice"
List = "List"
class ViewType(Enum):
Table = "Table"
Chart = "Chart"
Form = "Form"
class FooterAggregation(Enum):
Sum = "Sum"
Mean = "Mean"
@@ -59,4 +65,4 @@ class FooterAggregation(Enum):
FilteredMean = "FilteredMean"
FilteredMin = "FilteredMin"
FilteredMax = "FilteredMax"
FilteredCount = "FilteredCount"
FilteredCount = "FilteredCount"

View File

@@ -36,7 +36,7 @@ class DataGridDbManager:
self._settings_manager.save(self._session, self._get_db_entry(), {})
def _get_db_entry(self):
return f"{DATAGRID_DB_ENTRY}_{self._key}"
return make_safe_id(f"{DATAGRID_DB_ENTRY}_{self._key}")
@staticmethod
def _key_as_string(key):

View File

@@ -69,6 +69,7 @@ class DataGridSettings:
class DataGridState:
sidebar_visible: bool = False
selected_view: str = None
row_index: bool = False
columns: list[DataGridColumnState] = dataclasses.field(default_factory=list)
rows: list[DataGridRowState] = dataclasses.field(default_factory=list) # only the rows that have a specific state
footers: list[DataGridFooterConf] = dataclasses.field(default_factory=list)

View File

@@ -10,7 +10,7 @@ from components.aibuddy.components.AIBuddy import AIBuddy
from components.debugger.assets.icons import icon_dbengine
from components.debugger.commands import Commands
from components.debugger.components.JsonViewer import JsonViewer
from components.debugger.constants import DBENGINE_DEBUGGER_INSTANCE_ID
from components.debugger.constants import DBENGINE_DEBUGGER_INSTANCE_ID, DEBUGGER_ROLE
from components_helpers import mk_ellipsis, mk_accordion_section
from core.instance_manager import InstanceManager
from core.utils import get_unique_id
@@ -20,7 +20,7 @@ logger = logging.getLogger("Debugger")
class Debugger(BaseComponent):
def __init__(self, session, _id, settings_manager, tabs_manager):
super().__init__(session, _id)
super().__init__(session, _id, DEBUGGER_ROLE)
self.settings_manager = settings_manager
self.db_engine = settings_manager.get_db_engine()
self.tabs_manager = tabs_manager
@@ -104,7 +104,7 @@ class Debugger(BaseComponent):
self.tabs_manager.add_tab(title, content, key=tab_key)
return self.tabs_manager.render()
def __ft__(self):
def render(self):
return Div(
Div(cls="divider"),
mk_ellipsis("Debugger", cls="text-sm font-medium mb-1"),

View File

@@ -62,7 +62,7 @@ class JsonViewerHelper:
class JsonViewer(BaseComponent):
def __init__(self, session, _id, owner, user_id, data, hooks=None, key=None, boundaries=None):
super().__init__(session, _id)
self._key = key
self._key = key # for comparison between two jsonviewer components
self._owner = owner # debugger component
self.user_id = user_id
self.data = data
@@ -88,6 +88,10 @@ class JsonViewer(BaseComponent):
self._helper = JsonViewerHelper()
def set_data(self, data):
self.data = data
self.node = self._create_node(None, data)
def set_node_folding(self, node_id, folding):
if folding == self._folding_mode:
self._nodes_to_track.remove(node_id)
@@ -311,8 +315,6 @@ class JsonViewer(BaseComponent):
def __hash__(self):
return hash(self._key) if self._key is not None else super().__hash__()
@staticmethod
def add_quotes(value: str):
if '"' in value and "'" in value:

View File

@@ -1,4 +1,5 @@
DBENGINE_DEBUGGER_INSTANCE_ID = "debugger"
DEBUGGER_ROLE = "debugger"
ROUTE_ROOT = "/debugger"
INDENT_SIZE = 20

View File

@@ -11,6 +11,7 @@ from components.drawerlayout.assets.icons import icon_panel_contract_regular, ic
from components.drawerlayout.constants import DRAWER_LAYOUT_INSTANCE_ID
from components.repositories.components.Repositories import Repositories
from components.tabs.components.MyTabs import MyTabs
from components.undo_redo.components.UndoRedo import UndoRedo
from components.workflows.components.Workflows import Workflows
from core.instance_manager import InstanceManager
from core.settings_management import SettingsManager
@@ -31,6 +32,7 @@ class DrawerLayout(BaseComponent):
self._ai_buddy = self._create_component(AIBuddy)
self._admin = self._create_component(Admin)
self._applications = self._create_component(Applications)
self._undo_redo = self._create_component(UndoRedo)
self.top_components = self._get_sub_components("TOP", [self._ai_buddy])
self.bottom_components = self._get_sub_components("BOTTOM", [self._ai_buddy])
@@ -53,12 +55,16 @@ class DrawerLayout(BaseComponent):
name="sidebar"
),
Div(
Label(
Input(type="checkbox",
onclick=f"document.getElementById('sidebar_{self._id}').classList.toggle('collapsed');"),
icon_panel_contract_regular,
icon_panel_expand_regular,
cls="swap",
Div(
Label(
Input(type="checkbox",
onclick=f"document.getElementById('sidebar_{self._id}').classList.toggle('collapsed');"),
icon_panel_contract_regular,
icon_panel_expand_regular,
cls="swap mr-4",
),
self._undo_redo,
cls="flex"
),
Div(*[component for component in self.top_components], name="top", cls='dl-top'),
Div(self._tabs, id=f"page_{self._id}", name="page", cls='dl-page'),

View File

@@ -0,0 +1,26 @@
import logging
from fasthtml.fastapp import fast_app
from components.entryselector.constants import Routes
from core.instance_manager import debug_session, InstanceManager
logger = logging.getLogger("EntrySelectorApp")
repositories_app, rt = fast_app()
@rt(Routes.Select)
def get(session, _id: str, entry: str):
logger.debug(f"Entering {Routes.Select} with args {debug_session(session)}, {_id=}, {entry=}")
instance = InstanceManager.get(session, _id)
to_update = instance.select_entry(entry)
res = [instance]
if res is None:
return instance
if isinstance(to_update, (list, tuple)):
res.extend(to_update)
else:
res.append(to_update)
return tuple(res)

View File

View File

@@ -0,0 +1,20 @@
.es-container {
overflow-x: auto;
white-space: nowrap;
}
.es-entry {
border: 2px solid var(--color-base-300);
padding: 2px;
cursor: pointer;
display: inline-block; /* Ensure entries align horizontally if needed */
}
.es-entry-selected {
border: 2px solid var(--color-primary);
}
.es-entry:hover {
background-color: var(--color-base-300);
}

View File

@@ -0,0 +1,15 @@
from components.BaseCommandManager import BaseCommandManager
from components.entryselector.constants import Routes, ROUTE_ROOT
class EntrySelectorCommandManager(BaseCommandManager):
def __init__(self, owner):
super().__init__(owner)
def select_entry(self, entry):
return {
"hx-get": f"{ROUTE_ROOT}{Routes.Select}",
"hx-target": f"#{self._id}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}", "entry": "{entry}"}}',
}

View File

@@ -0,0 +1,56 @@
import logging
from fasthtml.components import *
from components.BaseComponent import BaseComponentMultipleInstance
from components.entryselector.commands import EntrySelectorCommandManager
logger = logging.getLogger("EntrySelector")
class EntrySelector(BaseComponentMultipleInstance):
def __init__(self, session, _id, owner, data=None, hooks=None, key=None, boundaries=None):
super().__init__(session, _id)
self._key = key
self._owner = owner # debugger component
self.data = data
self.selected = None
self.hooks = hooks
self._boundaries = boundaries if boundaries else {"width": "300"}
self._commands = EntrySelectorCommandManager(self)
def set_data(self, data):
self.data = data
def set_selected(self, selected):
if selected is None:
self.selected = None
else:
self.selected = int(selected)
def set_boundaries(self, boundaries):
self._boundaries = boundaries
def select_entry(self, entry):
logger.debug(f"Selecting entry {entry}")
self.set_selected(entry)
if self.hooks is not None and (on_entry_selected := self.hooks.get("on_entry_selected", None)) is not None:
return on_entry_selected(entry)
else:
return None
def _mk_content(self):
if not self.data:
return [Div("no entry")]
return [Div(index,
**self._commands.select_entry(index),
cls=f"es-entry {'es-entry-selected' if index == self.selected else ''}")
for index in range(self.data)]
def __ft__(self):
return Div(
*self._mk_content(),
cls="flex es-container",
id=f"{self._id}",
)

View File

@@ -0,0 +1,5 @@
ROUTE_ROOT = "/es" # for EntrySelector
class Routes:
Select = "/select"

View File

@@ -0,0 +1,18 @@
import logging
from fasthtml.fastapp import fast_app
from components.jsonviewer.constants import Routes
from core.instance_manager import debug_session, InstanceManager
jsonviwer_app, rt = fast_app()
logger = logging.getLogger("JsonViewer")
@rt(Routes.Fold)
def post(session, _id: str, node_id: str, folding: str):
logger.debug(f"Entering {Routes.Fold} with args {debug_session(session)}, {_id=}, {node_id=}, {folding=}")
instance = InstanceManager.get(session, _id)
instance.set_node_folding(node_id, folding)
return instance.render_node(node_id)

View File

@@ -0,0 +1,449 @@
# JsonViewer Hooks System - Technical Documentation
## Overview
The JsonViewer Hooks System provides a flexible, event-driven mechanism to customize the behavior and rendering of JSON nodes. Using a fluent builder pattern, developers can define conditions and actions that trigger during specific events in the JsonViewer lifecycle.
## Core Concepts
### Hook Architecture
A **Hook** consists of three components:
- **Event Type**: When the hook should trigger (`on_render`, `on_click`, etc.)
- **Conditions**: What criteria must be met for the hook to execute
- **Executor**: The function that runs when conditions are met
### HookContext
The `HookContext` object provides rich information about the current node being processed:
```python
class HookContext:
key: Any # The key of the current node
node: Any # The node object itself
helper: Any # JsonViewer helper utilities
jsonviewer: Any # Reference to the parent JsonViewer instance
json_path: str # Full JSON path (e.g., "users.0.name")
parent_node: Any # Reference to the parent node
metadata: dict # Additional metadata storage
```
**Utility Methods:**
- `get_node_type()`: Returns the string representation of the node type
- `get_value()`: Gets the actual value from the node
- `is_leaf_node()`: Checks if the node has no children
## HookBuilder API
### Creating a Hook
Use the `HookBuilder` class with method chaining to create hooks:
```python
hook = (HookBuilder()
.on_render()
.when_long_text(100)
.execute(my_custom_renderer))
```
### Event Types
#### `on_render()`
Triggers during node rendering, allowing custom rendering logic.
```python
def custom_text_renderer(context):
value = context.get_value()
return Span(f"Custom: {value}", cls="custom-text")
text_hook = (HookBuilder()
.on_render()
.when_type(str)
.execute(custom_text_renderer))
```
#### `on_click()`
Triggers when a node is clicked.
```python
def handle_click(context):
print(f"Clicked on: {context.json_path}")
return None # No rendering change
click_hook = (HookBuilder()
.on_click()
.when_editable()
.requires_modification()
.execute(handle_click))
```
#### `on_hover()` / `on_focus()`
Triggers on hover or focus events respectively.
```python
def show_tooltip(context):
return Div(f"Path: {context.json_path}", cls="tooltip")
hover_hook = (HookBuilder()
.on_hover()
.when_type(str)
.execute(show_tooltip))
```
## Conditions
Conditions determine when a hook should execute. Multiple conditions can be chained, and all must be satisfied.
### `when_type(target_type)`
Matches nodes with values of a specific type.
```python
# Hook for string values only
string_hook = (HookBuilder()
.on_render()
.when_type(str)
.execute(string_formatter))
# Hook for numeric values
number_hook = (HookBuilder()
.on_render()
.when_type((int, float)) # Accepts tuple of types
.execute(number_formatter))
```
### `when_key(key_pattern)`
Matches nodes based on their key.
```python
# Exact key match
email_hook = (HookBuilder()
.on_render()
.when_key("email")
.execute(email_formatter))
# Function-based key matching
def is_id_key(key):
return str(key).endswith("_id")
id_hook = (HookBuilder()
.on_render()
.when_key(is_id_key)
.execute(id_formatter))
```
### `when_value(target_value=None, predicate=None)`
Matches nodes based on their actual value.
**Exact value matching:**
```python
# Highlight error status
error_hook = (HookBuilder()
.on_render()
.when_value("ERROR")
.execute(lambda ctx: Span(ctx.get_value(), cls="error-status")))
# Special handling for null values
null_hook = (HookBuilder()
.on_render()
.when_value(None)
.execute(lambda ctx: Span("N/A", cls="null-value")))
```
**Predicate-based matching:**
```python
# URLs as clickable links
url_hook = (HookBuilder()
.on_render()
.when_value(predicate=lambda x: isinstance(x, str) and x.startswith("http"))
.execute(lambda ctx: A(ctx.get_value(), href=ctx.get_value(), target="_blank")))
# Large numbers formatting
large_number_hook = (HookBuilder()
.on_render()
.when_value(predicate=lambda x: isinstance(x, (int, float)) and x > 1000)
.execute(lambda ctx: Span(f"{x:,}", cls="large-number")))
```
### `when_path(path_pattern)`
Matches nodes based on their JSON path using regex.
```python
# Match all user names
user_name_hook = (HookBuilder()
.on_render()
.when_path(r"users\.\d+\.name")
.execute(user_name_formatter))
# Match any nested configuration
config_hook = (HookBuilder()
.on_render()
.when_path(r".*\.config\..*")
.execute(config_formatter))
```
### `when_long_text(threshold=100)`
Matches string values longer than the specified threshold.
```python
def text_truncator(context):
value = context.get_value()
truncated = value[:100] + "..."
return Div(
Span(truncated, cls="truncated-text"),
Button("Show more", cls="expand-btn"),
cls="long-text-container"
)
long_text_hook = (HookBuilder()
.on_render()
.when_long_text(100)
.execute(text_truncator))
```
### `when_editable(editable_paths=None, editable_types=None)`
Matches nodes that should be editable.
```python
def inline_editor(context):
value = context.get_value()
return Input(
value=str(value),
type="text" if isinstance(value, str) else "number",
cls="inline-editor",
**{"data-path": context.json_path}
)
editable_hook = (HookBuilder()
.on_click()
.when_editable(
editable_paths=["user.name", "user.email"],
editable_types=[str, int, float]
)
.requires_modification()
.execute(inline_editor))
```
### `when_custom(condition)`
Use custom condition objects or callable predicates for complex logic.
The `when_custom()` method accepts either:
- **Condition instances**: Objects that inherit from the `Condition` base class
- **Callable predicates**: Functions that take a `HookContext` parameter and return a boolean
When a callable is provided, it's automatically wrapped in a `PredicateCondition` class internally.
```python
class BusinessLogicCondition(Condition):
def evaluate(self, context):
# Complex business logic here
return (context.key == "status" and
context.get_value() in ["pending", "processing"])
custom_hook = (HookBuilder()
.on_render()
.when_custom(BusinessLogicCondition())
.execute(status_renderer))
```
## Combining Conditions
### Multiple Conditions (AND Logic)
Chain multiple conditions - all must be satisfied:
```python
complex_hook = (HookBuilder()
.on_render()
.when_type(str)
.when_key("description")
.when_long_text(50)
.execute(description_formatter))
```
### Composite Conditions
Use `when_all()` and `when_any()` for explicit logic:
```python
# AND logic
strict_hook = (HookBuilder()
.on_render()
.when_all([
WhenType(str),
WhenLongText(100),
WhenKey("content")
])
.execute(content_formatter))
# OR logic
flexible_hook = (HookBuilder()
.on_render()
.when_any([
WhenKey("title"),
WhenKey("name"),
WhenKey("label")
])
.execute(title_formatter))
```
## State Modification
Use `requires_modification()` to indicate that the hook will modify the application state:
```python
def save_edit(context):
new_value = get_new_value_from_ui() # Implementation specific
# Update the actual data
context.jsonviewer.update_value(context.json_path, new_value)
return success_indicator()
edit_hook = (HookBuilder()
.on_click()
.when_editable()
.requires_modification()
.execute(save_edit))
```
## Complete Examples
### Example 1: Enhanced Text Display
```python
def enhanced_text_renderer(context):
value = context.get_value()
# Truncate long text
if len(value) > 100:
display_value = value[:100] + "..."
tooltip = value # Full text as tooltip
else:
display_value = value
tooltip = None
return Span(
display_value,
cls="enhanced-text",
title=tooltip,
**{"data-full-text": value}
)
text_hook = (HookBuilder()
.on_render()
.when_type(str)
.when_value(predicate=lambda x: len(x) > 20)
.execute(enhanced_text_renderer))
```
### Example 2: Interactive Email Fields
```python
def email_renderer(context):
email = context.get_value()
return Div(
A(f"mailto:{email}", href=f"mailto:{email}", cls="email-link"),
Button("Copy", cls="copy-btn", **{"data-clipboard": email}),
cls="email-container"
)
email_hook = (HookBuilder()
.on_render()
.when_key("email")
.when_value(predicate=lambda x: "@" in str(x))
.execute(email_renderer))
```
### Example 3: Status Badge System
```python
def status_badge(context):
status = context.get_value().lower()
badge_classes = {
"active": "badge-success",
"pending": "badge-warning",
"error": "badge-danger",
"inactive": "badge-secondary"
}
css_class = badge_classes.get(status, "badge-default")
return Span(
status.title(),
cls=f"status-badge {css_class}"
)
status_hook = (HookBuilder()
.on_render()
.when_key("status")
.when_value(predicate=lambda x: str(x).lower() in ["active", "pending", "error", "inactive"])
.execute(status_badge))
```
## Integration with JsonViewer
### Adding Hooks to JsonViewer
```python
# Create your hooks
hooks = [
text_hook,
email_hook,
status_hook
]
# Initialize JsonViewer with hooks
viewer = JsonViewer(
session=session,
_id="my-viewer",
data=my_json_data,
hooks=hooks
)
```
### Factory Functions
Create reusable hook factories for common patterns:
```python
def create_url_link_hook():
"""Factory for URL link rendering"""
def url_renderer(context):
url = context.get_value()
return A(url, href=url, target="_blank", cls="url-link")
return (HookBuilder()
.on_render()
.when_value(predicate=lambda x: isinstance(x, str) and x.startswith(("http://", "https://")))
.execute(url_renderer))
def create_currency_formatter_hook(currency_symbol="$"):
"""Factory for currency formatting"""
def currency_renderer(context):
amount = context.get_value()
return Span(f"{currency_symbol}{amount:,.2f}", cls="currency-amount")
return (HookBuilder()
.on_render()
.when_type((int, float))
.when_key(lambda k: "price" in str(k).lower() or "amount" in str(k).lower())
.execute(currency_renderer))
# Usage
hooks = [
create_url_link_hook(),
create_currency_formatter_hook("€"),
]
```
## Best Practices
1. **Specific Conditions**: Use the most specific conditions possible to avoid unintended matches
2. **Performance**: Avoid complex predicates in `when_value()` for large datasets
3. **Error Handling**: Include error handling in your executor functions
4. **Reusability**: Create factory functions for common hook patterns
5. **Testing**: Test hooks with various data structures to ensure they work as expected
## Performance Considerations
- Hooks are evaluated in the order they are added to the JsonViewer
- Only the first matching hook for each event type will execute per node
- Use simple conditions when possible to minimize evaluation time
- Consider the size of your JSON data when using regex in `when_path()`

View File

View File

@@ -0,0 +1,27 @@
from fastcore.basics import NotStr
# Fluent CaretRight20Filled
icon_collapsed = NotStr("""<svg name="collapsed" 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="M7 14.204a1 1 0 0 0 1.628.778l4.723-3.815a1.5 1.5 0 0 0 0-2.334L8.628 5.02A1 1 0 0 0 7 5.797v8.407z" fill="currentColor">
</path>
</g>
</svg>""")
# Fluent CaretDown20Filled
icon_expanded = NotStr("""<svg name="expanded" 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="M5.797 7a1 1 0 0 0-.778 1.628l3.814 4.723a1.5 1.5 0 0 0 2.334 0l3.815-4.723A1 1 0 0 0 14.204 7H5.797z" fill="currentColor">
</path>
</g>
</svg>""")
icon_class = NotStr("""
<svg name="expanded" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-width="1.5" >
<polygon points="5,2 2,8 8,8" />
<rect x="12" y="2" width="6" height="6"/>
<circle cx="5" cy="15" r="3" />
<polygon points="11.5,15 15,11.5 18.5,15 15,18.5" />
</g>
</svg>""")

View File

@@ -0,0 +1,23 @@
from components.jsonviewer.constants import ROUTE_ROOT, Routes
class JsonViewerCommands:
def __init__(self, owner):
self._owner = owner
self._id = owner.get_id()
def fold(self, node_id: str, folding: str):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.Fold}",
"hx-target": f"#{node_id}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}", "node_id": "{node_id}", "folding": "{folding}"}}',
}
def open_digest(self, user_id, digest):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.DbEngineDigest}",
"hx-target": f"#{self._owner.get_owner().tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}", "user_id": "{user_id}", "digest": "{digest}"}}',
}

View File

@@ -0,0 +1,544 @@
from dataclasses import dataclass, field
from typing import Any, Optional
from fasthtml.components import *
from pandas import DataFrame
from components.BaseComponent import BaseComponentMultipleInstance
from components.datagrid_new.components.DataGrid import DataGrid
from components.jsonviewer.assets.icons import icon_expanded, icon_collapsed, icon_class
from components.jsonviewer.commands import JsonViewerCommands
from components.jsonviewer.constants import NODES_KEYS_TO_NOT_EXPAND, NODE_OBJECT, INDENT_SIZE, MAX_TEXT_LENGTH
from components.jsonviewer.hooks import HookManager, HookContext, EventType, Hook
from components_helpers import apply_boundaries
from core.serializer import TAG_OBJECT
class FoldingMode:
COLLAPSE = "collapse"
EXPAND = "expand"
@dataclass
class Node:
value: Any
@dataclass
class ValueNode(Node):
hint: str = None
@dataclass
class ListNode(Node):
node_id: str
level: int
children: list[Node] = field(default_factory=list)
@dataclass
class DictNode(Node):
node_id: str
level: int
children: dict[str, Node] = field(default_factory=dict)
class NodeIdGenerator:
"""Manages unique node ID generation"""
def __init__(self, base_id: str):
self.base_id = base_id
self._counter = -1
def generate(self) -> str:
self._counter += 1
return f"{self.base_id}-{self._counter}"
def reset(self):
self._counter = -1
class FoldingManager:
"""Manages folding/unfolding state of nodes"""
# A little explanation on how the folding / unfolding work
# all the nodes are either fold or unfold... except when there are not !
# self._folding_mode keeps the current value (it's FoldingMode.COLLAPSE or FoldingMode.EXPAND
# self._nodes_to_track keeps track of the exceptions
# The idea is to minimize the memory usage
def __init__(self, default_mode: str = FoldingMode.COLLAPSE):
self._folding_mode = default_mode
self._nodes_to_track = set() # exceptions to the default mode
def set_folding_mode(self, mode: str):
"""Changes the global folding mode and clears exceptions"""
self._folding_mode = mode
self._nodes_to_track.clear()
def set_node_folding(self, node_id: str, folding: str):
"""Sets specific folding state for a node"""
if folding == self._folding_mode:
self._nodes_to_track.discard(node_id)
else:
self._nodes_to_track.add(node_id)
def must_expand(self, node: Node) -> Optional[bool]:
"""Determines if a node should be expanded"""
if not isinstance(node, (ListNode, DictNode)):
return None
if self._folding_mode == FoldingMode.COLLAPSE:
return node.node_id in self._nodes_to_track
else:
return node.node_id not in self._nodes_to_track
def get_folding_mode(self) -> str:
return self._folding_mode
def get_nodes_to_track(self) -> set[str]:
return self._nodes_to_track
class NodeFactory:
"""Factory for creating nodes from data with JSON path tracking"""
def __init__(self, id_generator: NodeIdGenerator, folding_manager: FoldingManager):
self.id_generator = id_generator
self.folding_manager = folding_manager
self._nodes_by_id = {}
self._node_paths = {} # node_id -> json_path mapping
self._node_parents = {} # node_id -> parent_node mapping
def create_node(self, key: Any, data: Any, level: int = 0, json_path: str = "", parent_node: Any = None) -> Node:
"""Creates appropriate node type based on data with path tracking"""
if isinstance(data, list):
return self._create_list_node(key, data, level, json_path, parent_node)
elif isinstance(data, dict):
return self._create_dict_node(key, data, level, json_path, parent_node)
else:
return self._create_value_node(key, data, json_path, parent_node)
def _create_list_node(self, key: Any, data: list, level: int, json_path: str, parent_node: Any) -> ListNode:
node_id = self.id_generator.generate()
if level <= 1 and key not in NODES_KEYS_TO_NOT_EXPAND:
self.folding_manager._nodes_to_track.add(node_id)
node = ListNode(data, node_id, level)
self._nodes_by_id[node_id] = (key, node)
self._node_paths[node_id] = json_path
self._node_parents[node_id] = parent_node
for index, item in enumerate(data):
child_path = f"{json_path}[{index}]" if json_path else f"[{index}]"
node.children.append(self.create_node(index, item, level + 1, child_path, node))
return node
def _create_dict_node(self, key: Any, data: dict, level: int, json_path: str, parent_node: Any) -> DictNode:
node_id = self.id_generator.generate()
if level <= 1 and key not in NODES_KEYS_TO_NOT_EXPAND:
self.folding_manager._nodes_to_track.add(node_id)
node = DictNode(data, node_id, level)
self._nodes_by_id[node_id] = (key, node)
self._node_paths[node_id] = json_path
self._node_parents[node_id] = parent_node
for child_key, value in data.items():
child_path = f"{json_path}.{child_key}" if json_path else str(child_key)
node.children[child_key] = self.create_node(child_key, value, level + 1, child_path, node)
return node
def _create_value_node(self, key: Any, data: Any, json_path: str, parent_node: Any) -> ValueNode:
hint = NODE_OBJECT if key == TAG_OBJECT else None
node = ValueNode(data, hint)
# Value nodes don't have node_id, but we can still track their path for hooks
return node
def get_node_by_id(self, node_id: str) -> tuple[Any, Node]:
return self._nodes_by_id[node_id]
def get_node_path(self, node_id: str) -> str:
return self._node_paths.get(node_id, "")
def get_node_parent(self, node_id: str) -> Any:
return self._node_parents.get(node_id, None)
def clear(self):
"""Clears all stored nodes"""
self._nodes_by_id.clear()
self._node_paths.clear()
self._node_parents.clear()
class JsonViewerHelper:
class_string = f"mmt-jsonviewer-string"
class_bool = f"mmt-jsonviewer-bool"
class_number = f"mmt-jsonviewer-number"
class_null = f"mmt-jsonviewer-null"
class_digest = f"mmt-jsonviewer-digest"
class_object = f"mmt-jsonviewer-object"
class_dataframe = f"mmt-jsonviewer-dataframe"
@staticmethod
def is_sha256(_value):
return (isinstance(_value, str) and
len(_value) == 64 and
all(c in '0123456789abcdefABCDEF' for c in _value))
@staticmethod
def add_quotes(value: str) -> str:
if '"' in value and "'" in value:
return f'"{value.replace("\"", "\\\"")}"'
elif '"' in value:
return f"'{value}'"
else:
return f'"{value}"'
class NodeRenderer:
"""Single class handling all node rendering with new hook system"""
def __init__(self, session,
jsonviewer_instance,
folding_manager: FoldingManager,
commands: JsonViewerCommands,
helper: JsonViewerHelper,
hook_manager: HookManager,
node_factory: NodeFactory):
self.session = session
self.jsonviewer = jsonviewer_instance
self.folding_manager = folding_manager
self.commands = commands
self.helper = helper
self.hook_manager = hook_manager
self.node_factory = node_factory
def render(self, key: Any, node: Node, json_path: str = "", parent_node: Any = None) -> Div:
"""Main rendering method for any node"""
must_expand = self.folding_manager.must_expand(node)
return Div(
self._create_folding_icon(node, must_expand),
Span(f'{key} : ') if key is not None else None,
self._render_value(key, node, must_expand, json_path, parent_node),
style=f"margin-left: {INDENT_SIZE}px;",
id=getattr(node, "node_id", None)
)
def _create_folding_icon(self, node: Node, must_expand: Optional[bool]) -> Optional[Span]:
"""Creates folding/unfolding icon"""
if must_expand is None:
return None
return Span(
icon_expanded if must_expand else icon_collapsed,
cls="icon-16-inline mmt-jsonviewer-folding",
style=f"margin-left: -{INDENT_SIZE}px;",
**self.commands.fold(
node.node_id,
FoldingMode.COLLAPSE if must_expand else FoldingMode.EXPAND
)
)
def _render_value(self, key: Any,
node: Node,
must_expand: Optional[bool],
json_path: str = "",
parent_node: Any = None):
"""Renders the value part of a node with new hook system"""
if must_expand is False:
return self._render_collapsed_indicator(node)
# Create hook context
context = HookContext(
key=key,
node=node,
helper=self.helper,
jsonviewer=self.jsonviewer,
json_path=json_path,
parent_node=parent_node
)
# Execute render hooks and check for results
hook_results = self.hook_manager.execute_hooks(EventType.RENDER, context)
# If any hook returned a result, use the first one
if hook_results:
# Filter out None results
valid_results = [result for result in hook_results if result is not None]
if valid_results:
return valid_results[0]
# No hooks matched or returned results, use default rendering
if isinstance(node, DictNode):
return self._render_dict_node(key, node)
elif isinstance(node, ListNode):
return self._render_list_node(key, node)
else:
return self._render_value_node(key, node)
def _render_collapsed_indicator(self, node: Node) -> Span:
"""Renders collapsed indicator"""
indicator = "[...]" if isinstance(node, ListNode) else "{...}"
return Span(
indicator,
id=node.node_id,
**self.commands.fold(node.node_id, FoldingMode.EXPAND)
)
def _render_dict_node(self, key: Any, node: DictNode) -> Span:
"""Renders dictionary node"""
children_elements = []
base_path = self.node_factory.get_node_path(node.node_id)
for child_key, child_node in node.children.items():
child_path = f"{base_path}.{child_key}" if base_path else str(child_key)
children_elements.append(self.render(child_key, child_node, child_path, node))
return Span(
"{",
*children_elements,
Div("}"),
id=node.node_id
)
def _render_list_node(self, key: Any, node: ListNode) -> Span:
"""Renders list node"""
if self._should_render_list_as_grid(key, node):
return self._render_list_as_grid(key, node)
else:
return self._render_list_as_array(key, node)
def _should_render_list_as_grid(self, key: Any, node: ListNode) -> bool:
"""Determines if list should be rendered as grid"""
if len(node.children) == 0:
return False
sample_node = node.children[0]
sample_value = sample_node.value
if sample_value is None:
return False
type_ = type(sample_value)
if type_ in (int, float, str, bool, list, dict, ValueNode):
return False
# Check if hooks handle this type (simplified check)
sample_context = HookContext(
key=key,
node=sample_node,
helper=self.helper,
jsonviewer=self.jsonviewer
)
hook_results = self.hook_manager.execute_hooks(EventType.RENDER, sample_context)
if hook_results and any(result is not None for result in hook_results):
return False
return all(type(item.value) == type_ for item in node.children)
def _render_list_as_grid(self, key: Any, node: ListNode) -> Span:
"""Renders list as grid"""
type_ = type(node.children[0].value)
icon = icon_class
str_value = type_.__name__.split(".")[-1]
data = [child.value.__dict__ for child in node.children]
df = DataFrame(data)
dg = DataGrid(self.session)
dg.init_from_dataframe(df)
return Span(
Span(
Span(icon, cls="icon-16-inline mr-1"),
Span(str_value),
cls="mmt-jsonviewer-object"
),
dg,
id=node.node_id
)
def _render_list_as_array(self, key: Any, node: ListNode) -> Span:
"""Renders list as array"""
children_elements = []
base_path = self.node_factory.get_node_path(node.node_id)
for index, child_node in enumerate(node.children):
child_path = f"{base_path}[{index}]" if base_path else f"[{index}]"
children_elements.append(self.render(index, child_node, child_path, node))
return Span(
"[",
*children_elements,
Div("]"),
)
def _render_value_node(self, key: Any, node: ValueNode) -> Span:
"""Renders value node"""
data_tooltip = None
htmx_params = {}
icon = None
if isinstance(node.value, bool): # order is important bool is an int in Python !
str_value = "true" if node.value else "false"
data_class = "bool"
elif isinstance(node.value, (int, float)):
str_value = str(node.value)
data_class = "number"
elif node.value is None:
str_value = "null"
data_class = "null"
elif self.helper.is_sha256(node.value):
str_value = str(node.value)
data_class = "digest"
htmx_params = self.commands.open_digest(self.jsonviewer.user_id, node.value)
elif node.hint == NODE_OBJECT:
icon = icon_class
str_value = node.value.split(".")[-1]
data_class = "object"
elif isinstance(node.value, DataFrame):
return self._render_dataframe_value(node.value)
else:
str_value, data_tooltip = self._format_string_value(node.value)
data_class = "string"
return self._create_value_span(str_value, data_class, icon, data_tooltip, htmx_params)
def _render_dataframe_value(self, dataframe: DataFrame) -> Any:
"""Renders DataFrame value"""
dg = DataGrid(self.session)
dg.init_from_dataframe(dataframe)
return dg
def _format_string_value(self, value: Any) -> tuple[str, Optional[str]]:
"""Formats string value with tooltip if too long"""
as_str = str(value)
if len(as_str) > MAX_TEXT_LENGTH:
return as_str[:MAX_TEXT_LENGTH] + "...", as_str
else:
return self.helper.add_quotes(as_str), None
def _create_value_span(self, str_value: str, data_class: str, icon: Any,
data_tooltip: Optional[str], htmx_params: dict) -> Span:
"""Creates the final Span element for a value"""
css_class = f"mmt-jsonviewer-{data_class}"
if data_tooltip:
css_class += " mmt-tooltip"
if icon:
return Span(
Span(icon, cls="icon-16-inline mr-1"),
Span(str_value, data_tooltip=data_tooltip, **htmx_params),
cls=css_class
)
return Span(str_value, cls=css_class, data_tooltip=data_tooltip, **htmx_params)
class JsonViewer(BaseComponentMultipleInstance):
"""Main JsonViewer component with new hook system"""
COMPONENT_INSTANCE_ID = "Jsonviewer"
def __init__(self, session, _id, data=None, hooks: list[Hook] = None, key=None, boundaries=None):
super().__init__(session, _id)
self._key = key
self.data = data
self._boundaries = boundaries if boundaries else {"height": "600"}
self._commands = JsonViewerCommands(self)
# Initialize hook system (transparent to user)
self._hook_manager = HookManager()
if hooks:
self._hook_manager.add_hooks(hooks)
# Initialize helper components
self._helper = JsonViewerHelper()
self._id_generator = NodeIdGenerator(_id)
self._folding_manager = FoldingManager()
self._node_factory = NodeFactory(self._id_generator, self._folding_manager)
# Initialize renderer with hook manager
self._node_renderer = NodeRenderer(
session, self, self._folding_manager,
self._commands, self._helper, self._hook_manager, self._node_factory
)
# Create the initial node tree
self.node = self._node_factory.create_node(None, data)
@property
def user_id(self) -> str:
"""Gets user_id from session or returns default"""
return getattr(self, '_user_id', getattr(self._session, 'user_id', 'default_user'))
def set_data(self, data):
"""Updates the data and recreates the node tree"""
self.data = data
self._id_generator.reset()
self._node_factory.clear()
self.node = self._node_factory.create_node(None, data)
def add_hook(self, hook: Hook):
"""Adds a single hook to the viewer"""
self._hook_manager.add_hook(hook)
def add_hooks(self, hooks: list[Hook]):
"""Adds multiple hooks to the viewer"""
self._hook_manager.add_hooks(hooks)
def clear_hooks(self):
"""Removes all hooks from the viewer"""
self._hook_manager.clear_hooks()
def set_node_folding(self, node_id: str, folding: str):
"""Sets folding state for a specific node"""
self._folding_manager.set_node_folding(node_id, folding)
def render_node(self, node_id: str):
"""Renders a specific node by ID"""
key, node = self._node_factory.get_node_by_id(node_id)
json_path = self._node_factory.get_node_path(node_id)
parent_node = self._node_factory.get_node_parent(node_id)
return self._node_renderer.render(key, node, json_path, parent_node)
def set_folding_mode(self, folding_mode: str):
"""Sets global folding mode"""
self._folding_manager.set_folding_mode(folding_mode)
def get_folding_mode(self) -> str:
"""Gets current folding mode"""
return self._folding_manager.get_folding_mode()
def open_digest(self, user_id: str, digest: str):
"""Opens digest - preserves original method"""
return self._owner.db_engine_headers(user_id, digest)
def __ft__(self):
"""FastHTML rendering method"""
if self.node is None:
return Div("No data to display", cls="mmt-jsonviewer", id=f"{self._id}")
return Div(
Div(
self._node_renderer.render(None, self.node, "", None),
id=f"{self._id}-root",
style="margin-left: 0px;"
),
cls="mmt-jsonviewer",
id=f"{self._id}",
**apply_boundaries(self._boundaries)
)
def __eq__(self, other):
"""Equality comparison"""
if type(other) is type(self):
return self._key is not None and self._key == other._key
return False
def __hash__(self):
"""Hash method"""
return hash(self._key) if self._key is not None else super().__hash__()

View File

@@ -0,0 +1,10 @@
ROUTE_ROOT = "/jsonviewer"
INDENT_SIZE = 20
MAX_TEXT_LENGTH = 50
NODE_OBJECT = "Object"
NODES_KEYS_TO_NOT_EXPAND = ["Dataframe", "__parent__"]
class Routes:
Fold = "/fold"

View File

@@ -0,0 +1,386 @@
from abc import ABC, abstractmethod
from enum import Enum
from typing import Any, Callable, Optional
class EventType(Enum):
RENDER = "render"
CLICK = "click"
HOVER = "hover"
FOCUS = "focus"
class DefaultEditableTypes:
pass
class HookContext:
"""Enhanced context object passed to hook executors"""
def __init__(self, key: Any, node: Any, helper: Any, jsonviewer: Any,
json_path: str = None, parent_node: Any = None):
self.key = key
self.node = node
self.helper = helper
self.jsonviewer = jsonviewer
self.json_path = json_path or ""
self.parent_node = parent_node
self.metadata = {}
def get_node_type(self) -> str:
"""Returns string representation of node type"""
if hasattr(self.node, '__class__'):
return self.node.__class__.__name__
return type(self.node.value).__name__ if hasattr(self.node, 'value') else "unknown"
def get_value(self) -> Any:
"""Gets the actual value from the node"""
return getattr(self.node, 'value', self.node)
def is_leaf_node(self) -> bool:
"""Checks if this is a leaf node (no children)"""
return not hasattr(self.node, 'children') or not self.node.children
class Condition(ABC):
"""Base class for all conditions"""
@abstractmethod
def evaluate(self, context: HookContext) -> bool:
pass
class WhenLongText(Condition):
"""Condition: text length > threshold"""
def __init__(self, threshold: int = 100):
self.threshold = threshold
def evaluate(self, context: HookContext) -> bool:
value = context.get_value()
return isinstance(value, str) and len(value) > self.threshold
class WhenEditable(Condition):
"""Condition: node is editable (configurable logic)"""
def __init__(self, editable_paths: list[str] = None, editable_types: list[type] = DefaultEditableTypes):
self.editable_paths = set(editable_paths or [])
if editable_types is None:
self.editable_types = set()
else:
self.editable_types = set([str, int, float, bool] if editable_types is DefaultEditableTypes else editable_types)
def evaluate(self, context: HookContext) -> bool:
# Check if path is in editable paths
if self.editable_paths and context.json_path in self.editable_paths:
return True
# Check if type is editable
value = context.get_value()
return type(value) in self.editable_types and context.is_leaf_node()
class WhenType(Condition):
"""Condition: node value is of specific type"""
def __init__(self, target_type: type):
self.target_type = target_type
def evaluate(self, context: HookContext) -> bool:
value = context.get_value()
return isinstance(value, self.target_type)
class WhenKey(Condition):
"""Condition: node key matches pattern"""
def __init__(self, key_pattern: Any):
self.key_pattern = key_pattern
def evaluate(self, context: HookContext) -> bool:
if callable(self.key_pattern):
return self.key_pattern(context.key)
return context.key == self.key_pattern
class WhenPath(Condition):
"""Condition: JSON path matches pattern"""
def __init__(self, path_pattern: str):
self.path_pattern = path_pattern
def evaluate(self, context: HookContext) -> bool:
import re
return bool(re.match(self.path_pattern, context.json_path))
class WhenValue(Condition):
"""Condition: node value matches specific value or predicate"""
def __init__(self, target_value: Any = None, predicate: Callable[[Any], bool] = None):
if target_value is not None and predicate is not None:
raise ValueError("Cannot specify both target_value and predicate")
if target_value is None and predicate is None:
raise ValueError("Must specify either target_value or predicate")
self.target_value = target_value
self.predicate = predicate
def evaluate(self, context: HookContext) -> bool:
value = context.get_value()
if self.predicate:
return self.predicate(value)
else:
return value == self.target_value
class CompositeCondition(Condition):
"""Allows combining conditions with AND/OR logic"""
def __init__(self, conditions: list[Condition], operator: str = "AND"):
self.conditions = conditions
self.operator = operator.upper()
def evaluate(self, context: HookContext) -> bool:
if not self.conditions:
return True
results = [condition.evaluate(context) for condition in self.conditions]
if self.operator == "AND":
return all(results)
elif self.operator == "OR":
return any(results)
else:
raise ValueError(f"Unknown operator: {self.operator}")
class Hook:
"""Represents a complete hook with event, conditions, and executor"""
def __init__(self, event_type: EventType, conditions: list[Condition],
executor: Callable, requires_modification: bool = False):
self.event_type = event_type
self.conditions = conditions
self.executor = executor
self.requires_modification = requires_modification
def matches(self, event_type: EventType, context: HookContext) -> bool:
"""Checks if this hook should be executed for given event and context"""
if self.event_type != event_type:
return False
return all(condition.evaluate(context) for condition in self.conditions)
def execute(self, context: HookContext) -> Any:
"""Executes the hook with given context"""
return self.executor(context)
class HookBuilder:
"""Builder class for creating hooks with fluent interface"""
def __init__(self):
self._event_type: Optional[EventType] = None
self._conditions: list[Condition] = []
self._executor: Optional[Callable] = None
self._requires_modification: bool = False
# Event specification methods
def on_render(self):
"""Hook will be triggered on render event"""
self._event_type = EventType.RENDER
return self
def on_click(self):
"""Hook will be triggered on click event"""
self._event_type = EventType.CLICK
return self
def on_hover(self):
"""Hook will be triggered on hover event"""
self._event_type = EventType.HOVER
return self
def on_focus(self):
"""Hook will be triggered on focus event"""
self._event_type = EventType.FOCUS
return self
# Condition methods
def when_long_text(self, threshold: int = 100):
"""Add condition: text length > threshold"""
self._conditions.append(WhenLongText(threshold))
return self
def when_editable(self, editable_paths: list[str] = None, editable_types: list[type] = None):
"""Add condition: node is editable"""
self._conditions.append(WhenEditable(editable_paths, editable_types))
return self
def when_type(self, target_type: type):
"""Add condition: node value is of specific type"""
self._conditions.append(WhenType(target_type))
return self
def when_key(self, key_pattern: Any):
"""Add condition: node key matches pattern"""
self._conditions.append(WhenKey(key_pattern))
return self
def when_path(self, path_pattern: str):
"""Add condition: JSON path matches pattern"""
self._conditions.append(WhenPath(path_pattern))
return self
def when_value(self, target_value: Any = None, predicate: Callable[[Any], bool] = None):
"""Add condition: node value matches specific value or predicate"""
self._conditions.append(WhenValue(target_value, predicate))
return self
def when_custom(self, condition):
"""Add custom condition (supports both Condition instances and predicate functions)."""
if callable(condition) and not isinstance(condition, Condition):
# Wrap the predicate function in a Condition class
class PredicateCondition(Condition):
def __init__(self, predicate):
self.predicate = predicate
def evaluate(self, context):
return self.predicate(context)
condition = PredicateCondition(condition) # Pass the function to the wrapper
elif not isinstance(condition, Condition):
raise ValueError("when_custom expects a Condition instance or a callable predicate.")
self._conditions.append(condition)
return self
def when_all(self, conditions: list[Condition]):
"""Add composite condition with AND logic"""
self._conditions.append(CompositeCondition(conditions, "AND"))
return self
def when_any(self, conditions: list[Condition]):
"""Add composite condition with OR logic"""
self._conditions.append(CompositeCondition(conditions, "OR"))
return self
# Modification flag
def requires_modification(self):
"""Indicates this hook will modify the state"""
self._requires_modification = True
return self
# Execution
def execute(self, executor: Callable) -> Hook:
"""Sets the executor function and builds the hook"""
if not self._event_type:
raise ValueError("Event type must be specified (use on_render(), on_click(), etc.)")
if not executor:
raise ValueError("Executor function must be provided")
self._executor = executor
return Hook(
event_type=self._event_type,
conditions=self._conditions,
executor=self._executor,
requires_modification=self._requires_modification
)
class HookManager:
"""Manages and executes hooks for JsonViewer"""
def __init__(self):
self.hooks: list[Hook] = []
def add_hook(self, hook: Hook):
"""Adds a hook to the manager"""
self.hooks.append(hook)
def add_hooks(self, hooks: list[Hook]):
"""Adds multiple hooks to the manager"""
self.hooks.extend(hooks)
def find_matching_hooks(self, event_type: EventType, context: HookContext) -> list[Hook]:
"""Finds all hooks that match the event and context"""
return [hook for hook in self.hooks if hook.matches(event_type, context)]
def execute_hooks(self, event_type: EventType, context: HookContext) -> list[Any]:
"""Executes all matching hooks and returns results"""
matching_hooks = self.find_matching_hooks(event_type, context)
results = []
for hook in matching_hooks:
try:
result = hook.execute(context)
results.append(result)
# If this hook requires modification, we might want to stop here
# or handle the modification differently
if hook.requires_modification:
# Could add callback to parent component here
pass
except Exception as e:
# Log error but continue with other hooks
print(f"Hook execution error: {e}")
continue
return results
def clear_hooks(self):
"""Removes all hooks"""
self.hooks.clear()
# Example usage and factory functions
def create_long_text_viewer_hook(threshold: int = 100) -> Hook:
"""Factory function for common long text viewer hook"""
def text_viewer_component(context: HookContext):
from fasthtml.components import Div, Span
value = context.get_value()
truncated = value[:threshold] + "..."
return Div(
Span(truncated, cls="text-truncated"),
Span("Click to expand", cls="expand-hint"),
cls="long-text-viewer"
)
return (HookBuilder()
.on_render()
.when_long_text(threshold)
.execute(text_viewer_component))
def create_inline_editor_hook(editable_paths: list[str] = None) -> Hook:
"""Factory function for common inline editor hook"""
def inline_editor_component(context: HookContext):
from fasthtml.components import Input, Div
value = context.get_value()
return Div(
Input(
value=str(value),
type="text" if isinstance(value, str) else "number",
cls="inline-editor"
),
cls="editable-field"
)
return (HookBuilder()
.on_click()
.when_editable(editable_paths)
.requires_modification()
.execute(inline_editor_component))

View File

@@ -1,3 +1,4 @@
from fasthtml.components import Html
from fasthtml.components import *
from fasthtml.xtend import Script

View File

@@ -20,7 +20,7 @@ def get(session):
@rt(Routes.AddRepository)
def post(session, _id: str, tab_id: str, form_id: str, repository: str, table: str, tab_boundaries:str):
def post(session, _id: str, tab_id: str, form_id: str, repository: str, table: str, tab_boundaries: str):
logger.debug(
f"Entering {Routes.AddRepository} with args {debug_session(session)}, {_id=}, {tab_id=}, {form_id=}, {repository=}, {table=}, {tab_boundaries=}")
instance = InstanceManager.get(session, _id) # Repository
@@ -34,8 +34,9 @@ def get(session, _id: str, repository_name: str):
@rt(Routes.AddTable)
def post(session, _id: str, tab_id: str, form_id: str, repository_name: str, table_name: str, tab_boundaries:str):
logger.debug(f"Entering {Routes.AddTable} with args {debug_session(session)}, {_id=}, {tab_id=}, {form_id=}, {repository_name=}, {table_name=}, {tab_boundaries=}")
def post(session, _id: str, tab_id: str, form_id: str, repository_name: str, table_name: str, tab_boundaries: str):
logger.debug(
f"Entering {Routes.AddTable} with args {debug_session(session)}, {_id=}, {tab_id=}, {form_id=}, {repository_name=}, {table_name=}, {tab_boundaries=}")
instance = InstanceManager.get(session, _id)
return instance.add_new_table(tab_id, form_id, repository_name, table_name, json.loads(tab_boundaries))
@@ -48,7 +49,8 @@ def put(session, _id: str, repository: str):
@rt(Routes.ShowTable)
def get(session, _id: str, repository: str, table: str, tab_boundaries:str):
logger.debug(f"Entering {Routes.ShowTable} with args {debug_session(session)}, {_id=}, {repository=}, {table=}, {tab_boundaries=}")
def get(session, _id: str, repository: str, table: str, tab_boundaries: str):
logger.debug(
f"Entering {Routes.ShowTable} with args {debug_session(session)}, {_id=}, {repository=}, {table=}, {tab_boundaries=}")
instance = InstanceManager.get(session, _id)
return instance.show_table(repository, table, json.loads(tab_boundaries))

View File

@@ -8,7 +8,7 @@ from components.datagrid_new.components.DataGrid import DataGrid
from components.form.components.MyForm import MyForm, FormField
from components.repositories.assets.icons import icon_database, icon_table
from components.repositories.commands import Commands
from components.repositories.constants import REPOSITORIES_INSTANCE_ID, ROUTE_ROOT, Routes
from components.repositories.constants import REPOSITORIES_INSTANCE_ID, ROUTE_ROOT, Routes, REPOSITORIES_ROLE
from components.repositories.db_management import RepositoriesDbManager, Repository
from components_helpers import mk_icon, mk_ellipsis
from core.instance_manager import InstanceManager
@@ -19,7 +19,7 @@ logger = logging.getLogger("Repositories")
class Repositories(BaseComponent):
def __init__(self, session: dict, _id: str, settings_manager=None, tabs_manager=None):
super().__init__(session, _id)
super().__init__(session, _id, REPOSITORIES_ROLE)
self.commands = Commands(self)
self.tabs_manager = tabs_manager
self.db = RepositoriesDbManager(session, settings_manager)
@@ -121,7 +121,7 @@ class Repositories(BaseComponent):
def refresh(self):
return self._mk_repositories(oob=True)
def __ft__(self):
def render(self):
return Div(
Div(cls="divider"),
mk_ellipsis("Repositories", cls="text-sm font-medium mb-1"),

View File

@@ -1,4 +1,5 @@
REPOSITORIES_INSTANCE_ID = "__Repositories__"
REPOSITORIES_ROLE = "repositories"
ROUTE_ROOT = "/repositories"
USERS_REPOSITORY_NAME = "__USERS___"
HOLIDAYS_TABLE_NAME = "__HOLIDAYS__"

View File

@@ -152,6 +152,14 @@ class MyTabs(BaseComponent):
def get_tab_content_by_key(self, key):
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):
return self.render(oob=True)
@@ -188,6 +196,13 @@ class MyTabs(BaseComponent):
active_tab = next(filter(lambda t: t.active, self.tabs), 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
def create_component_id(session):
prefix = f"{MY_TABS_INSTANCE_ID}{session['user_id']}"

View File

@@ -0,0 +1,23 @@
import logging
from fasthtml.fastapp import fast_app
from components.undo_redo.constants import Routes
from core.instance_manager import debug_session, InstanceManager
logger = logging.getLogger("UndoRedoApp")
undo_redo_app, rt = fast_app()
@rt(Routes.Undo)
def post(session, _id: str):
logger.debug(f"Entering {Routes.Undo} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.undo()
@rt(Routes.Redo)
def post(session, _id: str):
logger.debug(f"Entering {Routes.Redo} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.redo()

View File

View File

@@ -0,0 +1,7 @@
from fastcore.basics import NotStr
# carbon Undo
icon_undo = NotStr("""<svg name="undo" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32"><path d="M20 10H7.815l3.587-3.586L10 5l-6 6l6 6l1.402-1.415L7.818 12H20a6 6 0 0 1 0 12h-8v2h8a8 8 0 0 0 0-16z" fill="currentColor"></path></svg>""")
# carbon Redo
icon_redo = NotStr("""<svg name="redo" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32"><path d="M12 10h12.185l-3.587-3.586L22 5l6 6l-6 6l-1.402-1.415L24.182 12H12a6 6 0 0 0 0 12h8v2h-8a8 8 0 0 1 0-16z" fill="currentColor"></path></svg>""")

View File

@@ -0,0 +1,25 @@
from components.BaseCommandManager import BaseCommandManager
from components.undo_redo.constants import ROUTE_ROOT, Routes
class UndoRedoCommandManager(BaseCommandManager):
def __init__(self, owner):
super().__init__(owner)
def undo(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.Undo}",
"hx-trigger": "click, keyup[ctrlKey&&key=='z'] from:body",
"hx-target": f"#{self._id}",
"hx-swap": "innerHTML",
"hx-vals": f'{{"_id": "{self._id}"}}',
}
def redo(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.Redo}",
"hx_trigger": "click, keyup[ctrlKey&&key=='y'] from:body",
"hx-target": f"#{self._id}",
"hx-swap": "innerHTML",
"hx-vals": f'{{"_id": "{self._id}"}}',
}

View File

@@ -0,0 +1,165 @@
import logging
from dataclasses import dataclass
from fastcore.xml import FT
from fasthtml.components import *
from components.BaseComponent import BaseComponentSingleton
from components.undo_redo.assets.icons import icon_redo, icon_undo
from components.undo_redo.commands import UndoRedoCommandManager
from components.undo_redo.constants import UNDO_REDO_INSTANCE_ID, UndoRedoAttrs
from components_helpers import mk_icon, mk_tooltip
from core.settings_management import NoDefault
logger = logging.getLogger("UndoRedoApp")
@dataclass
class CommandHistory:
attrs: UndoRedoAttrs
tab_key: str | None
digest: str | None # digest to remember
entry: str # digest to remember
key: str # key
path: str # path within the key if only on subitem needs to be updated
class UndoRedo(BaseComponentSingleton):
COMPONENT_INSTANCE_ID = UNDO_REDO_INSTANCE_ID
def __init__(self, session, _id, settings_manager=None, tabs_manager=None):
super().__init__(session, _id, None, settings_manager, tabs_manager)
self.index = -1
self.history = []
self._commands = UndoRedoCommandManager(self)
self._db_engine = settings_manager.get_db_engine()
def snapshot(self, undo_redo_attrs: UndoRedoAttrs, entry, key, path=None):
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.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):
logger.debug(f"Undo command")
if self.index < 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)
previous = self.history[self.index - 1]
previous_state = self._settings_manager.load(self._session, None, digest=previous.digest)
# 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
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
if current.attrs.on_redo 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
def refresh(self):
return self.__ft__(oob=True)
def __ft__(self, oob=False):
return Div(
self._mk_undo(),
self._mk_redo(),
id=self._id,
cls="flex",
hx_swap_oob="true" if oob else None
)
def _mk_undo(self):
if self._can_undo():
command = self.history[self.index]
return mk_tooltip(mk_icon(icon_undo,
size=24,
**self._commands.undo()),
f"Undo '{command.attrs.name}'.")
else:
return mk_tooltip(mk_icon(icon_undo,
size=24,
can_select=False,
cls="mmt-btn-disabled"),
"Nothing to undo.")
def _mk_redo(self):
if self._can_redo():
command = self.history[self.index + 1]
return mk_tooltip(mk_icon(icon_redo,
size=24,
**self._commands.redo()),
f"Redo '{command.attrs.name}'.")
else:
return mk_tooltip(mk_icon(icon_redo,
size=24,
can_select=False,
cls="mmt-btn-disabled"),
"Nothing to redo.")
def _can_undo(self):
return self.index >= 1
def _can_redo(self):
return self.index < len(self.history) - 1

View File

@@ -0,0 +1,24 @@
from dataclasses import dataclass
from typing import Callable
UNDO_REDO_INSTANCE_ID = "__UndoRedo__"
ROUTE_ROOT = "/undo"
class Routes:
Undo = "/undo"
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

View File

@@ -4,10 +4,21 @@
using `_id={WORKFLOW_DESIGNER_INSTANCE_ID}{session['user_id']}{get_unique_id()}`
| Name | value |
|---------------|------------------|
| Canvas | `c_{self._id}` |
| Designer | `d_{self._id}` |
| Error Message | `err_{self._id}` |
| Properties | `p_{self._id}` |
| Spliter | `s_{self._id}` |
| Name | value |
|----------------------------------|--------------------------------|
| Canvas | `c_{self._id}` |
| Designer | `d_{self._id}` |
| Error Message | `err_{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}` |
| Top element | `t_{self._id}` |
| Form for properties | `f_{self._id}_{component_id}` |
| Form for output properties | `fo_{self._id}_{component_id}` |

View File

@@ -35,7 +35,7 @@ def post(session, _id: str, name: str, tab_boundaries: str):
@rt(Routes.AddComponent)
def post(session, _id: str, component_type: str, x: int, y: int):
def post(session, _id: str, component_type: str, x: float, y: float):
logger.debug(
f"Entering {Routes.AddComponent} with args {debug_session(session)}, {_id=}, {component_type=}, {x=}, {y=}")
instance = InstanceManager.get(session, _id)
@@ -43,7 +43,7 @@ def post(session, _id: str, component_type: str, x: int, y: int):
@rt(Routes.MoveComponent)
def post(session, _id: str, component_id: str, x: int, y: int):
def post(session, _id: str, component_id: str, x: float, y: float):
logger.debug(
f"Entering {Routes.MoveComponent} with args {debug_session(session)}, {_id=}, {component_id=}, {x=}, {y=}")
instance = InstanceManager.get(session, _id)
@@ -82,6 +82,14 @@ def post(session, _id: str, designer_height: int):
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)
def post(session, _id: str, component_id: str):
logger.debug(
@@ -129,7 +137,20 @@ def post(session, _id: str, component_id: str, event_name: str, details: dict):
@rt(Routes.PlayWorkflow)
def post(session, _id: str, tab_boundaries: str):
logger.debug(
f"Entering {Routes.PlayWorkflow} with args {debug_session(session)}, {_id=}")
logger.debug(f"Entering {Routes.PlayWorkflow} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.play_workflow(json.loads(tab_boundaries))
@rt(Routes.StopWorkflow)
def post(session, _id: str):
logger.debug(f"Entering {Routes.StopWorkflow} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
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()

View File

@@ -47,16 +47,118 @@
.wkf-properties {
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 {
position: relative;
box-sizing: border-box;
background-image:
linear-gradient(rgba(0,0,0,.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,0,0,.1) 1px, transparent 1px);
background-size: 20px 20px;
}
.wkf-canvas-error {
border: 3px solid var(--color-error);
}
.wkf-toolbox {
min-height: 230px;
width: 8rem; /* w-32 (32 * 0.25rem = 8rem) */
@@ -89,6 +191,11 @@
transition: none;
}
.wkf-workflow-component.error {
background: var(--color-error);
}
.wkf-component-content {
padding: 0.75rem; /* p-3 in Tailwind */
border-radius: 0.5rem; /* rounded-lg in Tailwind */
@@ -99,6 +206,12 @@
align-items: center; /* items-center in Tailwind */
}
.wkf-component-content.error {
background: var(--color-error);
}
.wkf-component-content.not-run {
}
.wkf-connection-line {
position: absolute;
@@ -177,5 +290,3 @@
.wkf-connection-path-arrowhead-selected {
fill:#ef4444 !important;;
}

View File

@@ -1,6 +1,7 @@
function bindWorkflowDesigner(elementId) {
bindWorkflowDesignerToolbox(elementId)
bindWorkflowDesignerSplitter(elementId)
bindWorkflowProperties(elementId)
}
function bindWorkflowDesignerToolbox(elementId) {
@@ -204,7 +205,7 @@ function bindWorkflowDesignerToolbox(elementId) {
// Also trigger server-side selection
utils.makeRequest('/workflows/select-component', {
component_id: designer.selectedComponent
}, `#p_${elementId}`, "outerHTML");
}, `#ppc_${elementId}`, "outerHTML");
},
// 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 = '';
}
});
}

View File

@@ -23,3 +23,6 @@ icon_pause_circle = NotStr(
# fluent RecordStop20Regular
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>""")
# 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>""")

View File

@@ -37,7 +37,7 @@ class WorkflowDesignerCommandManager(BaseCommandManager):
def select_processor(self, component_id: str):
return {
"hx_post": f"{ROUTE_ROOT}{Routes.SelectProcessor}",
"hx-target": f"#p_{self._id}",
"hx-target": f"#ppc_{self._id}",
"hx-swap": "outerHTML",
"hx-trigger": "change",
"hx-vals": f'js:{{"_id": "{self._id}", "component_id": "{component_id}"}}',
@@ -46,7 +46,7 @@ class WorkflowDesignerCommandManager(BaseCommandManager):
def save_properties(self, component_id: str):
return {
"hx_post": f"{ROUTE_ROOT}{Routes.SaveProperties}",
"hx-target": f"#p_{self._id}",
"hx-target": f"#ppc_{self._id}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", "component_id": "{component_id}"}}',
}
@@ -54,7 +54,7 @@ class WorkflowDesignerCommandManager(BaseCommandManager):
def cancel_properties(self, component_id: str):
return {
"hx_post": f"{ROUTE_ROOT}{Routes.CancelProperties}",
"hx-target": f"#p_{self._id}",
"hx-target": f"#ppc_{self._id}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", "component_id": "{component_id}"}}',
}
@@ -62,7 +62,7 @@ class WorkflowDesignerCommandManager(BaseCommandManager):
def on_processor_details_event(self, component_id: str, event_name: str):
return {
"hx_post": f"{ROUTE_ROOT}{Routes.OnProcessorDetailsEvent}",
"hx-target": f"#p_{self._id}",
"hx-target": f"#ppc_{self._id}",
"hx-trigger": "change",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", "component_id": "{component_id}", "event_name": "{event_name}"}}',
@@ -81,7 +81,7 @@ class WorkflowDesignerCommandManager(BaseCommandManager):
"hx_post": f"{ROUTE_ROOT}{Routes.PauseWorkflow}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", "tab_boundaries": getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
"hx-vals": f'js:{{"_id": "{self._id}"}}',
}
def stop_workflow(self):
@@ -89,7 +89,14 @@ class WorkflowDesignerCommandManager(BaseCommandManager):
"hx_post": f"{ROUTE_ROOT}{Routes.StopWorkflow}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", "tab_boundaries": getTabContentBoundaries("{self._owner.tabs_manager.get_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}"}}',
}

View File

@@ -6,46 +6,24 @@ from fasthtml.xtend import Script
from assets.icons import icon_error
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.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.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, \
Connection, WorkflowsDesignerDbManager, WorkflowsPlayerSettings
Connection, WorkflowsDesignerDbManager, ComponentState, WorkflowsDesignerState
from components_helpers import apply_boundaries, mk_tooltip, mk_dialog_buttons, mk_icon
from core.instance_manager import InstanceManager
from core.jira import JiraRequestTypes, DEFAULT_SEARCH_FIELDS
from core.utils import get_unique_id, make_safe_id
from utils.ComponentsInstancesHelper import ComponentsInstancesHelper
from utils.DbManagementHelper import DbManagementHelper
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):
def __init__(self, session,
@@ -61,19 +39,55 @@ class WorkflowDesigner(BaseComponent):
self._key = key
self._designer_settings = designer_settings
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.commands = WorkflowDesignerCommandManager(self)
self.properties = WorkflowDesignerProperties(self._session, f"{self._id}", self)
workflow_name = self._designer_settings.workflow_name
self.player = InstanceManager.get(self._session,
WorkflowPlayer.create_component_id(self._session, workflow_name),
WorkflowPlayer,
settings_manager=self._settings_manager,
tabs_manager=self.tabs_manager,
designer=self,
boundaries=boundaries)
self._error_message = None
def set_boundaries(self, boundaries: dict):
self._boundaries = boundaries
def refresh_designer(self):
return self._mk_elements()
def get_boundaries(self):
return self._boundaries
def refresh_properties(self):
return self._mk_properties()
def get_state(self) -> WorkflowsDesignerState:
return self._state
def get_db(self):
return self._db
def get_key(self):
return self._key
def refresh_designer(self, oob=False):
if oob:
return self._mk_canvas(oob)
else:
return self._mk_elements()
def refresh_properties(self, oob=False):
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):
self._state.component_counter += 1
@@ -90,31 +104,40 @@ class WorkflowDesigner(BaseComponent):
description=info["description"],
properties={"processor_name": PROCESSOR_TYPES[component_type][0]}
)
self._state.components[component_id] = component
self._db.save_state(self._key, self._state) # update db
return self.refresh_designer()
undo_redo_attrs = UndoRedoAttrs(f"Add Component '{component_type}'", on_undo=self.refresh_state)
self._db.save_state(self._key, self._state, undo_redo_attrs) # update db
return self.refresh_designer(), self._undo_redo.refresh()
def move_component(self, component_id, x, y):
if component_id in self._state.components:
self._state.components[component_id].x = int(x)
self._state.components[component_id].y = int(y)
self._db.save_state(self._key, self._state) # update db
component = self._state.components[component_id]
self._state.selected_component_id = component_id
component.x = int(x)
component.y = int(y)
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()
return self.refresh_designer(), self.properties.refresh(mode="form", oob=True), self._undo_redo.refresh()
def delete_component(self, component_id):
# Remove component
if component_id in self._state.components:
component = self._state.components[component_id]
del self._state.components[component_id]
# Remove related connections
self._state.connections = [connection for connection in self._state.connections
if connection.from_id != component_id and connection.to_id != component_id]
# 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)
# Remove related connections
self._state.connections = [connection for connection in self._state.connections
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()
return self.refresh_designer(), self._undo_redo.refresh()
def add_connection(self, from_id, to_id):
# Check if connection already exists
@@ -127,9 +150,10 @@ class WorkflowDesigner(BaseComponent):
self._state.connections.append(connection)
# 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):
for connection in self._state.connections:
@@ -137,65 +161,85 @@ class WorkflowDesigner(BaseComponent):
self._state.connections.remove(connection)
# 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):
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):
if component_id in self._state.components:
self._state.selected_component_id = component_id
self._db.save_state(self._key, self._state)
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.refresh_properties()
return self.properties.refresh(mode="form"), self._undo_redo.refresh()
def save_properties(self, component_id: str, details: dict):
if component_id in self._state.components:
component = self._state.components[component_id]
component.properties = details
self._db.save_state(self._key, self._state)
component.properties |= details
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}")
return self.refresh_properties()
return self.properties.refresh(mode="form"), self._undo_redo.refresh()
def cancel_properties(self, component_id: str):
if component_id in self._state.components:
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):
if component_id in self._state.components:
component = self._state.components[component_id]
component.properties = {"processor_name": processor_name}
self._db.save_state(self._key, self._state)
return self.refresh_properties()
undo_redo_attrs = UndoRedoAttrs(f"Set Processor for {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 play_workflow(self, boundaries: dict):
if self._state.selected_component_id is None:
return self.error_message("No component selected")
self._error_message = None
workflow_name = self._designer_settings.workflow_name
player = InstanceManager.get(self._session,
WorkflowPlayer.create_component_id(self._session, workflow_name),
WorkflowPlayer,
settings_manager=self._settings_manager,
tabs_manager=self.tabs_manager,
player_settings=WorkflowsPlayerSettings(workflow_name,
list(self._state.components.values())),
boundaries=boundaries)
try:
player.run()
self.tabs_manager.add_tab(f"Workflow {workflow_name}", player, player.key)
return self.tabs_manager.refresh()
self.player.run()
if self.player.global_error:
# Show the error message in the same tab
self._error_message = self.player.global_error
except Exception as e:
return self.error_message(str(e))
else:
self.properties.set_entry_selector_data(self.player.nb_items)
# change the tab and display the results
self.player.set_boundaries(boundaries)
self.tabs_manager.add_tab(f"Workflow {self._designer_settings.workflow_name}", self.player, self.player.key)
return self.tabs_manager.refresh()
def stop_workflow(self):
self._error_message = None
self.player.stop()
self.properties.set_entry_selector_data(0)
return self.tabs_manager.refresh()
def on_processor_details_event(self, component_id: str, event_name: str, details: dict):
if component_id in self._state.components:
@@ -204,21 +248,30 @@ class WorkflowDesigner(BaseComponent):
component.properties["repository"] = details["repository"]
tables = DbManagementHelper.list_tables(self._session, details["repository"])
component.properties["table"] = tables[0] if len(tables) > 0 else None
elif event_name == "OnJiraRequestTypeChanged":
component.properties["request_type"] = details["request_type"]
return self.refresh_properties()
return self.properties.refresh(mode="form")
def error_message(self, message: str):
self._error_message = message
return self.tabs_manager.refresh()
def get_workflow_name(self):
return self._designer_settings.workflow_name
def __ft__(self):
def get_workflow_components(self):
return self._state.components.values()
def get_workflow_connections(self):
return self._state.connections
def __ft__(self, oob=False):
return Div(
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(
self._mk_media(),
# self._mk_refresh_button(),
self._mk_error_message(),
cls="flex mb-2"
cls="flex mb-2",
id=f"t_{self._id}"
),
self._mk_designer(),
Div(cls="wkf-splitter", id=f"s_{self._id}"),
@@ -226,6 +279,7 @@ class WorkflowDesigner(BaseComponent):
Script(f"bindWorkflowDesigner('{self._id}');"),
**apply_boundaries(self._boundaries),
id=f"{self._id}",
hx_swap_oob='true' if oob else None,
)
def _mk_connection_svg(self, conn: Connection):
@@ -259,19 +313,67 @@ class WorkflowDesigner(BaseComponent):
</svg>
"""
def _mk_component(self, component: WorkflowComponent):
runtime_state = self.player.get_component_runtime_state(component.id)
info = COMPONENT_TYPES[component.type]
is_selected = self._state.selected_component_id == component.id
tooltip_content = None
tooltip_class = ""
if runtime_state.state == ComponentState.FAILURE:
state_class = 'error' # To be styled with a red highlight
tooltip_content = runtime_state.error_message
tooltip_class = "mmt-tooltip"
elif runtime_state.state == ComponentState.NOT_RUN:
state_class = 'not-run' # To be styled as greyed-out
else:
state_class = ''
return Div(
# Input connection point
Div(cls="wkf-connection-point wkf-input-point",
data_component_id=component.id,
data_point_type="input"),
# Component content
Div(
Span(info["icon"], cls="text-xl mb-1"),
H4(component.title, cls="font-semibold text-xs"),
cls=f"wkf-component-content {info['color']} {state_class}"
),
# Output connection point
Div(cls="wkf-connection-point wkf-output-point",
data_component_id=component.id,
data_point_type="output"),
cls=f"wkf-workflow-component w-32 {'selected' if is_selected else ''} {tooltip_class}",
style=f"left: {component.x}px; top: {component.y}px;",
data_component_id=component.id,
data_tooltip=tooltip_content,
draggable="true"
)
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(
# Render connections
*[NotStr(self._mk_connection_svg(conn)) for conn in self._state.connections],
# Render components
*[self._mk_workflow_component(comp) for comp in self._state.components.values()],
*[self._mk_component(comp) for comp in self._state.components.values()],
)
def _mk_canvas(self, oob=False):
return Div(
self._mk_elements(),
cls="wkf-canvas flex-1 rounded-lg border flex-1",
cls=f"wkf-canvas flex-1 rounded-lg border flex-1 {'wkf-canvas-error' if self._error_message else ''}",
id=f"c_{self._id}",
hx_swap_oob='true' if oob else None,
),
@@ -291,7 +393,7 @@ class WorkflowDesigner(BaseComponent):
self._mk_toolbox(), # (Left side)
self._mk_canvas(), # (Right side)
cls="wkf-designer flex gap-4",
cls="wkf-designer flex gap-1",
id=f"d_{self._id}",
style=f"height:{self._state.designer_height}px;"
)
@@ -299,11 +401,14 @@ class WorkflowDesigner(BaseComponent):
def _mk_media(self):
return Div(
mk_icon(icon_play, cls="mr-1", **self.commands.play_workflow()),
mk_icon(icon_pause, cls="mr-1", **self.commands.play_workflow()),
mk_icon(icon_stop, cls="mr-1", **self.commands.play_workflow()),
mk_icon(icon_pause, cls="mr-1", **self.commands.pause_workflow()),
mk_icon(icon_stop, cls="mr-1", **self.commands.stop_workflow()),
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):
if not self._error_message:
return Div()
@@ -328,6 +433,17 @@ class WorkflowDesigner(BaseComponent):
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_header():
return Div(
@@ -361,36 +477,91 @@ class WorkflowDesigner(BaseComponent):
return Div(
Form(
_mk_header(),
_mk_select(),
self._mk_processor_properties(component, selected_processor_name),
Div(
Input(type="radio", name=f"pt_{self._id}", cls="tab", aria_label="Properties", checked="checked"),
Div(
_mk_select(),
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",
on_ok=self.commands.save_properties(component_id),
on_cancel=self.commands.cancel_properties(component_id)),
cls="font-mono text-sm",
id=f"f_{self._id}_{component_id}",
),
Script(f"bindFormData('f_{self._id}_{component_id}');")
)
def _mk_properties(self):
return Div(
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;",
id=f"p_{self._id}",
)
def _mk_properties(self, oob=False):
return self.properties.__ft__(oob)
@staticmethod
def _mk_jira_processor_details(component):
def _mk_jira_processor_details(self, component):
def _mk_option(name):
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:
return Div(
Input(type="text",
name="request",
value=component.properties.get("request", ""),
placeholder="Enter JQL",
cls="input w-full"),
P("Write your jql code"),
)
elif request_type == JiraRequestTypes.Comments.value:
return Div(
Input(type="text",
name="request",
value=component.properties.get("request", ""),
placeholder="Issue id",
cls="input w-full"),
P("Put the issue id here"),
)
def _mk_extra_parameters():
if request_type == JiraRequestTypes.Search.value:
return Input(type="text",
name="fields",
value=component.properties.get("fields", DEFAULT_SEARCH_FIELDS),
placeholder="default fields",
cls="input w-full ml-2")
else:
return None
request_type = component.properties.get("request_type", JiraRequestTypes.Search.value)
return Div(
Fieldset(
Legend("JQL", cls="fieldset-legend"),
Input(type="text",
name="jira_jql",
value=component.properties.get("jira_jql", ""),
placeholder="Enter JQL",
cls="input w-full"),
P("Write your jsl code"),
Div(
Select(
*[_mk_option(enum) for enum in JiraRequestTypes],
cls="select w-xs",
name="request_type",
**self.commands.on_processor_details_event(component.id, "OnJiraRequestTypeChanged"),
),
_mk_extra_parameters(),
cls="flex"),
_mk_input_group(),
cls="fieldset bg-base-200 border-base-300 rounded-box border p-4"
),
)
@@ -470,7 +641,8 @@ class WorkflowDesigner(BaseComponent):
value=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("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"
)
)
@@ -501,30 +673,3 @@ class WorkflowDesigner(BaseComponent):
draggable="true",
data_type=component_type
)
@staticmethod
def _mk_workflow_component(component: WorkflowComponent):
info = COMPONENT_TYPES[component.type]
return Div(
# Input connection point
Div(cls="wkf-connection-point wkf-input-point",
data_component_id=component.id,
data_point_type="input"),
# Component content
Div(
Span(info["icon"], cls="text-xl mb-1"),
H4(component.title, cls="font-semibold text-xs"),
cls=f"wkf-component-content {info['color']}"
),
# Output connection point
Div(cls="wkf-connection-point wkf-output-point",
data_component_id=component.id,
data_point_type="output"),
cls="wkf-workflow-component w-32",
style=f"left: {component.x}px; top: {component.y}px;",
data_component_id=component.id,
draggable="true"
)

View File

@@ -0,0 +1,384 @@
from fasthtml.common import *
from components.BaseComponent import BaseComponent
from components.entryselector.components.EntrySelector import EntrySelector
from components.jsonviewer.components.JsonViewer import JsonViewer
from components.workflows.constants import COMPONENT_TYPES, PROCESSOR_TYPES
from components_helpers import mk_dialog_buttons
from core.instance_manager import InstanceManager
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)
self.entry_selector: EntrySelector = InstanceManager.new(self._session,
EntrySelector,
owner=self,
hooks={
"on_entry_selected": self.on_entry_selector_changed})
self._input_jsonviewer: JsonViewer = InstanceManager.new(self._session,
JsonViewer)
self._output_jsonviewer: JsonViewer = InstanceManager.new(self._session,
JsonViewer)
def set_entry_selector_data(self, data):
self.entry_selector.set_data(data)
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 on_entry_selector_changed(self, entry):
entry = int(entry)
input_data, output_data = None, None
selected_component_id = self._owner.get_state().selected_component_id
if selected_component_id is not None:
runtime_state = self._owner.player.runtime_states.get(selected_component_id, None)
if runtime_state is not None:
input_content = runtime_state.input[entry] if len(runtime_state.input) > entry else None
output_content = runtime_state.output[entry] if len(runtime_state.output) > entry else None
if input_content is not None:
self._input_jsonviewer.set_data(input_content.item.as_dict())
input_data = self._input_jsonviewer
if output_content is not None:
self._output_jsonviewer.set_data(output_content.item.as_dict())
output_data = self._output_jsonviewer
return (self._mk_input(content=input_data, oob=True),
self._mk_output(content=output_data, oob=True))
def _mk_layout(self):
return Div(
self.entry_selector,
Div(
self._mk_input(),
self._mk_properties(),
self._mk_output(),
cls="flex",
style="height: 100%; width: 100%; flex: 1;"
)
)
def _mk_input(self, content=None, oob=False):
return Div(
content,
id=f"pi_{self._id}",
style=f"width: {self.layout.input_width}px;",
cls="wkf-properties-input",
hx_swap_oob=f'true' if oob else None,
)
def _mk_output(self, content=None, oob=False):
return Div(
content,
id=f"po_{self._id}",
style=f"width: {self.layout.output_width}px;",
cls="wkf-properties-output",
hx_swap_oob=f'true' if oob else None,
)
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

View File

@@ -1,3 +1,6 @@
from collections import deque
from dataclasses import dataclass
import pandas as pd
from fasthtml.components import *
@@ -6,10 +9,12 @@ from components.datagrid_new.components.DataGrid import DataGrid
from components.datagrid_new.settings import DataGridSettings
from components.workflows.commands import WorkflowPlayerCommandManager
from components.workflows.constants import WORKFLOW_PLAYER_INSTANCE_ID, ProcessorTypes
from components.workflows.db_management import WorkflowsPlayerSettings
from components.workflows.db_management import WorkflowComponentRuntimeState, \
WorkflowComponent, ComponentState
from core.instance_manager import InstanceManager
from core.utils import get_unique_id, make_safe_id
from workflow.engine import WorkflowEngine, TableDataProducer, DefaultDataPresenter, DefaultDataFilter
from workflow.DefaultDataPresenter import DefaultDataPresenter
from workflow.engine import WorkflowEngine, TableDataProducer, DefaultDataFilter, JiraDataProducer
grid_settings = DataGridSettings(
header_visible=True,
@@ -19,18 +24,24 @@ grid_settings = DataGridSettings(
open_settings_visible=False)
@dataclass
class WorkflowsPlayerError(Exception):
component_id: str
error: Exception
class WorkflowPlayer(BaseComponent):
def __init__(self, session,
_id=None,
settings_manager=None,
tabs_manager=None,
player_settings: WorkflowsPlayerSettings = None,
designer=None,
boundaries: dict = None):
super().__init__(session, _id)
self._settings_manager = settings_manager
self.tabs_manager = tabs_manager
self.key = f"__WorkflowPlayer_{player_settings.workflow_name}"
self._player_settings = player_settings
self._designer = designer
self.key = f"__WorkflowPlayer_{designer.get_workflow_name()}"
self._boundaries = boundaries
self.commands = WorkflowPlayerCommandManager(self)
self._datagrid = InstanceManager.get(self._session,
@@ -39,31 +50,177 @@ class WorkflowPlayer(BaseComponent):
key=self.key,
grid_settings=grid_settings,
boundaries=boundaries)
self.runtime_states = {}
self.global_error = None
self.has_error = False
self.nb_items = 0
def set_boundaries(self, boundaries: dict):
self._datagrid.set_boundaries(boundaries)
def get_component_runtime_state(self, component_id: str):
# return a default value if the player hasn't been played yet
return self.runtime_states.get(component_id, WorkflowComponentRuntimeState(component_id))
def run(self):
engine = WorkflowEngine()
for component in self._player_settings.components:
if component.type == ProcessorTypes.Producer and component.properties["processor_name"] == "Repository":
engine.add_processor(
TableDataProducer(self._session, self._settings_manager, component.properties["repository"],
component.properties["table"]))
elif component.type == ProcessorTypes.Filter and component.properties["processor_name"] == "Default":
engine.add_processor(DefaultDataFilter(component.properties["filter"]))
elif component.type == ProcessorTypes.Presenter and component.properties["processor_name"] == "Default":
engine.add_processor(DefaultDataPresenter(component.properties["columns"]))
# at least one connection is required to play
if len(self._designer.get_workflow_connections()) == 0:
self.global_error = "No connections defined."
return
self._init_state(ComponentState.NOT_RUN)
try:
sorted_components = self._get_sorted_components()
engine = self._get_engine(sorted_components)
except ValueError as e:
# Handle workflow structure errors (e.g., cycles)
self.has_error = True
self.global_error = f"Workflow configuration error: {e}"
return
except WorkflowsPlayerError as ex:
self.has_error = True
self.global_error = self._get_global_error_as_str(ex, "Failed to init ")
if ex.component_id in self.runtime_states:
self.runtime_states[ex.component_id].state = ComponentState.FAILURE
self.runtime_states[ex.component_id].error_message = str(ex.error)
return
res = engine.run_to_list()
if engine.has_error and not engine.errors:
self.has_error = True
self.global_error = engine.global_error
else: # loop through the components and update the runtime states
self.nb_items = engine.nb_items
for component in sorted_components:
runtime_state = self.runtime_states.get(component.id)
if component.id not in engine.errors:
runtime_state.state = ComponentState.SUCCESS
runtime_state.input = engine.debug[component.id]["input"]
runtime_state.output = engine.debug[component.id]["output"]
continue
# the component failed
error = engine.errors[component.id]
runtime_state.state = ComponentState.FAILURE
runtime_state.error_message = str(error)
self.global_error = self._get_global_error_as_str(error, "Error in ") # update global error as well
self.has_error = True
break # the remaining components will remain as NOT_RUN
data = [row.as_dict() for row in res]
df = pd.DataFrame(data)
self._datagrid.init_from_dataframe(df)
def stop(self):
self._init_state()
def get_dataframe(self):
return self._datagrid.get_dataframe()
def __ft__(self):
return Div(
self._datagrid,
id=self._id,
)
def _get_sorted_components(self) -> list[WorkflowComponent]:
"""
Sorts the workflow components based on their connections using topological sort.
- A connection from component A to B means A must come before B.
- Raises a ValueError if a cycle is detected.
- Raises a ValueError if a connection references a non-existent component.
- Ignores components that are not part of any connection.
:return: A list of sorted WorkflowComponent objects.
"""
components_by_id = {c.id: c for c in self._designer.get_workflow_components()}
# Get all component IDs involved in connections
involved_ids = set()
for conn in self._designer.get_workflow_connections():
involved_ids.add(conn.from_id)
involved_ids.add(conn.to_id)
# Check if all involved components exist
for component_id in involved_ids:
if component_id not in components_by_id:
raise ValueError(f"Component with ID '{component_id}' referenced in connections but does not exist.")
# Build the graph (adjacency list and in-degrees) for involved components
adj = {cid: [] for cid in involved_ids}
in_degree = {cid: 0 for cid in involved_ids}
for conn in self._designer.get_workflow_connections():
# from_id -> to_id
adj[conn.from_id].append(conn.to_id)
in_degree[conn.to_id] += 1
# Find all sources (nodes with in-degree 0)
queue = deque([cid for cid in involved_ids if in_degree[cid] == 0])
sorted_order = []
while queue:
u = queue.popleft()
sorted_order.append(u)
for v in adj.get(u, []):
in_degree[v] -= 1
if in_degree[v] == 0:
queue.append(v)
# Check for cycles
if len(sorted_order) != len(involved_ids):
raise ValueError("A cycle was detected in the workflow connections.")
# Return sorted components
return [components_by_id[cid] for cid in sorted_order]
def _get_engine(self, sorted_components) -> WorkflowEngine:
# first reorder the component, according to the connection definitions
engine = WorkflowEngine()
for component in sorted_components:
key = (component.type, component.properties["processor_name"])
try:
if key == (ProcessorTypes.Producer, "Repository"):
engine.add_processor(
TableDataProducer(self._session,
self._settings_manager,
component.id,
component.properties["repository"],
component.properties["table"]))
elif key == (ProcessorTypes.Producer, "Jira"):
request_type = component.properties["request_type"]
engine.add_processor(
JiraDataProducer(self._session,
self._settings_manager,
component.id,
component.properties["request_type"],
component.properties[f"{request_type}_request"],
component.properties.get(f"{request_type}_fields", None)))
elif key == (ProcessorTypes.Filter, "Default"):
engine.add_processor(DefaultDataFilter(component.id, component.properties["filter"]))
elif key == (ProcessorTypes.Presenter, "Default"):
engine.add_processor(DefaultDataPresenter(component.id, component.properties["columns"]))
else:
raise ValueError(
f"Unsupported processor : type={component.type}, name={component.properties['processor_name']}")
except Exception as e:
raise WorkflowsPlayerError(component.id, e)
return engine
def _init_state(self, state: ComponentState = ComponentState.SUCCESS):
self.global_error = None
self.has_error = False
self.runtime_states = {component.id: WorkflowComponentRuntimeState(component.id, state)
for component in self._designer.get_workflow_components()}
@staticmethod
def create_component_id(session, suffix=None):
prefix = f"{WORKFLOW_PLAYER_INSTANCE_ID}{session['user_id']}"
@@ -71,3 +228,10 @@ class WorkflowPlayer(BaseComponent):
suffix = get_unique_id()
return make_safe_id(f"{prefix}{suffix}")
@staticmethod
def _get_global_error_as_str(error, prefix=""):
if hasattr(error, "component_id"):
return f"{prefix}component '{error.component_id}': {error.error}"
else:
return str(error)

View File

@@ -7,7 +7,7 @@ from components.BaseComponent import BaseComponentSingleton
from components.form.components.MyForm import MyForm, FormField
from components.workflows.commands import WorkflowsCommandManager
from components.workflows.components.WorkflowDesigner import WorkflowDesigner
from components.workflows.constants import WORKFLOWS_INSTANCE_ID
from components.workflows.constants import WORKFLOWS_INSTANCE_ID, WORKFLOWS_ROLE
from components.workflows.db_management import WorkflowsDbManager, WorkflowsDesignerSettings
from components_helpers import mk_ellipsis, mk_icon
from core.instance_manager import InstanceManager
@@ -19,7 +19,7 @@ class Workflows(BaseComponentSingleton):
COMPONENT_INSTANCE_ID = WORKFLOWS_INSTANCE_ID
def __init__(self, session, _id, settings_manager=None, tabs_manager=None):
super().__init__(session, _id, settings_manager, tabs_manager)
super().__init__(session, _id, WORKFLOWS_ROLE, settings_manager, tabs_manager)
self.commands = WorkflowsCommandManager(self)
self.db = WorkflowsDbManager(session, settings_manager)
@@ -80,7 +80,7 @@ class Workflows(BaseComponentSingleton):
def refresh(self):
return self._mk_workflows(True)
def __ft__(self):
def render(self):
return Div(
Div(cls="divider"),
Div(

View File

@@ -1,4 +1,5 @@
WORKFLOWS_INSTANCE_ID = "__Workflows__"
WORKFLOWS_ROLE = "workflows"
WORKFLOW_DESIGNER_INSTANCE_ID = "__WorkflowDesigner__"
WORKFLOW_PLAYER_INSTANCE_ID = "__WorkflowPlayer__"
WORKFLOWS_DB_ENTRY = "Workflows"
@@ -6,11 +7,39 @@ WORKFLOW_DESIGNER_DB_ENTRY = "WorkflowDesigner"
WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY = "Settings"
WORKFLOW_DESIGNER_DB_STATE_ENTRY = "State"
class ProcessorTypes:
Producer = "producer"
Filter = "filter"
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"
@@ -25,6 +54,7 @@ class Routes:
AddConnection = "/add-connection"
DeleteConnection = "/delete-connection"
ResizeDesigner = "/resize-designer"
UpdatePropertiesLayout = "/update-properties-layout"
SaveProperties = "/save-properties"
CancelProperties = "/cancel-properties"
SelectProcessor = "/select-processor"
@@ -32,4 +62,4 @@ class Routes:
PlayWorkflow = "/play-workflow"
PauseWorkflow = "/pause-workflow"
StopWorkflow = "/stop-workflow"
Refresh = "/refresh"

View File

@@ -1,13 +1,26 @@
import enum
import logging
from dataclasses import dataclass, field
from components.undo_redo.constants import UndoRedoAttrs
from components.workflows.constants import WORKFLOWS_DB_ENTRY, WORKFLOW_DESIGNER_DB_ENTRY, \
WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY, WORKFLOW_DESIGNER_DB_STATE_ENTRY
from core.settings_management import SettingsManager
from core.utils import make_safe_id
from utils.ComponentsInstancesHelper import ComponentsInstancesHelper
logger = logging.getLogger("WorkflowsSettings")
class ComponentState(enum.Enum):
"""
Represents the execution state of a workflow component.
"""
SUCCESS = "success"
FAILURE = "failure"
NOT_RUN = "not_run"
# Data structures
@dataclass
class WorkflowComponent:
@@ -27,6 +40,18 @@ class Connection:
to_id: str
@dataclass
class WorkflowComponentRuntimeState:
"""
Represents the runtime state of a single workflow component.
"""
id: str
state: ComponentState = ComponentState.SUCCESS
error_message: str | None = None
input: list = None
output: list = None
@dataclass
class WorkflowsDesignerSettings:
workflow_name: str = "No Name"
@@ -36,15 +61,12 @@ class WorkflowsDesignerSettings:
class WorkflowsDesignerState:
components: dict[str, WorkflowComponent] = field(default_factory=dict)
connections: list[Connection] = field(default_factory=list)
component_counter = 0
designer_height = 230
selected_component_id = None
@dataclass
class WorkflowsPlayerSettings:
workflow_name: str = "No Name"
components: list[WorkflowComponent] = None
component_counter: int = 0
designer_height: int = 230
properties_input_width: int = None
properties_properties_width: int = None
properties_output_width: int = None
selected_component_id: str | None = None
@dataclass
@@ -143,10 +165,11 @@ class WorkflowsDesignerDbManager:
def __init__(self, session: dict, settings_manager: SettingsManager):
self._session = session
self._settings_manager = settings_manager
self._undo_redo = ComponentsInstancesHelper.get_undo_redo(session)
@staticmethod
def _get_db_entry(key):
return f"{WORKFLOW_DESIGNER_DB_ENTRY}_{key}"
return make_safe_id(f"{WORKFLOW_DESIGNER_DB_ENTRY}_{key}")
def save_settings(self, key: str, settings: WorkflowsDesignerSettings):
self._settings_manager.put(self._session,
@@ -154,11 +177,17 @@ class WorkflowsDesignerDbManager:
WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY,
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._get_db_entry(key),
db_entry,
WORKFLOW_DESIGNER_DB_STATE_ENTRY,
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):
items = {}

View File

@@ -3,9 +3,10 @@ from fasthtml.components import *
from core.utils import merge_classes
def mk_icon(icon, size=20, can_select=True, cls='', tooltip=None, **kwargs):
def mk_icon(icon, size=20, can_select=True, can_hover=False, cls='', tooltip=None, **kwargs):
merged_cls = merge_classes(f"icon-{size}",
'icon-btn' if can_select else '',
'mmt-btn' if can_hover else '',
cls,
kwargs)
return mk_tooltip(icon, tooltip, cls=merged_cls, **kwargs) if tooltip else Div(icon, cls=merged_cls, **kwargs)

View File

@@ -13,6 +13,10 @@ load_dotenv()
DB_PATH = os.getenv("DB_PATH", "tools.db")
logger.info(f"{DB_PATH=}")
# Custom database engine settings
DBENGINE_PATH = os.getenv("DBENGINE_PATH", ".mytools_db")
logger.info(f"{DBENGINE_PATH=}")
# Authentication settings
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")

View File

@@ -48,6 +48,9 @@ class Expando:
return self._props.copy()
def to_dict(self, mappings: dict) -> dict:
"""
Return the information as a dictionary, with the given mappings
"""
return {prop_name: self.get(path) for path, prop_name in mappings.items() if prop_name is not None}
def __hasattr__(self, item):

View File

@@ -0,0 +1,18 @@
"""Database manager for application initialization."""
import logging
from .user_database import Database, set_user_db
logger = logging.getLogger(__name__)
def initialize_database():
"""Initialize the application database."""
try:
# Create default database instance
db_instance = Database()
set_user_db(db_instance)
logger.info("Database initialized successfully")
return db_instance
except Exception as e:
logger.error(f"Failed to initialize database: {e}")
raise

View File

@@ -271,6 +271,42 @@ class DbEngine:
except KeyError:
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):
"""
Lists all folders in the root directory
@@ -312,7 +348,7 @@ class DbEngine:
return []
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)
def _serialize(self, obj):

View File

@@ -0,0 +1,76 @@
from fastcore.basics import NotStr
from core.utils import merge_classes
attr_map = {
"cls": "class",
"_id": "id",
}
def safe_attr(attr_name):
attr_name = attr_name.replace("hx_", "hx-")
attr_name = attr_name.replace("data_", "data-")
return attr_map.get(attr_name, attr_name)
def to_html(item):
if item is None:
return ""
elif isinstance(item, str):
return item
elif isinstance(item, (int, float, bool)):
return str(item)
elif isinstance(item, MyFt):
return item.to_html()
elif isinstance(item, NotStr):
return str(item)
else:
raise Exception(f"Unsupported type: {type(item)}, {item=}")
class MyFt:
def __init__(self, tag, *args, **kwargs):
self.tag = tag
self.children = args
self.attrs = {safe_attr(k): v for k, v in kwargs.items()}
def to_html(self):
body_items = [to_html(item) for item in self.children]
return f"<{self.tag} {' '.join(f'{k}="{v}"' for k, v in self.attrs.items())}>{' '.join(body_items)}</div>"
def __ft__(self):
return NotStr(self.to_html())
class MyDiv(MyFt):
def __init__(self, *args, **kwargs):
super().__init__("div", *args, **kwargs)
class MySpan(MyFt):
def __init__(self, *args, **kwargs):
super().__init__("span", *args, **kwargs)
def mk_my_ellipsis(txt: str, cls='', **kwargs):
merged_cls = merge_classes("truncate",
cls,
kwargs)
return MyDiv(txt, cls=merged_cls, data_tooltip=txt, **kwargs)
def mk_my_icon(icon, size=20, can_select=True, can_hover=False, cls='', tooltip=None, **kwargs):
merged_cls = merge_classes(f"icon-{size}",
'icon-btn' if can_select else '',
'mmt-btn' if can_hover else '',
cls,
kwargs)
return mk_my_tooltip(icon, tooltip, cls=merged_cls, **kwargs) if tooltip else MyDiv(icon, cls=merged_cls, **kwargs)
def mk_my_tooltip(element, tooltip: str, cls='', **kwargs):
merged_cls = merge_classes("mmt-tooltip",
cls,
kwargs)
return MyDiv(element, cls=merged_cls, data_tooltip=tooltip, **kwargs)

View File

@@ -47,6 +47,10 @@ class InstanceManager:
return InstanceManager._instances[key]
@staticmethod
def new(session, instance_type, **kwargs):
return InstanceManager.get(session, instance_type.create_component_id(session), instance_type, **kwargs)
@staticmethod
def register(session: dict | None, instance, instance_id: str = None):
"""

296
src/core/jira.py Normal file
View File

@@ -0,0 +1,296 @@
import json
import logging
from enum import Enum
import requests
from requests.auth import HTTPBasicAuth
from core.Expando import Expando
JIRA_ROOT = "https://altares.atlassian.net/rest/api/3"
DEFAULT_HEADERS = {"Accept": "application/json"}
DEFAULT_SEARCH_FIELDS = "summary,status,assignee"
logger = logging.getLogger("Jira")
class NotFound(Exception):
pass
class JiraRequestTypes(Enum):
Search = "search"
Issue = "issue"
Comments = "comments"
Versions = "versions"
class Jira:
"""Manage default operation to JIRA"""
def __init__(self, user_name: str, api_token: str, fields=DEFAULT_SEARCH_FIELDS):
"""
Prepare a connection to JIRA
The initialisation do not to anything,
It only stores the user_name and the api_token
Note that user_name and api_token is the recommended way to connect,
therefore, the only supported here
:param user_name:
:param api_token:
"""
self.user_name = user_name
self.api_token = api_token
self.auth = HTTPBasicAuth(self.user_name, self.api_token)
self.fields = fields
def test(self):
logger.debug(f"test with no parameters")
url = f"{JIRA_ROOT}/myself"
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}")
return response
def issue(self, issue_id: str) -> list[Expando]:
"""
Retrieve an issue
:param issue_id:
:return:
"""
logger.debug(f"comments with {issue_id=}")
url = f"{JIRA_ROOT}/issue/{issue_id}"
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}")
return [Expando(json.loads(response.text))]
def fields(self) -> list[Expando]:
"""
Retrieve the list of all fields for an issue
:return:
"""
url = f"{JIRA_ROOT}/field"
response = requests.request(
"GET",
url,
headers=DEFAULT_HEADERS,
auth=self.auth
)
as_dict = json.loads(response.text)
return [Expando(field) for field in as_dict]
def search(self, jql: str, fields=None) -> list[Expando]:
"""
Executes a JQL and returns the list of issues
:param jql:
:param fields: list of fields to retrieve
:return:
"""
logger.debug(f"search with {jql=}, {fields=}")
if not jql:
raise ValueError("Jql cannot be empty.")
if not fields:
fields = self.fields
url = f"{JIRA_ROOT}/search"
logger.debug(f" url: {url}")
headers = DEFAULT_HEADERS.copy()
headers["Content-Type"] = "application/json"
payload = {
"fields": [f.strip() for f in fields.split(",")],
"fieldsByKeys": False,
"jql": jql,
"maxResults": 500, # Does not seem to be used. It's always 100 !
"startAt": 0
}
logger.debug(f" payload: {payload}")
result = []
while True:
logger.debug(f" Request startAt '{payload['startAt']}'")
response = requests.request("POST",
url,
data=json.dumps(payload),
headers=headers,
auth=self.auth)
logger.debug(f" response: {response}")
logger.debug(f" response.text: {response.text}")
if response.status_code != 200:
raise Exception(self._format_error(response))
as_dict = json.loads(response.text)
result += as_dict["issues"]
if as_dict["startAt"] + as_dict["maxResults"] >= as_dict["total"]:
logger.debug(f" response: {response}")
# We retrieve more than the total nuber of items
break
payload["startAt"] += as_dict["maxResults"]
return [Expando(issue) for issue in result]
def comments(self, issue_id: str) -> list[Expando]:
"""
Retrieve the list of comments for an issue
:param issue_id:
:return:
"""
logger.debug(f"comments with {issue_id=}")
url = f"{JIRA_ROOT}/issue/{issue_id}/comment"
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 Exception(self._format_error(response))
as_dict = json.loads(response.text)
result = as_dict["comments"]
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]:
"""
Executes a jql and returns list of dict
The <code>issue</code> object, returned by the <ref>jql</ref> methods
contains all the fields for Jira. They are not all necessary
This method selects the required fields
:param jql:
:param mappings:
:param updates: List of updates (lambda on issue) to perform
:return:
"""
logger.debug(f"Processing extract using mapping {mappings}")
def _get_field(mapping):
"""Returns the meaningful jira field, for the mapping description path"""
fields = mapping.split(".")
return fields[1] if len(fields) > 1 and fields[0] == "fields" else fields[0]
# retrieve the list of requested fields from what was asked in the mapping
jira_fields = [_get_field(mapping) for mapping in mappings]
as_string = ", ".join(jira_fields)
issues = self.issues(jql, as_string)
for issue in issues:
# apply updates if needed
if updates:
for update in updates:
update(issue)
row = {cvs_col: issue.get(jira_path) for jira_path, cvs_col in mappings.items() if cvs_col is not None}
yield row
def get_version(self, project_key, version_name):
"""
Given a project name and a version name
returns fixVersion number in JIRA
:param project_key:
:param version_name:
:return:
"""
for version in self.versions(project_key):
if version.name == version_name:
return version
raise NotFound()
def get_all_fields(self):
"""
Helper function that returns the list of all field that can be requested in an issue
:return:
"""
url = f"{JIRA_ROOT}/field"
response = requests.request(
"GET",
url,
headers=DEFAULT_HEADERS,
auth=self.auth
)
as_dict = json.loads(response.text)
return [Expando(issue) for issue in as_dict]
@staticmethod
def update_customer_refs(issue: Expando, bug_only=True, link_name=None):
issue["ticket_customer_refs"] = []
if bug_only and issue.fields.issuetype.name != "Bug":
return
for issue_link in issue.fields.issuelinks: # [i_link for i_link in issue.fields.issuelinks if i_link["type"]["name"] == "Relates"]:
if link_name and issue_link["type"]["name"] not in link_name:
continue
direction = "inwardIssue" if "inwardIssue" in issue_link else "outwardIssue"
related_issue_key = issue_link[direction]["key"]
if related_issue_key.startswith("ITSUP"):
issue.ticket_customer_refs.append(related_issue_key)
continue
@staticmethod
def _format_error(response):
if "errorMessages" in response.text:
error_messages = json.loads(response.text)["errorMessages"]
else:
error_messages = response.text
return f"Error {response.status_code} : {response.reason} : {error_messages}"

189
src/core/preprocessor.py Normal file
View 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

View File

@@ -1,6 +1,7 @@
import logging
from datetime import datetime
import config
from constants import NOT_LOGGED, NO_SESSION
from core.dbengine import DbEngine, TAG_PARENT, TAG_USER, TAG_DATE, DbException
from core.settings_objects import *
@@ -92,16 +93,16 @@ class MemoryDbEngine:
class SettingsManager:
def __init__(self, engine=None):
self._db_engine = engine or DbEngine()
self._db_engine = engine or DbEngine(config.DBENGINE_PATH)
def save(self, session: dict, entry: str, obj: object):
user_id, user_email = self._get_user(session)
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)
try:
return self._db_engine.load(user_id, entry)
return self._db_engine.load(user_id, entry, digest)
except DbException:
return default
@@ -128,6 +129,14 @@ class SettingsManager:
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):
return self._db_engine
@@ -177,7 +186,7 @@ class GenericDbManager:
if key.startswith("_"):
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)):
raise AttributeError(f"Settings '{self._obj_entry}' has no attribute '{key}'.")
@@ -188,7 +197,7 @@ class GenericDbManager:
if item.startswith("_"):
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)):
raise AttributeError(f"Settings '{self._obj_entry}' has no attribute '{item}'.")
@@ -250,8 +259,20 @@ class NestedSettingsManager:
self._settings_manager.save(self._session, self._obj_entry, settings)
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):
raise AttributeError(f"Settings '{self._obj_entry}' has no attribute '{self._obj_attribute}'.")
return settings, getattr(settings, self._obj_attribute)
_settings_manager = SettingsManager()
def set_settings_manager(_setting_manager):
global _settings_manager
_settings_manager = _setting_manager
def get_settings_manager():
return _settings_manager

View File

@@ -3,15 +3,17 @@ import secrets
from datetime import datetime
from typing import Any
from .user_database import user_db
from .user_database import get_user_db
logger = logging.getLogger(__name__)
class UserDAO:
"""Data Access Object for user management."""
@staticmethod
def create_user(username: str, email: str, password: str | None = None, github_id: str | None = None) -> int:
def create_user(username: str, email: str, password: str | None = None, github_id: str | None = None,
db_instance=None) -> int:
"""
Create a new user with email/password or GitHub authentication.
@@ -20,10 +22,12 @@ class UserDAO:
email: The user's email
password: The user's password (optional)
github_id: GitHub user ID (optional)
db_instance: Database instance (optional, uses default if None)
Returns:
int: ID of the new user or 0 if creation failed
"""
user_db = db_instance or get_user_db()
try:
with user_db.get_connection() as conn:
cursor = conn.cursor()
@@ -48,28 +52,43 @@ class UserDAO:
password_hash = user_db._hash_password(password, salt)
cursor.execute('''
INSERT INTO users (username, email, password_hash, salt, github_id, is_admin)
VALUES (?, ?, ?, ?, ?, 0)
''', (username, email, password_hash, salt, github_id))
INSERT INTO users (username, email, password_hash, salt, github_id, is_admin)
VALUES (?, ?, ?, ?, ?, 0)
''', (username, email, password_hash, salt, github_id))
# Get the ID of the newly created user
user_id = cursor.lastrowid
# Add default roles to the new user
default_roles = ['repositories', 'datagrid', 'ai_buddy']
for role in default_roles:
cursor.execute('''
INSERT INTO roles (user_id, role_name)
VALUES (?, ?)
''', (user_id, role))
conn.commit()
return cursor.lastrowid
return user_id
except Exception as e:
logger.error(f"Error creating user: {e}")
return 0
@staticmethod
def authenticate_email(email: str, password: str) -> dict[str, Any] | None:
def authenticate_email(email: str, password: str, db_instance=None) -> dict[str, Any] | None:
"""
Authenticate a user with email and password.
Args:
email: The user's email
password: The user's password
db_instance: Database instance (optional, uses default if None)
Returns:
Dict or None: User record if authentication succeeds, None otherwise
"""
user_db = db_instance or get_user_db()
with user_db.get_connection() as conn:
cursor = conn.cursor()
@@ -100,19 +119,23 @@ class UserDAO:
# Return user info
return dict(user)
@staticmethod
def find_or_create_github_user(github_id: str, username: str, email: str | None) -> dict[str, Any] | None:
def find_or_create_github_user(github_id: str, username: str, email: str | None, db_instance=None) -> dict[
str, Any] | None:
"""
Find existing GitHub user or create a new one.
Args:
github_id: GitHub user ID
username: The username from GitHub
email: The email from GitHub (may be None)
db_instance: Database instance (optional, uses default if None)
Returns:
Dict or None: User record if found or created, None on error
"""
user_db = db_instance or get_user_db()
with user_db.get_connection() as conn:
cursor = conn.cursor()
@@ -138,9 +161,9 @@ class UserDAO:
try:
cursor.execute('''
INSERT INTO users (username, email, github_id, is_admin)
VALUES (?, ?, ?, 0)
''', (username, user_email, github_id))
INSERT INTO users (username, email, github_id, is_admin)
VALUES (?, ?, ?, 0)
''', (username, user_email, github_id))
user_id = cursor.lastrowid
conn.commit()
@@ -156,17 +179,20 @@ class UserDAO:
logger.error(f"Error creating GitHub user: {e}")
return None
@staticmethod
def get_user_by_id(user_id: int) -> dict[str, Any] | None:
def get_user_by_id(user_id: int, db_instance=None) -> dict[str, Any] | None:
"""
Get a user by ID.
Args:
user_id: The user ID
db_instance: Database instance (optional, uses default if None)
Returns:
Dict or None: User record if found, None otherwise
"""
user_db = db_instance or get_user_db()
with user_db.get_connection() as conn:
cursor = conn.cursor()
@@ -179,42 +205,80 @@ class UserDAO:
return dict(user) if user else None
@staticmethod
def get_all_users(limit: int = 100, offset: int = 0) -> list[dict[str, Any]]:
def get_user_roles_by_id(user_id: int, db_instance=None) -> list[str]:
"""
Retrieve the roles associated with a user.
Args:
user_id (int): User ID.
db_instance: Database instance (optional, uses default if None)
Returns:
list[str]: List of role names associated with the user.
"""
db_instance = db_instance or get_user_db()
with db_instance.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT role_name
FROM roles
WHERE user_id = ?
''', (user_id,))
# get the roles
roles = [row["role_name"] for row in cursor.fetchall()]
return roles
@staticmethod
def get_all_users(limit: int = 100, offset: int = 0, db_instance=None) -> list[dict[str, Any]]:
"""
Get all users with pagination.
Args:
limit: Maximum number of users to return
offset: Number of users to skip
db_instance: Database instance (optional, uses default if None)
Returns:
List of user records
"""
user_db = db_instance or get_user_db()
with user_db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT id, username, email, is_admin, created_at, last_login,
(github_id IS NOT NULL) as is_github_user
FROM users
ORDER BY created_at DESC
LIMIT ? OFFSET ?
''', (limit, offset))
SELECT id,
username,
email,
is_admin,
created_at,
last_login,
(github_id IS NOT NULL) as is_github_user
FROM users
ORDER BY created_at DESC
LIMIT ? OFFSET ?
''', (limit, offset))
return [dict(user) for user in cursor.fetchall()]
@staticmethod
def set_admin_status(user_id: int, is_admin: bool) -> bool:
def set_admin_status(user_id: int, is_admin: bool, db_instance=None) -> bool:
"""
Change a user's admin status.
Args:
user_id: The user ID
is_admin: True to make admin, False to remove admin status
db_instance: Database instance (optional, uses default if None)
Returns:
bool: True if successful, False otherwise
"""
user_db = db_instance or get_user_db()
try:
with user_db.get_connection() as conn:
cursor = conn.cursor()
@@ -230,17 +294,20 @@ class UserDAO:
logger.error(f"Error setting admin status: {e}")
return False
@staticmethod
def delete_user(user_id: int) -> bool:
def delete_user(user_id: int, db_instance=None) -> bool:
"""
Delete a user and all their data.
Args:
user_id: The user ID
db_instance: Database instance (optional, uses default if None)
Returns:
bool: True if successful, False otherwise
"""
user_db = db_instance or get_user_db()
try:
with user_db.get_connection() as conn:
cursor = conn.cursor()

View File

@@ -46,6 +46,17 @@ class Database:
''')
logger.info("Created users table")
# Create the roles table
cursor.execute('''
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
role_name TEXT NOT NULL,
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)
''')
logger.info("Created roles table")
# Check if we need to create an admin user
cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = 1")
@@ -111,5 +122,18 @@ class Database:
finally:
conn.close()
# Create a singleton instance
user_db = Database()
# Global instance for backward compatibility (will be modified in tests)
_default_instance = None
def get_user_db():
"""Get the default database instance."""
global _default_instance
if _default_instance is None:
_default_instance = Database()
return _default_instance
def set_user_db(instance):
"""Set a custom database instance (for testing)."""
global _default_instance
_default_instance = instance

View File

@@ -1,12 +1,16 @@
import ast
import base64
import cProfile
import functools
import hashlib
import importlib
import inspect
import pkgutil
import re
import time
import types
import uuid
from datetime import datetime
from enum import Enum
from io import BytesIO
from urllib.parse import urlparse
@@ -420,6 +424,66 @@ def split_host_port(url):
return host, port
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
end = time.perf_counter()
# get class name
class_name = None
if args:
# check the first argument to see if it's a class'
if inspect.isclass(args[0]):
class_name = args[0].__name__ # class method
elif hasattr(args[0], "__class__"):
class_name = args[0].__class__.__name__ # instance method
if class_name:
print(f"[PERF] {class_name}.{func.__name__} took {end - start:.4f} sec")
else:
print(f"[PERF] {func.__name__} took {end - start:.4f} sec")
return result
return wrapper
def profile_function(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
profiler = cProfile.Profile()
try:
profiler.enable()
result = func(*args, **kwargs)
finally:
profiler.disable()
# Determine class name if any
class_name = None
if args:
if inspect.isclass(args[0]):
class_name = args[0].__name__ # class method
elif hasattr(args[0], "__class__"):
class_name = args[0].__class__.__name__ # instance method
# Compose filename with timestamp
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
if class_name:
filename = f"{class_name}_{func.__name__}_{timestamp}.prof"
else:
filename = f"{func.__name__}_{timestamp}.prof"
# Dump stats to file
profiler.dump_stats(filename)
print(f"[PROFILE] Profiling data saved to {filename}")
return result
return wrapper
class UnreferencedNamesVisitor(ast.NodeVisitor):
"""
Try to find symbols that will be requested by the ast
@@ -463,5 +527,4 @@ class UnreferencedNamesVisitor(ast.NodeVisitor):
:rtype:
"""
self.names.add(node.arg)
self.visit_selected(node, ["value"])
self.visit_selected(node, ["value"])

View File

@@ -47,4 +47,9 @@ loggers:
AddStuffApp:
level: INFO
handlers: [ console ]
propagate: False
Jira:
level: DEBUG
handlers: [ console ]
propagate: False

View File

@@ -1,7 +1,7 @@
# global layout
import asyncio
import logging.config
import click
import yaml
from fasthtml.common import *
@@ -20,9 +20,10 @@ from components.register.constants import ROUTE_ROOT as REGISTER_ROUTE_ROOT
from components.register.constants import Routes as RegisterRoutes
from config import APP_PORT
from constants import Routes
from core.database_manager import initialize_database
from core.dbengine import DbException
from core.instance_manager import InstanceManager
from core.settings_management import SettingsManager
from core.settings_management import get_settings_manager
from pages.admin_import_settings import AdminImportSettings, IMPORT_SETTINGS_PATH, import_settings_app
from pages.another_grid import get_datagrid2
from pages.basic_test import BASIC_TEST_PATH, basic_test_app, get_basic_test
@@ -39,6 +40,9 @@ logging.config.dictConfig(config)
logger = logging.getLogger("MainApp")
# Initialize database
initialize_database()
# daisy_ui_links_v4 = (
# Link(href="https://cdn.jsdelivr.net/npm/daisyui@latest/dist/full.min.css", rel="stylesheet", type="text/css"),
# Script(src="https://cdn.tailwindcss.com"),
@@ -145,7 +149,10 @@ register_component("login", "components.login", "LoginApp")
register_component("register", "components.register", "RegisterApp")
register_component("theme_controller", "components.themecontroller", "ThemeControllerApp")
register_component("main_layout", "components.drawerlayout", "DrawerLayoutApp")
register_component("undo_redo", "components.undo_redo", "UndoRedoApp")
register_component("tabs", "components.tabs", "TabsApp") # before repositories
register_component("entryselector", "components.entryselector", "EntrySelectorApp")
register_component("jsonviewer", "components.jsonviewer", "JsonViewerApp")
register_component("applications", "components.applications", "ApplicationsApp")
register_component("repositories", "components.repositories", "RepositoriesApp")
register_component("workflows", "components.workflows", "WorkflowsApp")
@@ -210,9 +217,26 @@ app, rt = fast_app(
pico=False,
)
settings_manager = SettingsManager()
import_settings = AdminImportSettings(settings_manager, None)
# -------------------------
# Profiling middleware
# -------------------------
# @app.middleware("http")
async def timing_middleware(request, call_next):
import time
start_total = time.perf_counter()
# Call the next middleware or route handler
response = await call_next(request)
end_total = time.perf_counter()
elapsed = end_total - start_total
print(f"[PERF] Total server time: {elapsed:.4f} sec - Path: {request.url.path}")
return response
import_settings = AdminImportSettings(get_settings_manager(), None)
pages = [
Page("My table", get_datagrid, id="my_table"),
Page("new settings", import_settings, id="import_settings"),
@@ -221,8 +245,8 @@ pages = [
Page("Another Table", get_datagrid2, id="another_table"),
]
login = Login(settings_manager)
register = Register(settings_manager)
login = Login(get_settings_manager())
register = Register(get_settings_manager())
InstanceManager.register_many(login, register)
@@ -232,8 +256,8 @@ def get(session):
main = InstanceManager.get(session,
DrawerLayout.create_component_id(session),
DrawerLayout,
settings_manager=settings_manager)
return page_layout_lite(session, settings_manager, main)
settings_manager=get_settings_manager())
return page_layout_lite(session, get_settings_manager(), main)
except DbException:
return RedirectResponse(LOGIN_ROUTE_ROOT + LoginRoutes.Logout, status_code=303)
@@ -252,6 +276,17 @@ def get(session):
DrawerLayoutOld(pages),)
@rt('/toasting')
def get(session):
# Normally one toast is enough, this allows us to see
# different toast types in action.
add_toast(session, f"Toast is being cooked", "info")
add_toast(session, f"Toast is ready", "success")
add_toast(session, f"Toast is getting a bit crispy", "warning")
add_toast(session, f"Toast is burning!", "error")
return Titled("I like toast")
# Error Handling
@app.get("/{path:path}")
def not_found(path: str, session=None):
@@ -266,7 +301,7 @@ def not_found(path: str, session=None):
return page_layout_new(
session=session,
settings_manager=settings_manager,
settings_manager=get_settings_manager(),
content=error_content
)
@@ -274,28 +309,14 @@ def not_found(path: str, session=None):
setup_toasts(app)
@rt('/toasting')
def get(session):
# Normally one toast is enough, this allows us to see
# different toast types in action.
add_toast(session, f"Toast is being cooked", "info")
add_toast(session, f"Toast is ready", "success")
add_toast(session, f"Toast is getting a bit crispy", "warning")
add_toast(session, f"Toast is burning!", "error")
return Titled("I like toast")
async def main():
logger.info(f" Starting FastHTML server on http://localhost:{APP_PORT}")
serve(port=APP_PORT)
@click.command()
@click.option('--port', default=APP_PORT, help='Port to run the server on')
def main(port):
logger.info(f" Starting FastHTML server on http://localhost:{port}")
serve(port=port)
if __name__ == "__main__":
# Start your application
logger.info("Application starting...")
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info("\nStopping application...")
except Exception as e:
logger.error(f"Error: {e}")
main()

View File

@@ -1,4 +1,5 @@
from components.repositories.components.Repositories import Repositories
from components.undo_redo.components.UndoRedo import UndoRedo
from core.instance_manager import InstanceManager
@@ -6,4 +7,8 @@ class ComponentsInstancesHelper:
@staticmethod
def get_repositories(session):
return InstanceManager.get(session, Repositories.create_component_id(session))
@staticmethod
def get_undo_redo(session):
return InstanceManager.get(session, UndoRedo.create_component_id(session))

View File

@@ -0,0 +1,105 @@
from typing import Any
from core.Expando import Expando
from workflow.engine import DataPresenter
class DefaultDataPresenter(DataPresenter):
"""Default data presenter that returns the input data unchanged."""
def __init__(self, component_id: str, mappings_definition: str):
super().__init__(component_id)
self._mappings_definition = mappings_definition
self._split_definitions = [definition.strip() for definition in mappings_definition.split(",")]
if "*" not in mappings_definition:
self._static_mappings = self._get_static_mappings()
else:
self._static_mappings = None
def present(self, data: Any) -> Any:
self._validate_mappings_definition()
if self._static_mappings:
return Expando(data.to_dict(self._static_mappings))
dynamic_mappings = self._get_dynamic_mappings(data)
return Expando(data.to_dict(dynamic_mappings))
def _get_dynamic_mappings(self, data):
manage_conflicts = {}
mappings = {}
for mapping in self._split_definitions:
if "=" in mapping:
key, value = [s.strip() for s in mapping.split('=', 1)]
if key == "*":
# all fields
if value != "*":
raise ValueError("Only '*' is accepted when renaming wildcard.")
for key in data.as_dict().keys():
if key in manage_conflicts:
raise ValueError(f"Collision detected for field '{key}'. It is mapped from both '{manage_conflicts[key]}' and '{mapping}'.")
manage_conflicts[key] = mapping
mappings[key] = key
elif key.endswith(".*"):
# all fields in a sub-object
if value != "*" and value != "":
raise ValueError("Only '*' is accepted when renaming wildcard.")
obj_path = key[:-2]
sub_obj = data.get(obj_path)
if isinstance(sub_obj, dict):
for sub_field in sub_obj:
if sub_field in manage_conflicts:
raise ValueError(
f"Collision detected for field '{sub_field}'. It is mapped from both '{manage_conflicts[sub_field]}' and '{mapping}'.")
manage_conflicts[sub_field] = mapping
mappings[f"{obj_path}.{sub_field}"] = sub_field
else:
raise ValueError(f"Field '{obj_path}' is not an object.")
else:
mappings[key.strip()] = value.strip()
else:
if mapping == "*":
# all fields
for key in data.as_dict().keys():
mappings[key] = key
elif mapping.endswith(".*"):
# all fields in a sub-object
obj_path = mapping[:-2]
sub_obj = data.get(obj_path)
if isinstance(sub_obj, dict):
for sub_field in sub_obj:
mappings[f"{obj_path}.{sub_field}"] = f"{obj_path}.{sub_field}"
else:
raise ValueError(f"Field '{obj_path}' is not an object.")
else:
mappings[mapping] = mapping
return mappings
def _get_static_mappings(self):
mappings = {}
for mapping in self._split_definitions:
if "=" in mapping:
key, value = [s.strip() for s in mapping.split('=', 1)]
if not value:
value = key.split(".")[-1]
mappings[key] = value
else:
mappings[mapping] = mapping
return mappings
def _validate_mappings_definition(self):
last_char_was_comma = False
for i, char in enumerate(self._mappings_definition):
if char == ',':
if last_char_was_comma:
raise ValueError(f"Invalid mappings definition: Error found at index {i}")
last_char_was_comma = True
elif not char.isspace():
last_char_was_comma = False

View File

@@ -1,15 +1,37 @@
import ast
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Generator
from components.admin.admin_db_manager import AdminDbManager
from core.Expando import Expando
from core.jira import Jira, JiraRequestTypes
from core.preprocessor import PlainTextPreprocessor
from core.utils import UnreferencedNamesVisitor
from utils.Datahelper import DataHelper
@dataclass
class WorkflowPayload:
processor_name: str
component_id: str
item_linkage_id: int
item: Any
class DataProcessorError(Exception):
def __init__(self, component_id, error):
self.component_id = component_id
self.error = error
class DataProcessor(ABC):
"""Base class for all data processing components."""
def __init__(self, component_id: str = None):
self.component_id = component_id
@abstractmethod
def process(self, data: Any) -> Generator[Any, None, None]:
pass
@@ -24,7 +46,11 @@ class DataProducer(DataProcessor):
pass
def process(self, data: Any) -> Generator[Any, None, None]:
yield from self.emit(data)
try:
yield from self.emit(data)
except Exception as e:
raise DataProcessorError(self.component_id, e)
class DataFilter(DataProcessor):
@@ -36,8 +62,12 @@ class DataFilter(DataProcessor):
pass
def process(self, data: Any) -> Generator[Any, None, None]:
if self.filter(data):
yield data
try:
if self.filter(data):
yield data
except Exception as e:
raise DataProcessorError(self.component_id, e)
class DataPresenter(DataProcessor):
@@ -49,13 +79,18 @@ class DataPresenter(DataProcessor):
pass
def process(self, data: Any) -> Generator[Any, None, None]:
yield self.present(data)
try:
yield self.present(data)
except Exception as e:
raise DataProcessorError(self.component_id, e)
class TableDataProducer(DataProducer):
"""Base class for data producers that emit data from a repository."""
def __init__(self, session, settings_manager, repository_name, table_name):
def __init__(self, session, settings_manager, component_id, repository_name, table_name):
super().__init__(component_id)
self._session = session
self.settings_manager = settings_manager
self.repository_name = repository_name
@@ -65,35 +100,40 @@ class TableDataProducer(DataProducer):
yield from DataHelper.get(self._session, self.settings_manager, self.repository_name, self.table_name, Expando)
class DefaultDataPresenter(DataPresenter):
"""Default data presenter that returns the input data unchanged."""
class JiraDataProducer(DataProducer):
"""Base class for data producers that emit data from Jira."""
def __init__(self, columns_as_str: str):
super().__init__()
if not columns_as_str or columns_as_str == "*":
self.mappings = None
else:
self.mappings = {}
temp_mappings = [col.strip() for col in columns_as_str.split(",")]
for mapping in temp_mappings:
if "=" in mapping:
key, value = mapping.split("=")
self.mappings[key] = value
else:
self.mappings[mapping] = mapping
logger = logging.getLogger("DataProcessor.Producer.Jira")
def present(self, data: Any) -> Any:
if self.mappings is None:
return data
def __init__(self, session, settings_manager, component_id, request_type='search', request='', fields=None):
super().__init__(component_id)
self._session = session
self.settings_manager = settings_manager
self.request_type = request_type.value if isinstance(request_type, JiraRequestTypes) else request_type
self.request = request
self.fields = fields
self.db = AdminDbManager(session, settings_manager).jira
def emit(self, data: Any = None) -> Generator[Any, None, None]:
self.logger.debug(f"Emitting data from Jira: {self.request_type} {self.request} {self.fields}")
return Expando(data.to_dict(self.mappings))
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):
raise ValueError(f"Invalid request type: {self.request_type}")
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):
def __init__(self, filter_expression: str):
super().__init__()
def __init__(self, component_id: str, filter_expression: str):
super().__init__(component_id)
self.filter_expression = filter_expression
self._ast_tree = ast.parse(filter_expression, "<user input>", 'eval')
self._compiled = compile(self._ast_tree, "<string>", "eval")
@@ -112,44 +152,89 @@ class WorkflowEngine:
def __init__(self):
self.processors: list[DataProcessor] = []
self.has_error = False
self.global_error = None
self.errors = {}
self.debug = {}
self.nb_items = -1
def add_processor(self, processor: DataProcessor) -> 'WorkflowEngine':
"""Add a data processor to the pipeline."""
self.processors.append(processor)
return self
def _process_single_item(self, item: Any, processor_index: int = 0) -> Generator[Any, None, None]:
def _process_single_item(self, item_linkage_id, item: Any, processor_index: int = 0) -> Generator[Any, None, None]:
"""Process a single item through the remaining processors."""
if processor_index >= len(self.processors):
yield item
return
processor = self.processors[processor_index]
if not processor.component_id in self.debug:
self.debug[processor.component_id] = {"input": [], "output": []}
self.debug[processor.component_id]["input"].append(WorkflowPayload(
processor_name=processor.__class__.__name__,
component_id=processor.component_id,
item_linkage_id=item_linkage_id,
item=item))
# Process the item through the current processor
for processed_item in processor.process(item):
self.debug[processor.component_id]["output"].append(WorkflowPayload(
processor_name=processor.__class__.__name__,
component_id=processor.component_id,
item_linkage_id=item_linkage_id,
item=processed_item))
# Recursively process through remaining processors
yield from self._process_single_item(processed_item, processor_index + 1)
yield from self._process_single_item(item_linkage_id, processed_item, processor_index + 1)
def run(self) -> Generator[Any, None, None]:
"""
Run the workflow pipeline and yield results one by one.
The first processor must be a DataProducer.
"""
if not self.processors:
raise ValueError("No processors in the pipeline")
self.debug.clear()
if not self.processors:
self.has_error = False
self.global_error = "No processors in the pipeline"
self.nb_items = -1
raise ValueError(self.global_error)
self.nb_items = 0
first_processor = self.processors[0]
if not isinstance(first_processor, DataProducer):
raise ValueError("First processor must be a DataProducer")
self.has_error = False
self.global_error = "First processor must be a DataProducer"
raise ValueError(self.global_error)
for item in first_processor.emit():
yield from self._process_single_item(item, 1)
self.debug[first_processor.component_id] = {"input": [], "output": []}
for item_linkage_id, item in enumerate(first_processor.process(None)):
self.nb_items += 1
self.debug[first_processor.component_id]["output"].append(WorkflowPayload(
processor_name=first_processor.__class__.__name__,
component_id=first_processor.component_id,
item_linkage_id=item_linkage_id,
item=item))
yield from self._process_single_item(item_linkage_id, item, 1)
def run_to_list(self) -> list[Any]:
"""
Run the workflow and return all results as a list.
Use this method when you need all results at once.
"""
return list(self.run())
try:
return list(self.run())
except DataProcessorError as err:
self.has_error = True
self.errors[err.component_id] = err.error
return []
except Exception as err:
self.has_error = True
self.global_error = str(err)
return []

Some files were not shown because too many files have changed in this diff Show More