Added Jira connectivity testing. Added alert management in AdminForm

This commit is contained in:
Kodjo Sossouvi
2025-07-22 18:23:01 +02:00
parent e793aeda95
commit 0d7b94a045
8 changed files with 154 additions and 51 deletions

View File

@@ -66,4 +66,10 @@ def post(session, _id: str, args: dict):
def post(session, _id: str): def post(session, _id: str):
logger.debug(f"Entering {Routes.ConfigureJiraCancel} with args {debug_session(session)}, {_id=}") logger.debug(f"Entering {Routes.ConfigureJiraCancel} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id) instance = InstanceManager.get(session, _id)
return instance.cancel_jira_settings() 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

@@ -1,10 +1,31 @@
from fastcore.basics import NotStr from fastcore.basics import NotStr
icon_jira = NotStr("""<svg name="jara" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"> icon_jira = NotStr("""<svg name="jira" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<defs> <defs>
<style>.a{fill:none;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;}</style> <style>.a{fill:none;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;}</style>
</defs> </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="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="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"/> <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>""") </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

@@ -63,6 +63,14 @@ class AdminCommandManager(BaseCommandManager):
"hx-swap": "outerHTML", "hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}"}}', "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): class ImportHolidaysCommandManager(BaseCommandManager):
def __init__(self, owner): def __init__(self, owner):

View File

@@ -6,7 +6,7 @@ from components.BaseComponent import BaseComponent
from components.admin.admin_db_manager import AdminDbManager from components.admin.admin_db_manager import AdminDbManager
from components.admin.assets.icons import icon_jira from components.admin.assets.icons import icon_jira
from components.admin.commands import AdminCommandManager 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.components.ImportHolidays import ImportHolidays
from components.admin.constants import ADMIN_INSTANCE_ID, ADMIN_AI_BUDDY_INSTANCE_ID, ADMIN_JIRA_INSTANCE_ID from components.admin.constants import ADMIN_INSTANCE_ID, ADMIN_AI_BUDDY_INSTANCE_ID, ADMIN_JIRA_INSTANCE_ID
from components.aibuddy.assets.icons import icon_brain_ok from components.aibuddy.assets.icons import icon_brain_ok
@@ -14,6 +14,7 @@ from components.hoildays.assets.icons import icon_holidays
from components.tabs.components.MyTabs import MyTabs from components.tabs.components.MyTabs import MyTabs
from components_helpers import mk_ellipsis, mk_icon from components_helpers import mk_ellipsis, mk_icon
from core.instance_manager import InstanceManager from core.instance_manager import InstanceManager
from core.jira import Jira
class Admin(BaseComponent): class Admin(BaseComponent):
@@ -36,7 +37,7 @@ class Admin(BaseComponent):
hooks = { hooks = {
"on_ok": self.commands.save_ai_buddy(), "on_ok": self.commands.save_ai_buddy(),
"on_cancel": self.commands.cancel_ai_buddy(), "on_cancel": self.commands.cancel_ai_buddy(),
"ok_title": "Apply" "ok_title": "Apply",
} }
form = InstanceManager.get(self._session, form = InstanceManager.get(self._session,
AdminForm.create_component_id(self._session, prefix=self._id), AdminForm.create_component_id(self._session, prefix=self._id),
@@ -68,7 +69,8 @@ class Admin(BaseComponent):
hooks = { hooks = {
"on_ok": self.commands.save_configure_jira(), "on_ok": self.commands.save_configure_jira(),
"on_cancel": self.commands.cancel_configure_jira(), "on_cancel": self.commands.cancel_configure_jira(),
"ok_title": "Apply" "ok_title": "Apply",
"extra_buttons": [AdminButton("Test", self.commands.test_jira)]
} }
form = InstanceManager.get(self._session, form = InstanceManager.get(self._session,
@@ -85,7 +87,7 @@ class Admin(BaseComponent):
return self._add_tab(ADMIN_JIRA_INSTANCE_ID, "Admin - Jira Configuration", form) return self._add_tab(ADMIN_JIRA_INSTANCE_ID, "Admin - Jira Configuration", form)
def update_ai_buddy_settings(self, values: dict): 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) self.db.ai_buddy.update(values, ignore_missing=True)
return self.tabs_manager.render() return self.tabs_manager.render()
@@ -95,15 +97,25 @@ class Admin(BaseComponent):
return self.tabs_manager.render() return self.tabs_manager.render()
def update_jira_settings(self, values: dict): def update_jira_settings(self, values: dict):
values = self.manage_lists(values) values = AdminForm.get_fields_values(values)
self.db.jira.update(values, ignore_missing=True) self.db.jira.update(values, ignore_missing=True)
return self.tabs_manager.render() return self.tabs_manager.render()
def cancel_jira_settings(self): def cancel_jira_settings(self):
tab_id = self.tabs_manager.get_tab_id(ADMIN_JIRA_INSTANCE_ID) tab_id = self.tabs_manager.get_tab_id(ADMIN_JIRA_INSTANCE_ID)
self.tabs_manager.remove_tab(tab_id) self.tabs_manager.remove_tab(tab_id)
return self.tabs_manager.render() 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 __ft__(self): def __ft__(self):
return Div( return Div(
@@ -138,40 +150,3 @@ class Admin(BaseComponent):
@staticmethod @staticmethod
def create_component_id(session): def create_component_id(session):
return f"{ADMIN_INSTANCE_ID}{session['user_id']}" 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 dataclasses import dataclass
from typing import Any from typing import Any, Callable
from fasthtml.components import * from fasthtml.components import *
from assets.icons import icon_error
from components.BaseComponent import BaseComponent 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 from core.utils import get_unique_id
@@ -18,6 +20,14 @@ class AdminFormType:
TEXTAREA = "textarea" TEXTAREA = "textarea"
class AdminMessageType:
NONE = "none"
SUCCESS = "success"
ERROR = "error"
INFO = "info"
WARNING = "warning"
@dataclass @dataclass
class AdminFormItem: class AdminFormItem:
name: str name: str
@@ -27,6 +37,12 @@ class AdminFormItem:
possible_values: list[str] = None possible_values: list[str] = None
@dataclass
class AdminButton:
title: str
on_click: Callable = None
class AdminForm(BaseComponent): class AdminForm(BaseComponent):
def __init__(self, session, _id, owner, title: str, obj: Any, form_fields: list[AdminFormItem], hooks=None, key=None, def __init__(self, session, _id, owner, title: str, obj: Any, form_fields: list[AdminFormItem], hooks=None, key=None,
boundaries=None): boundaries=None):
@@ -38,6 +54,21 @@ class AdminForm(BaseComponent):
self.title = title self.title = title
self.obj = obj self.obj = obj
self.form_fields = form_fields 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): def mk_input(self, item: AdminFormItem):
return Input( return Input(
@@ -62,7 +93,7 @@ class AdminForm(BaseComponent):
cls="checkbox checkbox-xs", cls="checkbox checkbox-xs",
checked=value in current_values checked=value in current_values
), ),
cls="checkbox-item") for value in item.possible_values] cls="checkbox-item") for value in item.possible_values]
return Div(*checkbox_items, cls="adm-items-group") return Div(*checkbox_items, cls="adm-items-group")
@@ -95,9 +126,20 @@ class AdminForm(BaseComponent):
else: else:
return self.mk_input(item) 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): def __ft__(self):
return Form( return Form(
Fieldset(Legend(self.title, cls="fieldset-legend"), Fieldset(Legend(self.title, cls="fieldset-legend"),
Div(self.message),
*[ *[
Div( Div(
Label(item.title, cls="label"), Label(item.title, cls="label"),
@@ -107,6 +149,7 @@ class AdminForm(BaseComponent):
for item in self.form_fields for item in self.form_fields
], ],
self.mk_extra_buttons(),
mk_dialog_buttons(**safe_get_dialog_buttons_parameters(self._hooks)), mk_dialog_buttons(**safe_get_dialog_buttons_parameters(self._hooks)),
**apply_boundaries(self._boundaries), **apply_boundaries(self._boundaries),
cls="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4" 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() suffix = get_unique_id()
return f"{prefix}{suffix}" 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

@@ -12,3 +12,4 @@ class Routes:
PasteHolidays = "/paste-holidays" PasteHolidays = "/paste-holidays"
ConfigureJira = "/configure-jira" ConfigureJira = "/configure-jira"
ConfigureJiraCancel = "/configure-jira-cancel" ConfigureJiraCancel = "/configure-jira-cancel"
ConfigureJiraTest = "/configure-jira-test"

View File

@@ -35,7 +35,7 @@ def post(session, _id: str, name: str, tab_boundaries: str):
@rt(Routes.AddComponent) @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( logger.debug(
f"Entering {Routes.AddComponent} with args {debug_session(session)}, {_id=}, {component_type=}, {x=}, {y=}") f"Entering {Routes.AddComponent} with args {debug_session(session)}, {_id=}, {component_type=}, {x=}, {y=}")
instance = InstanceManager.get(session, _id) instance = InstanceManager.get(session, _id)

View File

@@ -39,6 +39,18 @@ class Jira:
self.api_token = api_token self.api_token = api_token
self.auth = HTTPBasicAuth(self.user_name, self.api_token) self.auth = HTTPBasicAuth(self.user_name, self.api_token)
def test(self):
url = f"{JIRA_ROOT}/myself"
response = requests.request(
"GET",
url,
headers=DEFAULT_HEADERS,
auth=self.auth
)
return response
def issue(self, issue_id: str) -> Expando: def issue(self, issue_id: str) -> Expando:
""" """
Retrieve an issue Retrieve an issue