Added Application HolidayViewer

This commit is contained in:
Kodjo Sossouvi
2025-06-27 07:26:58 +02:00
parent 66ea45f501
commit 9f4b8ab4d0
87 changed files with 3756 additions and 212 deletions

View File

@@ -1,3 +1,6 @@
from core.utils import get_user_id
class BaseComponent:
"""
Base class for all components that need to have a session and an id
@@ -13,6 +16,9 @@ class BaseComponent:
def get_session(self):
return self._session
def get_user_id(self):
return get_user_id(self._session)
def __repr__(self):
return self._id

View File

@@ -0,0 +1,51 @@
import json
import logging
from fasthtml.fastapp import fast_app
from components.admin.constants import Routes
from core.instance_manager import debug_session, InstanceManager
admin_app, rt = fast_app()
logger = logging.getLogger("admin")
@rt(Routes.AiBuddy)
def get(session, _id: str, boundaries: str):
logger.debug(f"Entering {Routes.AiBuddy} - GET with args {debug_session(session)}, {_id=}, {boundaries=}")
instance = InstanceManager.get(session, _id)
return instance.show_ai_buddy_form(json.loads(boundaries) if boundaries else None)
@rt(Routes.AiBuddy)
def post(session, _id: str, args: dict):
logger.debug(f"Entering {Routes.AiBuddy} - POST with args {debug_session(session)}, {_id=}, {args=}")
instance = InstanceManager.get(session, _id)
return instance.update_ai_buddy_settings(args)
@rt(Routes.AiBuddyCancel)
def post(session, _id: str):
logger.debug(f"Entering {Routes.AiBuddyCancel} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.cancel_ai_buddy_settings()
@rt(Routes.ImportHolidays)
def get(session, _id: str, boundaries: str):
logger.debug(f"Entering {Routes.ImportHolidays} - GET with args {debug_session(session)}, {_id=}, {boundaries=}")
instance = InstanceManager.get(session, _id)
return instance.show_import_holidays_form(json.loads(boundaries) if boundaries else None)
@rt(Routes.PasteHolidays)
def post(session, _id: str, content: str):
logger.debug(f"Entering {Routes.PasteHolidays} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.on_paste(content)
@rt(Routes.ImportHolidays)
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()

View File

View File

@@ -0,0 +1,39 @@
from dataclasses import dataclass, field
from ai.mcp_client import InProcessMCPClientCustomTools
from ai.mcp_tools import MCPServerTools
from auth.auth_manager import AuthManager
from components.admin.constants import ADMIN_SETTINGS_ENTRY
from config import OLLAMA_HOST
from core.settings_management import SettingsManager, NestedSettingsManager
from core.utils import split_host_port
@dataclass
class AiBuddySettingsEntry:
ollama_ip: str = ""
ollama_port: int = 11434
ollama_model: str = "mistral"
llm_mode: str = InProcessMCPClientCustomTools.ID
available_tools: list = field(default_factory=MCPServerTools.list_tools)
def __post_init__(self):
host, port = split_host_port(OLLAMA_HOST)
self.ollama_ip = host
self.ollama_port = port
@dataclass
class AdminSettings:
ai_buddy: AiBuddySettingsEntry = field(default_factory=AiBuddySettingsEntry)
class AdminDbManager:
def __init__(self, session: dict, settings_manager: SettingsManager):
self._session = session
self._settings_manager = settings_manager
self.ai_buddy = NestedSettingsManager(AuthManager.admin_session(),
settings_manager,
ADMIN_SETTINGS_ENTRY,
AdminSettings,
"ai_buddy")

View File

@@ -0,0 +1,8 @@
.adm-items-group {
border: 1px solid color-mix(in oklab, var(--color-base-content) 20%, #0000);
border-radius: .25rem;
padding: 4px;
background-color: var(--color-base-100);
align-items: center;
padding-inline: .75rem;
}

View File

View File

@@ -0,0 +1,62 @@
from components.BaseCommandManager import BaseCommandManager
from components.admin.constants import ROUTE_ROOT, Routes
class AdminCommandManager(BaseCommandManager):
def __init__(self, owner):
super().__init__(owner)
def show_ai_buddy(self):
return {
"hx-get": f"{ROUTE_ROOT}{Routes.AiBuddy}",
"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_ai_buddy(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.AiBuddy}",
"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_ai_buddy(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.AiBuddyCancel}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}"}}',
}
def show_import_holidays(self):
return {
"hx-get": f"{ROUTE_ROOT}{Routes.ImportHolidays}",
"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()}")}}',
}
class ImportHolidaysCommandManager(BaseCommandManager):
def __init__(self, owner):
super().__init__(owner)
def on_paste(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.PasteHolidays}",
"hx-target": f"#{self._owner.datagrid.get_id()}",
"hx_trigger": "keydown[ctrlKey && key=='Enter']",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}"}}',
}
def import_holidays(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.ImportHolidays}",
"hx-target": f"#{self._owner.datagrid.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}"}}',
}

View File

@@ -0,0 +1,136 @@
from fasthtml.components import *
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.commands import AdminCommandManager
from components.admin.components.AdminForm import AdminFormItem, AdminFormType, AdminForm
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.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
class Admin(BaseComponent):
def __init__(self, session, _id: str = None, settings_manager=None, tabs_manager: MyTabs = None):
super().__init__(session, _id)
self.settings_manager = settings_manager
self.tabs_manager = tabs_manager
self.commands = AdminCommandManager(self)
self.db = AdminDbManager(session, settings_manager)
def show_ai_buddy_form(self, boundaries):
fields = [
AdminFormItem('ollama_ip', "Ollama IP", "IP address of the Ollama server.", AdminFormType.TEXT),
AdminFormItem("ollama_port", "Ollama Port", "Port of the Ollama server.", AdminFormType.NUMBER),
AdminFormItem("ollama_model", "LLM Model", "Model to use for Ollama.", AdminFormType.SELECT, ["mistral"]),
AdminFormItem("llm_mode", "LLM Mode", "Type of LLM to use (Dev)", AdminFormType.SELECT, MPC_CLIENTS_IDS),
AdminFormItem("available_tools", "Available Tools", "Possible tools", AdminFormType.CHECKBOX,
MCPServerTools.list_tools()),
]
hooks = {
"on_ok": self.commands.save_ai_buddy(),
"on_cancel": self.commands.cancel_ai_buddy(),
"ok_title": "Apply"
}
form = InstanceManager.get(self._session,
AdminForm.create_component_id(self._session, prefix=self._id),
AdminForm,
owner=self,
title="AI Buddy Configuration Page",
obj=self.db.ai_buddy,
form_fields=fields,
hooks=hooks,
key=ADMIN_AI_BUDDY_INSTANCE_ID,
boundaries=boundaries)
return self._add_tab(ADMIN_AI_BUDDY_INSTANCE_ID, "Admin - AI Buddy", form)
def show_import_holidays_form(self, boundaries):
form = InstanceManager.get(self._session,
ImportHolidays.create_component_id(self._session, prefix=self._id),
ImportHolidays,
settings_manager=self.settings_manager,
boundaries=boundaries)
return self._add_tab(ADMIN_AI_BUDDY_INSTANCE_ID, "Admin - Import Holidays", form)
def update_ai_buddy_settings(self, values: dict):
values = self.manage_lists(values)
self.db.ai_buddy.update(values, ignore_missing=True)
return self.tabs_manager.render()
def cancel_ai_buddy_settings(self):
tab_id = self.tabs_manager.get_tab_id(ADMIN_AI_BUDDY_INSTANCE_ID)
self.tabs_manager.remove_tab(tab_id)
return self.tabs_manager.render()
def __ft__(self):
return Div(
Div(cls="divider"),
Div(mk_ellipsis("Admin", cls="text-sm font-medium mb-1 mr-3")),
#
Div(
mk_icon(icon_brain_ok, can_select=False),
mk_ellipsis("AI Buddy", cls="text-sm", **self.commands.show_ai_buddy()),
cls="flex p-0 min-h-0 truncate",
),
Div(
mk_icon(icon_holidays, can_select=False),
mk_ellipsis("holidays", cls="text-sm", **self.commands.show_import_holidays()),
cls="flex p-0 min-h-0 truncate",
),
#
# cls=""),
# Script(f"bindAdmin('{self._id}')"),
id=f"{self._id}"
)
def _add_tab(self, tab_key, title, content):
self.tabs_manager.add_tab(title, content, key=tab_key)
return self.tabs_manager.render()
@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

@@ -0,0 +1,121 @@
from dataclasses import dataclass
from typing import Any
from fasthtml.components import *
from components.BaseComponent import BaseComponent
from components_helpers import set_boundaries, mk_dialog_buttons, safe_get_dialog_buttons_parameters
from core.utils import get_unique_id
class AdminFormType:
TEXT = "text"
NUMBER = "number"
DATE = "date"
SELECT = "select"
CHECKBOX = "checkbox"
RADIO = "radio"
TEXTAREA = "textarea"
@dataclass
class AdminFormItem:
name: str
title: str
description: str
type: str
possible_values: list[str] = None
class AdminForm(BaseComponent):
def __init__(self, session, _id, owner, title: str, obj: Any, form_fields: list[AdminFormItem], hooks=None, key=None,
boundaries=None):
super().__init__(session, _id)
self._owner = owner
self._hooks = hooks
self._key = key
self._boundaries = boundaries
self.title = title
self.obj = obj
self.form_fields = form_fields
def mk_input(self, item: AdminFormItem):
return Input(
type=item.type,
value=getattr(self.obj, item.name),
id=item.name,
cls="input"
)
def mk_checkbox(self, item: AdminFormItem):
if not item.possible_values:
return Div("No options available")
current_values = getattr(self.obj, item.name, [])
checkbox_items = [Div(
Input(
Label(value),
type="checkbox",
value=value,
id=f"{item.name}_{value}",
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")
def mk_select(self, item: AdminFormItem):
if not item.possible_values:
return Div("No options available")
current_value = getattr(self.obj, item.name, "")
options = [
Option(
value,
value=value,
selected=value == current_value
) for value in item.possible_values
]
return Select(
*options,
id=item.name,
cls="select select-bordered w-full"
)
def mk_item(self, item: AdminFormItem):
if item.type == AdminFormType.CHECKBOX:
return self.mk_checkbox(item)
elif item.type == AdminFormType.SELECT:
return self.mk_select(item)
else:
return self.mk_input(item)
def __ft__(self):
return Form(
Fieldset(Legend(self.title, cls="fieldset-legend"),
*[
Div(
Label(item.title, cls="label"),
self.mk_item(item),
P(item.description, cls="label"),
)
for item in self.form_fields
],
mk_dialog_buttons(**safe_get_dialog_buttons_parameters(self._hooks)),
**set_boundaries(self._boundaries),
cls="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4"
)
)
@staticmethod
def create_component_id(session, prefix=None, suffix=None):
if suffix is None:
suffix = get_unique_id()
return f"{prefix}{suffix}"

View File

@@ -0,0 +1,59 @@
from fasthtml.components import *
from pandas import DataFrame
from components.BaseComponent import BaseComponent
from helpers.ComponentsInstancesHelper import ComponentsInstancesHelper
from components.admin.commands import ImportHolidaysCommandManager
from components.admin.constants import ADMIN_IMPORT_HOLIDAYS_INSTANCE_ID
from components.datagrid_new.components.DataGrid import DataGrid
from components.datagrid_new.settings import DataGridSettings
from components.hoildays.helpers.nibelisparser import NibelisParser
from components.repositories.constants import USERS_REPOSITORY_NAME, HOLIDAYS_TABLE_NAME
from components_helpers import mk_dialog_buttons, set_boundaries
from conftest import session
from core.instance_manager import InstanceManager
class ImportHolidays(BaseComponent):
def __init__(self, session, _id, settings_manager, boundaries=None):
super().__init__(session, _id)
self._settings_manager = settings_manager
self._boundaries = boundaries
self.commands = ImportHolidaysCommandManager(self)
self.datagrid = InstanceManager.get(session,
DataGrid.create_component_id(session),
DataGrid,
grid_settings=DataGridSettings(views_visible=False,
open_file_visible=False,
open_settings_visible=False))
def on_paste(self, content):
parser = NibelisParser(content)
holidays = parser.parse()
self.datagrid.init_from_dataframe(DataFrame(holidays))
return self.datagrid
def import_holidays(self):
repositories = ComponentsInstancesHelper.get_repositories(self._session)
key = repositories.db.ensure_exists(USERS_REPOSITORY_NAME, HOLIDAYS_TABLE_NAME)
datagrid = DataGrid(self._session, DataGrid.create_component_id(self._session), key, self._settings_manager)
datagrid.init_from_dataframe(self.datagrid.get_dataframe(), save_state=True)
return repositories.refresh()
def __ft__(self):
return Div(
Div("Import holidays...", cls="mb-2"),
Textarea(name="content",
cls="textarea",
placeholder="Paste the content.\nCtl+Enter when done.",
**self.commands.on_paste()),
Div(self.datagrid, cls="mt-2"),
mk_dialog_buttons(ok_title="Import", cls="mt-2", on_ok=self.commands.import_holidays()),
id=self._id,
cls="m-2",
**set_boundaries(self._boundaries, other=26),
)
@staticmethod
def create_component_id(session, prefix=None, suffix=None):
return f"{ADMIN_IMPORT_HOLIDAYS_INSTANCE_ID}{session['user_id']}"

View File

@@ -0,0 +1,11 @@
ADMIN_INSTANCE_ID = "__Admin__"
ADMIN_AI_BUDDY_INSTANCE_ID = "__AdminAIBuddy__"
ADMIN_IMPORT_HOLIDAYS_INSTANCE_ID = "__AdminImportHolidays__"
ROUTE_ROOT = "/admin"
ADMIN_SETTINGS_ENTRY = "Admin"
class Routes:
AiBuddy = "/ai-buddy"
AiBuddyCancel = "/ai-buddy-cancel"
ImportHolidays = "/import-holidays"
PasteHolidays = "/paste-holidays"

View File

View File

@@ -0,0 +1,39 @@
import logging
from fasthtml.fastapp import fast_app
from components.aibuddy.constants import Routes
from core.instance_manager import debug_session, InstanceManager
ai_buddy_app, rt = fast_app()
logger = logging.getLogger("AIBuddy")
@rt(Routes.Request)
def get(session, _id: str):
logger.debug(f"Entering {Routes.Request} - GET with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.show_request_form()
@rt(Routes.Request)
async def post(session, _id: str, q: str):
logger.debug(f"Entering {Routes.Request} - POST with args {debug_session(session)}, {_id=}, {q=}")
instance = InstanceManager.get(session, _id)
res = await instance.make_async_ai_request(q)
return res
@rt(Routes.ResetRequest)
def post(session, _id: str):
logger.debug(f"Entering {Routes.ResetRequest} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.reset_ai_request()
@rt(Routes.LlmStatus)
def get(session, _id: str):
logger.debug(f"Entering {Routes.LlmStatus} - GET with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.get_llm_status()

View File

@@ -0,0 +1,11 @@
# id
**Datagrid ids**:
using `AIBuddy(id=my_id)`
| Name | value |
|----------------------------|----------------|
| Status icon | `s_{self._id}` |
| Question (input) component | `q_{self._id}` |
| Answer component | `a_{self._id}` |

View File

View File

@@ -0,0 +1,52 @@
function bindAIBuddy(elementId) {
console.debug("bindAIBuddy on element " + elementId);
const aiBuddy = document.getElementById(elementId);
if (!aiBuddy) {
console.error(`AIBuddy with id "${elementId}" not found.`);
return;
}
let lastShiftPress = 0;
const doublePressDelay = 300;
const makeAIRequest = () => {
const targetID = "q_" + elementId;
htmx.ajax('GET', '/ai/request', {
target: `#${targetID}`,
headers: {"Content-Type": "application/x-www-form-urlencoded"},
swap: "outerHTML",
values: {
_id: elementId,
}
});
// Listen for the htmx:afterSwap event to focus on the input
document.addEventListener('htmx:afterSwap', function(event) {
if (event.target.id === targetID) {
event.target.focus();
}
// Remove this event listener after it's been triggered
document.removeEventListener('htmx:afterSwap', arguments.callee);
});
};
document.addEventListener('keydown', (event) => {
if (event.ctrlKey && event.key === 'k') {
event.preventDefault();
makeAIRequest();
}
if (event.key === 'Shift') {
const currentTime = new Date().getTime();
if (currentTime - lastShiftPress <= doublePressDelay) {
event.preventDefault();
makeAIRequest();
}
lastShiftPress = currentTime;
}
});
}

View File

@@ -0,0 +1,17 @@
from fastcore.basics import NotStr
# Fluent BrainCircuit20Regular
icon_brain_ok = NotStr("""<svg name="ai_ok" 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="M6.13 2.793A3.91 3.91 0 0 1 8.5 2a1.757 1.757 0 0 1 1.5.78A1.757 1.757 0 0 1 11.5 2a3.91 3.91 0 0 1 2.37.793c.525.408.93.973 1.073 1.656c.328.025.628.161.88.366c.382.31.66.775.835 1.267c.274.765.348 1.74.064 2.57c.072.034.143.074.212.12c.275.183.484.445.638.754c.303.605.428 1.449.428 2.474c0 1.141-.435 1.907-.987 2.38a2.68 2.68 0 0 1-1.054.555c-.1.558-.38 1.204-.819 1.752C14.57 17.402 13.686 18 12.5 18c-.94 0-1.688-.52-2.174-1.03a4.252 4.252 0 0 1-.326-.385a4.245 4.245 0 0 1-.326.385C9.188 17.48 8.441 18 7.5 18c-1.186 0-2.069-.598-2.64-1.313a4.057 4.057 0 0 1-.819-1.752a2.68 2.68 0 0 1-1.054-.555C2.435 13.907 2 13.14 2 12c0-1.025.126-1.87.428-2.474c.154-.309.363-.57.638-.755a1.58 1.58 0 0 1 .212-.118c-.284-.832-.21-1.806.064-2.571c.175-.492.453-.957.835-1.267c.252-.205.552-.34.88-.366c.144-.683.549-1.248 1.074-1.656zM9.5 4.5V4.49l-.002-.05a2.744 2.744 0 0 0-.154-.764a1.222 1.222 0 0 0-.309-.49A.76.76 0 0 0 8.5 3a2.91 2.91 0 0 0-1.756.582C6.28 3.943 6 4.432 6 5a.5.5 0 0 1-.658.474c-.188-.062-.356-.027-.535.117c-.196.16-.387.444-.524.827c-.279.782-.25 1.729.133 2.305A.5.5 0 0 1 4.5 9h.75a2.25 2.25 0 0 1 2.25 2.25v.335a1.5 1.5 0 1 1-1 0v-.335c0-.69-.56-1.25-1.25-1.25H3.5a.499.499 0 0 1-.175-.032l-.003.006C3.124 10.369 3 11.025 3 12c0 .859.315 1.343.638 1.62c.347.298.732.38.862.38a.5.5 0 0 1 .5.5c0 .368.2 1.011.64 1.563c.429.535 1.046.937 1.86.937c.56 0 1.062-.313 1.45-.72c.191-.2.34-.407.437-.577a1.573 1.573 0 0 0 .113-.236V7.5H8.415a1.5 1.5 0 1 1 0-1H9.5v-2zm1 9.999v.967a1.575 1.575 0 0 0 .113.236c.098.17.246.377.436.577c.389.407.892.72 1.451.72c.814 0 1.431-.402 1.86-.937c.44-.552.64-1.195.64-1.563a.5.5 0 0 1 .5-.5c.13 0 .515-.082.862-.38c.323-.277.638-.761.638-1.62c0-.975-.125-1.63-.322-2.026a.923.923 0 0 0-.3-.37A.657.657 0 0 0 16 9.5a.5.5 0 0 1-.416-.777c.384-.576.412-1.523.133-2.305c-.137-.383-.328-.668-.524-.827c-.179-.144-.347-.18-.535-.117A.5.5 0 0 1 14 5c0-.568-.28-1.057-.745-1.418A2.91 2.91 0 0 0 11.5 3a.76.76 0 0 0-.535.186a1.22 1.22 0 0 0-.31.49a2.579 2.579 0 0 0-.155.814v9.01h.75c.69 0 1.25-.56 1.25-1.25v-1.835a1.5 1.5 0 1 1 1 0v1.835a2.25 2.25 0 0 1-2.25 2.25h-.75zM6.5 7a.5.5 0 1 0 1 0a.5.5 0 0 0-1 0zM13 9.5a.5.5 0 1 0 0-1a.5.5 0 0 0 0 1zm-6 3a.5.5 0 1 0 0 1a.5.5 0 0 0 0-1z" fill="currentColor">
</path>
</g>
</svg>""")
# Fluent Warning20Regular
icon_brain_warning = NotStr("""<svg name="ai_nok" 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 7a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-1 0v-4A.5.5 0 0 1 10 7zm0 7.5a.75.75 0 1 0 0-1.5a.75.75 0 0 0 0 1.5zM8.686 2.852a1.5 1.5 0 0 1 2.628 0l6.56 11.925A1.5 1.5 0 0 1 16.558 17H3.44a1.5 1.5 0 0 1-1.314-2.223L8.686 2.852zm1.752.482a.5.5 0 0 0-.876 0L3.003 15.26a.5.5 0 0 0 .438.741H16.56a.5.5 0 0 0 .438-.74L10.438 3.333z" fill="currentColor">
</path>
</g>
</svg>""")

View File

@@ -0,0 +1,32 @@
from components.BaseCommandManager import BaseCommandManager
from components.aibuddy.constants import ROUTE_ROOT, Routes
class AIBuddyCommandManager(BaseCommandManager):
def __init__(self, owner):
super().__init__(owner)
def make_request(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.Request}",
"hx-target": f"#a_{self._id}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}"}}',
}
def reset_request(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.ResetRequest}",
"hx-target": f"#q_{self._id}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}"}}',
}
def get_llm_status(self):
return {
"hx-get": f"{ROUTE_ROOT}{Routes.LlmStatus}",
"hx-target": f"#s_{self._id}",
"hx-trigger": "every 2s",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}"}}',
}

View File

@@ -0,0 +1,217 @@
import asyncio
import threading
from datetime import datetime
import httpx
from fasthtml.components import *
from fasthtml.xtend import Script
from ai.debug_lmm import DebugRequest, DebugRequestMetadata, DebugConversation
from ai.mcp_client import InProcessMCPClientCustomTools, InProcessMCPClientNativeTools
from assets.icons import icon_dismiss_regular
from components.BaseComponent import BaseComponent
from components.aibuddy.assets.icons import icon_brain_ok, icon_brain_warning
from components.aibuddy.commands import AIBuddyCommandManager
from components.aibuddy.constants import *
from components.aibuddy.settings import AI_BUDDY_SETTINGS_ENTRY, AIBuddySettings
from components_helpers import mk_ellipsis, mk_icon, mk_tooltip
from config import OLLAMA_HOST
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)
self.settings_manager = settings_manager
self.db = GenericDbManager(session, settings_manager, AI_BUDDY_SETTINGS_ENTRY, AIBuddySettings)
self.tabs_manager = tabs_manager
self.commands = AIBuddyCommandManager(self)
self.llm_status = None
self.mcp_clients = {
InProcessMCPClientNativeTools.ID: InProcessMCPClientNativeTools(session, settings_manager, OLLAMA_HOST),
InProcessMCPClientCustomTools.ID: InProcessMCPClientCustomTools(session, settings_manager, OLLAMA_HOST)
}
# self.db.mcp_client_mod
# self.db.llm_use_tools
self.conversations: list[DebugConversation] = self.db.conversations
# Check LLM status once at initialization in a background thread
threading.Thread(target=self._initial_status_check, daemon=True).start()
def _initial_status_check(self):
"""Check LLM status once in a background thread"""
try:
# Run the async function in a new event loop
loop = asyncio.new_event_loop()
self.llm_status = loop.run_until_complete(self.async_check_llm_active())
loop.close()
except Exception as e:
self.llm_status = f"Error checking LLM status: {e}"
def get_conversations(self):
return self.conversations
def show_request_form(self):
return self.mk_input()
def register_components(self):
return [
(self.mk_input(), "TOP"),
(self.mk_response(), "BOTTOM"),
]
async def make_async_ai_request(self, request):
if len(self.conversations) == 0:
start = datetime.now()
conversation = DebugConversation(f"{self.get_user_id()}{start.timestamp()}",
int(start.timestamp()),
request)
self.conversations.append(conversation)
else:
conversation = self.conversations[-1]
debug = DebugRequest(request)
conversation.requests.append(debug)
mcp_client = self.mcp_clients[self.db.mcp_client_mod]
start = datetime.now() # datetime.now().strftime("%Y-%m-%d %H:%M:%S")
response = await mcp_client.generate_with_mcp_context(debug, request, self.db.llm_use_tools)
request_metadata = DebugRequestMetadata(f"{self.get_user_id()}{start.timestamp()}",
self.get_user_id(),
int(start.timestamp()),
int(datetime.now().timestamp()),
mcp_client.model,
self.db.mcp_client_mod,
self.db.llm_use_tools,
self._debug_available_tools(mcp_client.available_tools))
debug.metadata = request_metadata
self.db.conversations = self.conversations
return self.mk_response(response)
def reset_ai_request(self):
return self.mk_input(True), self.mk_response(None, True)
def get_llm_status(self):
return self.mk_status(), self.mk_input(True)
def mk_status(self):
# Non-blocking, simply returns UI based on the current status
if self.llm_status is None:
return Span(cls="loading loading-infinity loading-sm", id=f"s_{self._id}", **self.commands.get_llm_status())
elif self.llm_status is not True:
return mk_tooltip(mk_icon(icon_brain_warning), self.llm_status, id=f"s_{self._id}")
else:
return mk_icon(icon_brain_ok, id=f"s_{self._id}")
def mk_input(self, oob=False):
if self.llm_status is True:
return self._mk_input_ok(oob)
else:
return Div(id=f"q_{self._id}", hx_swap_oob="true" if oob else None)
def mk_response(self, response=None, oob=False):
if self.llm_status is True:
return self._mk_response_ok(response, oob=oob)
else:
return Div(id=f"a_{self._id}",
hx_swap_oob="true")
def _mk_input_ok(self, oob=False):
return Div(
Label(
mk_icon(icon_brain_ok),
Input(
name="q",
placeholder="Ask me anything...",
tabindex="1",
**self.commands.make_request()
),
cls="input",
),
mk_icon(icon_dismiss_regular, cls="ml-2", tooltip="Reset question", **self.commands.reset_request()),
cls="flex",
id=f"q_{self._id}",
hx_swap_oob="true" if oob else None,
)
def _mk_response_ok(self, response=None, oob=False):
return Div(response or "Your response will appear here.",
cls="w-full px-4 py-2 border border-gray-300 rounded-md",
id=f"a_{self._id}",
hx_swap_oob="true" if oob else None,
)
@staticmethod
def _debug_available_tools(available_tools):
return [
{"name": tool["name"],
"parameters": tool["parameters"],
"description": tool["description"]}
for name, tool in available_tools.items()
]
def __ft__(self):
return Div(
Div(cls="divider"),
Div(
mk_ellipsis("AI Buddy", cls="text-sm font-medium mb-1 mr-3"),
self.mk_status(),
cls="flex items-center"),
Script(f"bindAIBuddy('{self._id}')"),
id=f"{self._id}"
)
@staticmethod
def create_component_id(session):
return f"{AI_BUDDY_INSTANCE_ID}{session['user_id']}"
@staticmethod
async def async_check_llm_active():
"""Asynchronous LLM status check"""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.post(
f"{OLLAMA_HOST}/api/pull",
json={"name": "mistral"}
)
response.raise_for_status()
return True
except Exception as e:
return f"Error pulling Mistral model: {e}"
def query_mistral(self, prompt):
"""Send a query to the Mistral model via Ollama API"""
try:
with httpx.Client(timeout=30.0) as client:
response = client.post(
f"{OLLAMA_HOST}/api/generate",
json={
"model": "mistral",
"prompt": prompt,
"stream": False
}
)
response.raise_for_status()
return response.json()
except Exception as e:
return {"error": str(e), "response": f"Error querying model: {e}"}
async def async_query_mistral(self, prompt):
"""Asynchronous version of query_mistral"""
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{OLLAMA_HOST}/api/generate",
json={
"model": "mistral",
"prompt": prompt,
"stream": False
}
)
response.raise_for_status()
return response.json()
except Exception as e:
return {"error": str(e), "response": f"Error querying model: {e}"}

View File

@@ -0,0 +1,8 @@
AI_BUDDY_INSTANCE_ID = "__AIBuddy__"
ROUTE_ROOT = "/ai"
class Routes:
Request = "/request"
ResetRequest = "/reset-request"
LlmStatus = "/llm-status"

View File

@@ -0,0 +1,16 @@
import dataclasses
from ai.debug_lmm import DebugConversation
from ai.mcp_client import InProcessMCPClientCustomTools
from core.settings_objects import BaseSettingObj
AI_BUDDY_SETTINGS_ENTRY = "AIBuddy"
@dataclasses.dataclass
class AIBuddySettings(BaseSettingObj):
__ENTRY_NAME__ = AI_BUDDY_SETTINGS_ENTRY
mcp_client_mod: str = InProcessMCPClientCustomTools.ID
llm_use_tools: bool = True
conversations: list[DebugConversation] = dataclasses.field(default_factory=list)

View File

@@ -0,0 +1,16 @@
import logging
from fasthtml.fastapp import fast_app
from components.applications.constants import Routes
from core.instance_manager import InstanceManager
logger = logging.getLogger("Applications")
applications_app, rt = fast_app()
@rt(Routes.Open)
def post(session, _id: str, app: str, boundaries: str):
logger.debug(f"Entering post with args {_id=}, {app=}, {boundaries=}")
instance = InstanceManager.get(session, _id)
return instance.open_application(app, boundaries)

View File

View File

@@ -0,0 +1,16 @@
from components.BaseCommandManager import BaseCommandManager
from components.applications.constants import ROUTE_ROOT, Routes
class Commands(BaseCommandManager):
def __init__(self, owner):
super().__init__(owner)
def open_application(self, app_name: str):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.Open}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", "app": "{app_name}", boundaries: getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
}

View File

@@ -0,0 +1,47 @@
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.hoildays.assets.icons import icon_holidays
from components.hoildays.components.HolidaysViewer import HolidaysViewer
from components.hoildays.constants import HOLIDAYS_VIEWER_INSTANCE_ID
from components_helpers import mk_ellipsis, mk_icon
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)
self.tabs_manager = tabs_manager
self.settings_manager = settings_manager
self.commands = Commands(self)
def open_application(self, app_name, boundaries):
if app_name == "holidays":
holiday = InstanceManager.get(self._session,
HolidaysViewer.create_component_id(self._session),
HolidaysViewer,
settings_manager=self.settings_manager,
boundaries=boundaries,
)
self.tabs_manager.add_tab("holidays", holiday, key=HOLIDAYS_VIEWER_INSTANCE_ID)
return self.tabs_manager.render()
raise NotImplementedError(app_name)
def __ft__(self):
return Div(
Div(cls="divider"),
mk_ellipsis("Applications", cls="text-sm font-medium mb-1"),
Div(
mk_icon(icon_holidays, can_select=False), mk_ellipsis("Holidays"),
cls="p-0 min-h-0 flex truncate",
**self.commands.open_application("holidays")
),
)
@staticmethod
def create_component_id(session):
return f"{APPLICATION_INSTANCE_ID}{session['user_id']}"

View File

@@ -0,0 +1,6 @@
APPLICATION_INSTANCE_ID = "__Applications__"
ROUTE_ROOT = "/apps"
class Routes:
Open = "/open"

View File

@@ -125,13 +125,14 @@ def post(session, _id: str, key: str, arg: str = None):
@rt(Routes.OnClick)
def post(session, _id: str, cell_id: str = None, modifier: str = None, boundaries: str=None):
def post(session, _id: str, cell_id: str = None, modifier: str = None, boundaries: str = None):
logger.debug(f"Entering on_click with args {_id=}, {cell_id=}, {modifier=}, {boundaries=}")
instance = InstanceManager.get(session, _id)
return instance.manage_click(cell_id, modifier, json.loads(boundaries) if boundaries else None)
@rt(Routes.UpdateState)
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)
return instance.manage_state_changed(state, args)

View File

@@ -10,6 +10,7 @@ using `Datagrid(id=my_id)`
| filter all | `fa_{datagrid_id}` |
| file upload | `fu_{datagrid_id}` |
| footer menu | `fm_{datagrid_id}` |
| main | `m_{datagrid_id}` |
| sidebar | `sb_{datagrid_id}` |
| scroll bars | `scb_{datagrid_id}` |
| Settings columns | `scol_{datagrid_id}` |

View File

@@ -469,12 +469,14 @@ function getClickModifier(event) {
}
function validateOnClickRequest(datagridId, event) {
if (event.target.id !== datagridId) { // only try to cancel event sent by the datagrid
console.debug("validateOnClickRequest : event", event)
if (event.target.id !== "m_" + datagridId) { // only try to cancel event sent by the datagrid
return;
}
const triggeringElt = event.detail.requestConfig.triggeringEvent.target
const sidebar = triggeringElt.closest('.dt2-sidebar');
console.debug("sidebar", sidebar)
if (sidebar) {
event.preventDefault();
}

View File

@@ -21,6 +21,7 @@ from components.datagrid_new.settings import DataGridRowState, DataGridColumnSta
DataGridFooterConf, DataGridState, DataGridSettings, DatagridView
from components_helpers import mk_icon, mk_ellipsis
from core.instance_manager import InstanceManager
from core.settings_management import SettingsManager
from core.utils import get_unique_id, make_safe_id
logger = logging.getLogger("DataGrid")
@@ -32,7 +33,13 @@ class DataGrid(BaseComponent):
like filtering, column management, and view management.
"""
def __init__(self, session, _id: str = None, key: Any = None, settings_manager=None, boundaries=None):
def __init__(self,
session,
_id: str = None,
key: Any = None,
settings_manager: SettingsManager = None,
grid_settings: DataGridSettings = None,
boundaries: dict = None):
"""
Initialize the DataGrid component.
@@ -50,17 +57,17 @@ class DataGrid(BaseComponent):
# Load state and data
self._state: DataGridState = self._db.load_state()
self._settings: DataGridSettings = self._db.load_settings()
self._settings: DataGridSettings = grid_settings or self._db.load_settings()
self._df: DataFrame | None = self._db.load_dataframe()
# update boundaries if possible
self.set_boundaries(boundaries)
# Create child components
self._file_upload = self._create_component(FileUpload, f"fu_{self._id}")
self._filter_all = self._create_component(FilterAll, f"fa_{self._id}")
self._file_upload = self._create_component(FileUpload, f"fu_{self._id}", permission="open_file")
self._filter_all = self._create_component(FilterAll, f"fa_{self._id}", permission="filter_all")
self._columns_settings = self._create_component(ColumnsSettings, f"scol_{self._id}")
self._views = self._create_component(Views, f"v_{self._id}")
self._views = self._create_component(Views, f"v_{self._id}", permission="views")
# Initial setup
self.close_sidebar()
@@ -69,10 +76,6 @@ class DataGrid(BaseComponent):
if len(self._state.footers) == 0:
self._state.footers.append(DataGridFooterConf())
# ----------------------
# Initialization Methods
# ----------------------
def init_from_excel(self):
"""
Initialize the DataGrid from an Excel file uploaded by the user.
@@ -344,6 +347,9 @@ class DataGrid(BaseComponent):
def get_settings(self) -> DataGridSettings:
return self._settings
def get_dataframe(self) -> DataFrame:
return self._df
def get_table_id(self):
return f"t_{self._id}"
@@ -625,14 +631,20 @@ class DataGrid(BaseComponent):
# self.mk_selection_mode_button(),
# self.mk_resize_table_button(),
# self.mk_reset_filter_button(),
self.mk_download_button(),
self.mk_settings_button(),
self.mk_button(self.mk_download_button(), permission="open_file"),
self.mk_button(self.mk_settings_button(), permission="open_settings"),
cls="flex mr-2",
name="dt-menu",
id=f"cm_{self._id}",
hx_swap_oob='true' if oob else None,
)
def mk_button(self, button, permission=None):
if not self._validate_permission(permission):
return None
return button
def mk_download_button(self):
return mk_icon(icon_open, **self.commands.download())
@@ -775,7 +787,10 @@ class DataGrid(BaseComponent):
df = df[df[col_id].astype(str).isin(values)]
return df
def _create_component(self, component_type: type, component_id: str):
def _create_component(self, component_type: type, component_id: str, permission=None):
if not self._validate_permission(permission):
return None
safe_create_component_id = getattr(component_type, "create_component_id")
return InstanceManager.get(self._session,
safe_create_component_id(self._session, component_id, ""),
@@ -797,6 +812,16 @@ class DataGrid(BaseComponent):
return max_height
def _validate_permission(self, permission):
if permission is None:
return True
settings_attr = permission + "_visible"
if hasattr(self._settings, settings_attr) and not getattr(self._settings, settings_attr):
return False
return True
def __ft__(self):
return Div(
Div(
@@ -810,11 +835,20 @@ class DataGrid(BaseComponent):
),
Script(f"bindDatagrid('{self._id}', false);"),
**self.commands.on_click()
**self.commands.on_click(),
id=f"m_{self._id}",
),
id=f"{self._id}"
)
@staticmethod
def new(session, data, index=None):
datagrid = DataGrid(session, DataGrid.create_component_id(session))
#dataframe = DataFrame(data, index=index)
dataframe = DataFrame(data)
datagrid.init_from_dataframe(dataframe)
return datagrid
@staticmethod
def create_component_id(session):
prefix = f"{DATAGRID_INSTANCE_ID}{session['user_id']}"

View File

@@ -26,7 +26,7 @@ class DataframeWrapper:
class DataGridDbManager:
def __init__(self, session: dict, settings_manager: SettingsManager, key: str):
def __init__(self, session: dict, settings_manager: SettingsManager, key: tuple):
self._session = session
self._settings_manager = settings_manager
self._key = "#".join(make_safe_id(item) for item in key) if key else ""

View File

@@ -54,6 +54,10 @@ class DataGridSettings:
file_name: str = None
selected_sheet_name: str = None
header_visible: bool = True
filter_all_visible: bool = True
views_visible: bool = True
open_file_visible: bool = True
open_settings_visible: bool = True
views: list[DatagridView] = dataclasses.field(default_factory=list)
@staticmethod

View File

@@ -1,3 +1,4 @@
import json
import logging
from fasthtml.fastapp import fast_app
@@ -14,7 +15,28 @@ logger = logging.getLogger("Debugger")
def post(session, _id: str, user_id: str, digest: str = None):
logger.debug(f"Entering {Routes.DbEngineData} with args {debug_session(session)}, {_id=}, {user_id=}, {digest=}")
instance = InstanceManager.get(session, _id)
return instance.add_tab(user_id, digest)
return instance.db_engine_headers(user_id, digest)
@rt(Routes.DbEngineDigest)
def post(session, _id: str, user_id: str, digest: str):
logger.debug(f"Entering {Routes.DbEngineDigest} with args {debug_session(session)}, {_id=}, {user_id=}, {digest=}")
instance = InstanceManager.get(session, _id)
return instance.open_digest(user_id, digest)
@rt(Routes.IaBuddyRequests)
def post(session, _id: str, boundaries: str = None):
logger.debug(f"Entering {Routes.IaBuddyRequests} with args {debug_session(session)}, {_id=}, {boundaries=}")
instance = InstanceManager.get(session, _id)
return instance.ia_requests(json.loads(boundaries) if boundaries else None)
@rt(Routes.IaBuddyRequest)
def post(session, _id: str, request: str, boundaries: str = None):
logger.debug(f"Entering {Routes.IaBuddyRequest} with args {debug_session(session)}, {_id=}, {request=}")
instance = InstanceManager.get(session, _id)
return instance.ia_request(request, json.loads(boundaries) if boundaries else None)
@rt(Routes.JsonViewerFold)
@@ -23,10 +45,3 @@ def post(session, _id: str, node_id: str, folding: str):
instance = InstanceManager.get(session, _id)
instance.set_node_folding(node_id, folding)
return instance.render_node(node_id)
@rt(Routes.JsonOpenDigest)
def post(session, _id: str, user_id: str, digest: str):
logger.debug(f"Entering {Routes.JsonOpenDigest} with args {debug_session(session)}, {_id=}, {user_id=}, {digest=}")
instance = InstanceManager.get(session, _id)
return instance.open_digest(user_id, digest)

View File

@@ -33,6 +33,9 @@
.mmt-jsonviewer {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
overflow-y: auto;
overflow-x: auto;
max-height: 100%
}
/* Use inherited CSS variables for your custom theme */

View File

@@ -11,7 +11,7 @@ class Commands:
"hx-post": f"{ROUTE_ROOT}{Routes.DbEngineData}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}", "user_id": "{user_id}"}}',
"hx-vals": f'js:{{"_id": "{self._id}", "user_id": "{user_id}", "boundaries": getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
}
def db_engine_refs(self, ref_id: str | None):
@@ -19,7 +19,23 @@ class Commands:
"hx-post": f"{ROUTE_ROOT}{Routes.DbEngineRefs}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}", "ref_id": "{ref_id}"}}',
"hx-vals": f'js:{{"_id": "{self._id}", "ref_id": "{ref_id}", "boundaries": getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
}
def ia_buddy_requests(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.IaBuddyRequests}",
"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 ia_buddy_request(self, request_id: str | None):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.IaBuddyRequest}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", "request": "{request_id}", "boundaries": getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
}
@@ -38,7 +54,7 @@ class JsonViewerCommands:
def open_digest(self, user_id, digest):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.JsonOpenDigest}",
"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

@@ -1,13 +1,17 @@
import logging
from datetime import datetime
from fasthtml.components import *
from ai.debug_lmm import DebugConversation
from components.BaseComponent import BaseComponent
from components.aibuddy.assets.icons import icon_brain_ok
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_helpers import mk_ellipsis, mk_icon
from components_helpers import mk_ellipsis, mk_accordion_section
from core.instance_manager import InstanceManager
from core.utils import get_unique_id
@@ -22,43 +26,83 @@ class Debugger(BaseComponent):
self.tabs_manager = tabs_manager
self.commands = Commands(self)
def add_tab(self, user_id, digest):
content = self.mk_db_engine_object(user_id, digest)
def ia_requests(self, boundaries: dict):
def _mk_span(conversation, helper):
return Span(
f"{datetime.fromtimestamp(conversation.start_time).strftime('%Y-%m-%d %H:%M:%S')} - {conversation.initial_prompt}",
cls=helper.class_string,
**self.commands.ia_buddy_request(conversation.id))
ia_buddy = InstanceManager.get(self._session, AIBuddy.create_component_id(self._session))
conversations = ia_buddy.get_conversations()
logger.debug(f"mk_ia_requests_object: {conversations}")
hook = (lambda key, node, helper: isinstance(node.value, DebugConversation),
lambda key, node, helper: _mk_span(node.value, helper))
jsonviewer = InstanceManager.get(self._session,
JsonViewer.create_component_id(self._session, prefix=self._id),
JsonViewer,
owner=self,
user_id=None,
data=ia_buddy.get_conversations(),
hooks=[hook],
boundaries=boundaries)
return self._add_tab(f"debugger-iabuddy-requests", "AI requests", jsonviewer)
def ia_request(self, request_id, boundaries: dict):
def _mk_text_area(value, helper):
return Textarea(value, cls="textarea textarea-sm w-full p-2", disabled=True)
ia_buddy = InstanceManager.get(self._session, AIBuddy.create_component_id(self._session))
requests = ia_buddy.get_conversations()
request = next((req for req in requests if req.id == request_id), None)
logger.debug(f"request: {request}")
if request is None:
return None
hook = (lambda key, node, helper: key in ("extended_prompt", "response"),
lambda key, node, helper: _mk_text_area(node.value, helper))
jsonviewer = InstanceManager.get(self._session,
JsonViewer.create_component_id(self._session, prefix=self._id),
JsonViewer,
owner=self,
user_id=None,
data=request.to_dict(),
hooks=[hook],
boundaries=boundaries)
return self._add_tab(f"debugger-iabuddy-{request_id}", f"Request {request.initial_prompt}", jsonviewer)
def db_engine_headers(self, user_id, digest):
data = self.db_engine.debug_load(user_id, digest) if digest else self.db_engine.debug_head(user_id)
logger.debug(f"mk_db_engine: {data}")
tab_key = f"debugger-dbengine-{digest}"
title = f"DBEngine-{digest if digest else 'head'}"
self.tabs_manager.add_tab(title, content, key=tab_key)
return self.tabs_manager.render()
def mk_db_engine_object(self, user_id, digest):
data = self.db_engine.debug_load(user_id, digest) if digest else self.db_engine.debug_head(user_id)
logger.debug(f"mk_db_engine: {data}")
return InstanceManager.get(self._session,
JsonViewer.create_component_id(self._session, prefix=self._id),
JsonViewer,
owner=self,
user_id=user_id,
data=data)
jsonviewer = InstanceManager.get(self._session,
JsonViewer.create_component_id(self._session, prefix=self._id),
JsonViewer,
owner=self,
user_id=user_id,
data=data,
key=tab_key)
return self._add_tab(tab_key, title, jsonviewer)
def mk_db_engine(self, selected):
return Div(
Input(type="radio",
name=f"dbengine-accordion-{self._id}",
checked="checked" if selected else None,
cls="p-0! min-h-0!",
),
Div(
mk_icon(icon_dbengine, can_select=False), mk_ellipsis("DbEngine", cls="text-sm"),
cls="collapse-title p-0 min-h-0 flex truncate",
),
Div(
*[Div(user_id, **self.commands.db_engine_data(user_id)) for user_id in self.db_engine.debug_users()],
Div("refs", **self.commands.db_engine_refs(None)),
cls="collapse-content pr-0! truncate",
),
cls="collapse mb-2",
id=f"db_engine_{self._id}",
)
content = [Div(user_id, **self.commands.db_engine_data(user_id)) for user_id in self.db_engine.debug_users()]
content.append(Div("refs", **self.commands.db_engine_refs(None)))
return mk_accordion_section(self._id, "DBEngine", icon_dbengine, content, selected)
def mk_ia(self, selected):
ia_request = Div("requests", **self.commands.ia_buddy_requests())
return mk_accordion_section(self._id, "IABuddy", icon_brain_ok, [ia_request], selected)
def _add_tab(self, tab_key, title, content):
self.tabs_manager.add_tab(title, content, key=tab_key)
return self.tabs_manager.render()
def __ft__(self):
return Div(
@@ -66,7 +110,7 @@ class Debugger(BaseComponent):
mk_ellipsis("Debugger", cls="text-sm font-medium mb-1"),
Div(
self.mk_db_engine(True),
cls="flex truncate",
self.mk_ia(False),
),
id=self._id,

View File

@@ -9,6 +9,7 @@ from components.datagrid_new.components.DataGrid import DataGrid
from components.debugger.assets.icons import icon_expanded, icon_collapsed, icon_class
from components.debugger.commands import JsonViewerCommands
from components.debugger.constants import INDENT_SIZE, MAX_TEXT_LENGTH, NODE_OBJECT, NODES_KEYS_TO_NOT_EXPAND
from components_helpers import set_boundaries
from core.serializer import TAG_OBJECT
from core.utils import get_unique_id
@@ -42,13 +43,31 @@ class DictNode(Node):
children: dict[str, Node] = dataclasses.field(default_factory=dict)
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))
class JsonViewer(BaseComponent):
def __init__(self, session, _id, owner, user_id, data):
def __init__(self, session, _id, owner, user_id, data, hooks=None, key=None, boundaries=None):
super().__init__(session, _id)
self._key = key
self._owner = owner # debugger component
self.user_id = user_id
self.data = data
self._node_id = -1
self._boundaries = boundaries if boundaries else {"height": "600"}
self._commands = JsonViewerCommands(self)
# A little explanation on how the folding / unfolding work
@@ -62,6 +81,12 @@ class JsonViewer(BaseComponent):
self._nodes_by_id = {}
self.node = self._create_node(None, data)
# hooks are used to define specific rendering
# They are tuple (Predicate, Element to render (eg Div))
self.hooks = hooks or []
self._helper = JsonViewerHelper()
def set_node_folding(self, node_id, folding):
if folding == self._folding_mode:
@@ -84,7 +109,7 @@ class JsonViewer(BaseComponent):
return self._owner
def open_digest(self, user_id: str, digest: str):
return self._owner.add_tab(user_id, digest)
return self._owner.db_engine_headers(user_id, digest)
def _create_node(self, key, data, level=0):
if isinstance(data, list):
@@ -115,17 +140,18 @@ class JsonViewer(BaseComponent):
return node
def _must_expand(self, node):
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 _mk_folding(self, node: Node):
if not isinstance(node, (ListNode, DictNode)):
def _mk_folding(self, node: Node, must_expand: bool | None):
if must_expand is None:
return None
must_expand = self._must_expand(node)
return Span(icon_expanded if must_expand else icon_collapsed,
cls="icon-16-inline mmt-jsonviewer-folding",
style=f"margin-left: -{INDENT_SIZE}px;",
@@ -136,15 +162,20 @@ class JsonViewer(BaseComponent):
self._node_id += 1
return f"{self._id}-{self._node_id}"
def _render_value(self, node):
def _is_sha256(_value):
return isinstance(_value, str) and len(_value) == 64 and all(
c in '0123456789abcdefABCDEF' for c in _value)
def _render_value(self, key, node, must_expand):
if must_expand is False:
return Span("[...]" if isinstance(node, ListNode) else "{...}",
id=node.node_id,
**self._commands.fold(node.node_id, FoldingMode.EXPAND))
for predicate, renderer in self.hooks:
if predicate(key, node, self._helper):
return renderer(key, node, self._helper)
if isinstance(node, DictNode):
return self._render_dict(node)
return self._render_dict(key, node)
elif isinstance(node, ListNode):
return self._render_list(node)
return self._render_list(key, node)
else:
data_tooltip = None
htmx_params = {}
@@ -159,7 +190,7 @@ class JsonViewer(BaseComponent):
elif node.value is None:
str_value = "null"
data_class = "null"
elif _is_sha256(node.value):
elif self._helper.is_sha256(node.value):
str_value = str(node.value)
data_class = "digest"
htmx_params = self._commands.open_digest(self.user_id, node.value)
@@ -194,24 +225,22 @@ class JsonViewer(BaseComponent):
return Span(str_value, cls=cls, data_tooltip=data_tooltip, **htmx_params)
def _render_dict(self, node: DictNode):
if self._must_expand(node):
return Span("{",
*[
self._render_node(key, value)
for key, value in node.children.items()
],
Div("}"),
id=node.node_id)
else:
return Span("{...}", id=node.node_id)
def _render_dict(self, key, node: DictNode):
return Span("{",
*[
self._render_node(child_key, value)
for child_key, value in node.children.items()
],
Div("}"),
id=node.node_id)
def _render_list(self, node: ListNode):
def _all_the_same(_node):
def _render_list(self, key, node: ListNode):
def _all_the_same(_key, _node):
if len(_node.children) == 0:
return False
sample_value = _node.children[0].value
sample_node = _node.children[0]
sample_value = sample_node.value
if sample_value is None:
return False
@@ -220,6 +249,11 @@ class JsonViewer(BaseComponent):
if type_ in (int, float, str, bool, list, dict, ValueNode):
return False
# a specific rendering is specified
for predicate, renderer in self.hooks:
if predicate(_key, sample_node, self._helper):
return False
return all(type(item.value) == type_ for item in _node.children)
def _render_as_grid(_node):
@@ -244,27 +278,40 @@ class JsonViewer(BaseComponent):
Div("]"),
)
if self._must_expand(node):
if _all_the_same(node):
return _render_as_grid(node)
return _render_as_list(node)
else:
return Span("[...]", id=node.node_id)
return _render_as_grid(node) if _all_the_same(key, node) else _render_as_list(node)
def _render_node(self, key, node):
must_expand = self._must_expand(node) # to be able to update the folding when the node is updated
return Div(
self._mk_folding(node),
self._mk_folding(node, must_expand),
Span(f'{key} : ') if key is not None else None,
self._render_value(node),
self._render_value(key, node, must_expand),
style=f"margin-left: {INDENT_SIZE}px;",
id=node.node_id if hasattr(node, "node_id") else None,
)
def __ft__(self):
return Div(
Div(self._render_node(None, self.node), id=f"{self._id}-root"),
Div(self._render_node(None, self.node),
id=f"{self._id}-root",
style="margin-left: 0px;"),
cls="mmt-jsonviewer",
id=f"{self._id}")
id=f"{self._id}",
**set_boundaries(self._boundaries),
)
def __eq__(self, other):
if type(other) is type(self):
return self._key is not None and self._key == other._key
else:
return False
def __hash__(self):
return hash(self._key) if self._key is not None else super().__hash__()
@staticmethod
def add_quotes(value: str):

View File

@@ -10,5 +10,7 @@ NODES_KEYS_TO_NOT_EXPAND = ["Dataframe", "__parent__"]
class Routes:
DbEngineData = "/dbengine-data"
DbEngineRefs = "/dbengine-refs"
DbEngineDigest = "/dbengine-digest"
IaBuddyRequests = "/iabuddy-requests"
IaBuddyRequest = "/iabuddy-request"
JsonViewerFold = "/jsonviewer-fold"
JsonOpenDigest = "/jsonviewer-open-digest"

View File

@@ -3,6 +3,9 @@ from fasthtml.xtend import Script
from components.BaseComponent import BaseComponent
from components.addstuff.components.AddStuffMenu import AddStuffMenu
from components.admin.components.Admin import Admin
from components.aibuddy.components.AIBuddy import AIBuddy
from components.applications.components.Applications import Applications
from components.debugger.components.Debugger import Debugger
from components.drawerlayout.assets.icons import icon_panel_contract_regular, icon_panel_expand_regular
from components.drawerlayout.constants import DRAWER_LAYOUT_INSTANCE_ID
@@ -23,13 +26,22 @@ class DrawerLayout(BaseComponent):
self._repositories = self._create_component(Repositories)
self._debugger = self._create_component(Debugger)
self._add_stuff = self._create_component(AddStuffMenu)
self._ai_buddy = self._create_component(AIBuddy)
self._admin = self._create_component(Admin)
self._applications = self._create_component(Applications)
self.top_components = self._get_sub_components("TOP", [self._ai_buddy])
self.bottom_components = self._get_sub_components("BOTTOM", [self._ai_buddy])
def __ft__(self):
return Div(
Div(
Div(
self._add_stuff,
self._ai_buddy,
self._applications,
self._repositories,
self._admin,
self._debugger,
),
Div(cls="dl-splitter", id=f"splitter_{self._id}"),
@@ -45,8 +57,10 @@ class DrawerLayout(BaseComponent):
icon_panel_expand_regular,
cls="swap",
),
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'),
Div(*[component for component in self.bottom_components], name="bottom", cls='dl-bottom'),
cls='dl-main',
tabindex="0",
),
@@ -64,3 +78,15 @@ class DrawerLayout(BaseComponent):
@staticmethod
def create_component_id(session, suffix: str = ""):
return f"{DRAWER_LAYOUT_INSTANCE_ID}{session['user_id']}{suffix}"
@staticmethod
def _get_sub_components(location, components):
sub_components = []
for component in components:
if hasattr(component, "register_components"):
sub_components.extend([
sub_component for sub_component, loc in component.register_components()
if loc == location
])
return sub_components

View File

View File

@@ -0,0 +1,8 @@
from fastcore.basics import NotStr
# Material - HolidayVillageTwotone
icon_holidays = NotStr("""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24">
<path opacity=".3" d="M8 6.83l-4 4V18h3v-3h2v3h3v-7.17l-4-4zM9 13H7v-2h2v2z" fill="currentColor"></path>
<path d="M8 4l-6 6v10h12V10L8 4zm4 14H9v-3H7v3H4v-7.17l4-4l4 4V18zm-3-5H7v-2h2v2zm9 7V8.35L13.65 4h-2.83L16 9.18V20h2zm4 0V6.69L19.31 4h-2.83L20 7.52V20h2z" fill="currentColor"></path>
</svg>
""")

View File

View File

@@ -0,0 +1,33 @@
from fasthtml.components import *
from components.BaseComponent import BaseComponent
from components.hoildays.constants import HOLIDAYS_VIEWER_INSTANCE_ID
from components.datagrid_new.components.DataGrid import DataGrid
from components.hoildays.helpers.calendar_helper import CalendarHelper
from components.hoildays.helpers.nibelisparser import OffPeriodDetails
from components.repositories.constants import USERS_REPOSITORY_NAME, HOLIDAYS_TABLE_NAME
from helpers.Datahelper import DataHelper
class HolidaysViewer(BaseComponent):
def __init__(self, session, _id, settings_manager, boundaries=None):
super().__init__(session, _id)
self._settings_manager = settings_manager
self._boundaries = boundaries
def __ft__(self):
records = DataHelper.get(self._session,
self._settings_manager,
USERS_REPOSITORY_NAME,
HOLIDAYS_TABLE_NAME,
OffPeriodDetails)
names, holidays = CalendarHelper.create_calendar(records)
calendar = DataGrid.new(self._session, holidays, index=names)
return Div(
calendar,
cls="mt-2",
)
@staticmethod
def create_component_id(session):
return f"{HOLIDAYS_VIEWER_INSTANCE_ID}{session['user_id']}"

View File

@@ -0,0 +1 @@
HOLIDAYS_VIEWER_INSTANCE_ID = "__HolidaysViewer__"

View File

@@ -0,0 +1,81 @@
from datetime import date, timedelta
from components.hoildays.helpers.nibelisparser import OffPeriodDetails
class CalendarHelper:
@staticmethod
def create_calendar(records: list[OffPeriodDetails], start_date: date | None = None, end_date: date | None = None):
"""
:param records:
:param start_date:
:param end_date:
:return:
"""
# multiple algorithms possible. The chosen one
# two steps
# step one
# create a dict [date, [name, holiday]] for all record in records
# in the loop, records all the names
#
# step two
# create a dict [date, list[holiday]] according to the sorted list names
# step 1
temp = {}
names = set()
for record in records:
full_name = record.first_name + " " + record.last_name
names.add(full_name)
duration = CalendarHelper.get_period(record.start_date, record.end_date)
last_index = len(duration) - 1
for index, current_date in enumerate(duration):
calendar = temp.setdefault(current_date, {})
if full_name in calendar:
calendar[full_name].append(CalendarHelper.get_reason(record, index == 0, index == last_index))
else:
calendar[full_name] = [CalendarHelper.get_reason(record, index == 0, index == last_index)]
res = {}
names = list(sorted(names))
start_date_to_use = start_date or min(temp.keys())
end_date_to_use = end_date or max(temp.keys())
for current_date in CalendarHelper.get_period(start_date_to_use, end_date_to_use):
if current_date in temp:
values = []
res[current_date] = values
for name in names:
if name in temp[current_date]:
values.append(temp[current_date][name])
else:
values.append(None)
else:
res[current_date] = [None] * len(names)
return names, res
@staticmethod
def get_reason(record: OffPeriodDetails, is_start, is_end):
suffix = ""
if is_start and record.start_am_pm:
suffix += "_" + record.start_am_pm
if is_end and record.end_am_pm:
suffix += "_" + record.end_am_pm
return record.reason + suffix
@staticmethod
def get_period(start_date: date, end_date: date):
if end_date < start_date:
raise ValueError("end date is before start date.")
current_date = start_date
res = [current_date]
while current_date < end_date:
current_date = current_date + timedelta(days=1)
res.append(current_date)
return res

View File

@@ -0,0 +1,240 @@
import dataclasses
import datetime
from typing import Literal
@dataclasses.dataclass
class ParsingError:
def __init__(self, reason: str, parser):
self.reason = reason
self.pos = parser.pos
self.line = parser.line
self.column = parser.column
@dataclasses.dataclass
class OffPeriodDetails:
first_name: str
last_name: str
start_date: datetime.date
start_am_pm: Literal["am", "pm"] | None
end_date: datetime.date
end_am_pm: Literal["am", "pm"] | None
total: float
reason: str
date_import: datetime.date
def get_key(self):
return (f"{self.first_name}|{self.last_name}|"
f"{self.start_date}|{self.start_am_pm}|"
f"{self.end_date}|{self.end_am_pm}")
class NibelisParser:
def __init__(self, data: str):
self.data = data
self.pos = 0
self.line = 1
self.column = 1
self.error_sink: list[ParsingError] = []
def parse(self):
return self.read_input()
def read_input(self):
# self.data = self.data.replace("\n\t\n", "\t\n")
while (line := self.read_line().rstrip("\t")) != "Demandes qualifiées" and line is not None:
pass
res = []
try:
while True:
if (detail := self.read_detail()) is not None:
res.append(detail)
except StopIteration:
pass
return res
def read_detail(self):
start = self.read_line() # Détail de la demande
if start is None:
raise StopIteration()
if start.strip() == "" or start == "Demandes qualifiées":
return None
if start != "Détail de la demande":
self.error_sink.append(ParsingError("'Détail de la demande' not found.", self))
return None
try:
self.read_word() # ENGI402
names = []
while (token := self.read_word()) not in ["Le", "Du"]:
names.append(token)
first_name, last_name = self.get_first_name_last_name(names)
if token == "Le":
start_date, start_am_pm, end_date, end_am_pm = self.read_one_date()
else:
start_date, start_am_pm, end_date, end_am_pm = self.read_period()
total = self.read_total()
reason = " ".join([self.read_word(), self.read_word()])
self.read_line() # finish the line
return OffPeriodDetails(first_name,
last_name,
start_date,
start_am_pm,
end_date,
end_am_pm,
total,
reason,
datetime.date.today())
except Exception as ex:
self.error_sink.append(ParsingError(str(ex), self))
self.read_line() # finish the line
return None
def read_one_date(self):
"""
:return:
"""
# ... Le] lundi 20/05/2024 [0.50 J. ...
# ... Le] jeudi 04/01/2024 après-midi exclu [0.50 J. ...
# ... Le] lundi 29/01/2024 matin exclu [0.50 J. ...
self.read_word() # read day
date_as_str = self.read_word()
start_date = datetime.datetime.strptime(date_as_str, "%d/%m/%Y").date()
end_date = start_date + datetime.timedelta(days=1)
am_pm_as_str = self.read_until(lambda c: c.isdigit(), "").strip()
am_pm = self.decode_am_pm(am_pm_as_str)
return start_date, am_pm, end_date, am_pm
def read_period(self):
"""
:return:
"""
# ... Du] <= lundi 27/05/2024 au mardi 28/05/2024 => [3.50 J. ..
# ... Du] <= lundi 19/02/2024 matin inclus au mardi 20/02/2024 après-midi exclu => [3.50 J. ..
# ... Du] <= mardi 20/02/2024 matin exclu au vendredi 23/02/2024 après-midi inclus => [3.50 J. ..
self.read_word() # read day
date_as_str = self.read_word()
start_am_pm_content = self.read_words_until(["au"])
self.read_word() # read day
end_as_str = self.read_word()
end_am_pm_content = self.read_until(lambda c: c.isdigit())
start_date = datetime.datetime.strptime(date_as_str, "%d/%m/%Y").date()
end_date = datetime.datetime.strptime(end_as_str, "%d/%m/%Y").date()
end_date = end_date + datetime.timedelta(days=1)
start_am_pm = self.decode_am_pm(start_am_pm_content)
end_am_pm = self.decode_am_pm(end_am_pm_content)
return start_date, start_am_pm, end_date, end_am_pm
def read_total(self):
total_as_str = self.read_word()
self.read_word() # parse J.
return float(total_as_str)
def read_line(self, strip=False):
return self._read_content(["\n"], strip=strip)
def read_word(self, strip=True):
return self._read_content(["\n", "\t", " "], strip=strip)
def read_words_until(self, words: list):
names = []
while True:
token = self.read_word()
if token is None or token in words:
break
names.append(token)
return names
def read_until(self, predicate, default=None):
if self.pos == len(self.data):
return default
buffer = ""
while self.pos < len(self.data) and not predicate(self.data[self.pos]):
buffer += self.data[self.pos]
self._move_forward()
return buffer
def _read_content(self, tokens: list, strip):
if self.pos >= len(self.data):
return None
buffer = ""
while self.pos < len(self.data) and self.data[self.pos] not in tokens:
buffer += self.data[self.pos]
self._move_forward()
# eat the token
self._move_forward()
if strip:
while self.pos < len(self.data) and self.data[self.pos] in tokens:
self._move_forward()
return buffer
def _move_forward(self):
self.pos += 1
try:
if self.data[self.pos - 1] == "\n": # \n is a 'new line', so it's counted in the new line
self.line += 1
self.column = 1
else:
self.column += 1
except IndexError:
return False
return True
@staticmethod
def get_first_name_last_name(names: list[str]):
if names[0][0].isdigit():
names.pop(0) # sometimes, the code (ex PROD134) is in two parts
for i, name in enumerate(names):
if name.isupper():
break
return " ".join(names[:i]), " ".join(names[i:])
@staticmethod
def decode_am_pm(input_):
if not input_:
return None
if isinstance(input_, list):
input_ = " ".join(input_)
else:
input_ = input_.replace("\t", " ")
input_ = input_.strip()
input_ = " ".join(input_.split())
match input_:
case "matin inclus":
return "am"
case "matin exclu":
return "pm"
case "après-midi inclus":
return "pm"
case "après-midi exclu":
return "am"
case _:
return None

View File

@@ -1,5 +1,7 @@
REPOSITORIES_INSTANCE_ID = "__Repositories__"
ROUTE_ROOT = "/repositories"
USERS_REPOSITORY_NAME = "__USERS___"
HOLIDAYS_TABLE_NAME = "__HOLIDAYS__"
class Routes:
AddRepository = "/add-repo"

View File

@@ -97,6 +97,13 @@ class RepositoriesDbManager:
self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
return repository
def exists_repository(self, repository_name):
if repository_name is None or repository_name == "":
raise ValueError("Repository name cannot be empty.")
settings = self._get_settings()
return repository_name in [repo.name for repo in settings.repositories]
def get_repositories(self):
return self._get_settings().repositories
@@ -141,6 +148,36 @@ class RepositoriesDbManager:
repository.tables.remove(table_name)
self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
def exists_table(self, repository_name: str, table_name: str):
if repository_name is None or repository_name == "":
raise ValueError("Repository name cannot be empty.")
if table_name is None or table_name == "":
raise ValueError("Table name cannot be empty.")
settings = self._get_settings()
repository = next(filter(lambda r: r.name == repository_name, settings.repositories), None)
if repository is None:
return False
return table_name in repository.tables
def ensure_exists(self, repository_name: str, table_name: str):
"""
:param repository_name:
:param table_name:
:return:
"""
try:
if not self.exists_table(repository_name, table_name):
self.add_table(repository_name, table_name)
except NameError:
self.add_repository(repository_name, [table_name])
return repository_name, table_name
def select_repository(self, repository_name: str):
"""
Select and save the specified repository name in the current session's settings.
@@ -172,12 +209,12 @@ class RepositoriesDbManager:
settings = self._get_settings()
repository = next(filter(lambda r: r.name == repository_name, settings.repositories), None)
if repository is None:
raise ValueError(f"Repository '{repository_name}' does not exist.")
raise NameError(f"Repository '{repository_name}' does not exist.")
for table_name, must_exist in zip(tables_names, [t1_must_exists, t2_must_exists]):
if must_exist:
if table_name not in repository.tables:
raise ValueError(f"Table '{table_name}' does not exist in repository '{repository_name}'.")
raise NameError(f"Table '{table_name}' does not exist in repository '{repository_name}'.")
else:
if table_name in repository.tables:
raise ValueError(f"Table '{table_name}' already exists in repository '{repository_name}'.")

View File

@@ -59,6 +59,9 @@ class MyTabs(BaseComponent):
if key in self.tabs_by_key:
self.select_tab_by_id(self.tabs_by_key[key].id)
def get_tab_id(self, tab_key):
return self.tabs_by_key[tab_key].id
def remove_tab(self, tab_id):
"""
Removes a tab with the specified ID from the current list of tabs.