diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_monolithic/.envrc b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/.envrc similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_monolithic/.envrc rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/.envrc diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_monolithic/AddToElementsScreens.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/AddToElementsScreens.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_monolithic/AddToElementsScreens.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/AddToElementsScreens.py diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_monolithic/CreateProfileScreen.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/CreateProfileScreen.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_monolithic/CreateProfileScreen.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/CreateProfileScreen.py diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_monolithic/CreateSubscriptionRequestScreen.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/CreateSubscriptionRequestScreen.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_monolithic/CreateSubscriptionRequestScreen.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/CreateSubscriptionRequestScreen.py diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_monolithic/EditElementsScreens.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/EditElementsScreens.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_monolithic/EditElementsScreens.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/EditElementsScreens.py diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_monolithic/MainScreen.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/MainScreen.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_monolithic/MainScreen.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/MainScreen.py diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_monolithic/MyTeamScreen.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/MyTeamScreen.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_monolithic/MyTeamScreen.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/MyTeamScreen.py diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_monolithic/SearchForTermScreen.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/SearchForTermScreen.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_monolithic/SearchForTermScreen.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/SearchForTermScreen.py diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_monolithic/SelectionOverviewScreen.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/SelectionOverviewScreen.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_monolithic/SelectionOverviewScreen.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/SelectionOverviewScreen.py diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_monolithic/ShopForDataScreen.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/ShopForDataScreen.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_monolithic/ShopForDataScreen.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/ShopForDataScreen.py diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_monolithic/ShowCommentsScreen.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/ShowCommentsScreen.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_monolithic/ShowCommentsScreen.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/ShowCommentsScreen.py diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_monolithic/StatusScreen.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/StatusScreen.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_monolithic/StatusScreen.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/StatusScreen.py diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_monolithic/TechnologyTypeScreens.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/TechnologyTypeScreens.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_monolithic/TechnologyTypeScreens.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/TechnologyTypeScreens.py diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_monolithic/UserIdentitiesScreen.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/UserIdentitiesScreen.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_monolithic/UserIdentitiesScreen.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/UserIdentitiesScreen.py diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_monolithic/__init__.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/__init__.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_monolithic/__init__.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/__init__.py diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_monolithic/catalog_api_calls.md b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/catalog_api_calls.md similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_monolithic/catalog_api_calls.md rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/catalog_api_calls.md diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_monolithic/my_profile.tcss b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/my_profile.tcss similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_monolithic/my_profile.tcss rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/my_profile.tcss diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_monolithic/my_profile_app-orig.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/my_profile_app-orig.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_monolithic/my_profile_app-orig.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/my_profile_app-orig.py diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_monolithic/my_profile_app.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/my_profile_app.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_monolithic/my_profile_app.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/my_profile_app.py diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_monolithic/sample-data b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/sample-data similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_monolithic/sample-data rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_monolithic/sample-data diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_parked_aug26/Add_to_Elements_Screens.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_parked_aug26/Add_to_Elements_Screens.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_parked_aug26/Add_to_Elements_Screens.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_parked_aug26/Add_to_Elements_Screens.py diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_parked_aug26/EditElementsScreens.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_parked_aug26/EditElementsScreens.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_parked_aug26/EditElementsScreens.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_parked_aug26/EditElementsScreens.py diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_parked_aug26/MainScreen.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_parked_aug26/MainScreen.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_parked_aug26/MainScreen.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_parked_aug26/MainScreen.py diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_parked_aug26/README.md b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_parked_aug26/README.md similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_parked_aug26/README.md rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_parked_aug26/README.md diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_parked_aug26/ShowCommentsScreen.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_parked_aug26/ShowCommentsScreen.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_parked_aug26/ShowCommentsScreen.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_parked_aug26/ShowCommentsScreen.py diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_parked_aug26/TechnologyTypeScreens.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_parked_aug26/TechnologyTypeScreens.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_parked_aug26/TechnologyTypeScreens.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_parked_aug26/TechnologyTypeScreens.py diff --git a/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_parked_aug26/__init__.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_parked_aug26/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/my_egeria/my_egeria/DemoCode/My_Profile_parked_aug26/my_profile_app.py b/my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_parked_aug26/my_profile_app.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/My_Profile_parked_aug26/my_profile_app.py rename to my_egeria/my_egeria/DemoCode/Deprecated/My_Profile_parked_aug26/my_profile_app.py diff --git a/my_egeria/my_egeria/DemoCode/Report_Specs_Demo/__init__.py b/my_egeria/my_egeria/DemoCode/Deprecated/Report_Specs_Demo/__init__.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/Report_Specs_Demo/__init__.py rename to my_egeria/my_egeria/DemoCode/Deprecated/Report_Specs_Demo/__init__.py diff --git a/my_egeria/my_egeria/DemoCode/Report_Specs_Demo/md_processing/dr-egeria-outbox/Digital-Products-MyE/Digital-Products-MyE-2025-11-28-09-41-41-FORM.md b/my_egeria/my_egeria/DemoCode/Deprecated/Report_Specs_Demo/md_processing/dr-egeria-outbox/Digital-Products-MyE/Digital-Products-MyE-2025-11-28-09-41-41-FORM.md similarity index 100% rename from my_egeria/my_egeria/DemoCode/Report_Specs_Demo/md_processing/dr-egeria-outbox/Digital-Products-MyE/Digital-Products-MyE-2025-11-28-09-41-41-FORM.md rename to my_egeria/my_egeria/DemoCode/Deprecated/Report_Specs_Demo/md_processing/dr-egeria-outbox/Digital-Products-MyE/Digital-Products-MyE-2025-11-28-09-41-41-FORM.md diff --git a/my_egeria/my_egeria/DemoCode/Report_Specs_Demo/my_reports.py b/my_egeria/my_egeria/DemoCode/Deprecated/Report_Specs_Demo/my_reports.py similarity index 99% rename from my_egeria/my_egeria/DemoCode/Report_Specs_Demo/my_reports.py rename to my_egeria/my_egeria/DemoCode/Deprecated/Report_Specs_Demo/my_reports.py index a91f650a..670970c2 100644 --- a/my_egeria/my_egeria/DemoCode/Report_Specs_Demo/my_reports.py +++ b/my_egeria/my_egeria/DemoCode/Deprecated/Report_Specs_Demo/my_reports.py @@ -38,7 +38,7 @@ class MyApp(App): - CSS_PATH = "./my_reports.tcss" + CSS_PATH = "my_reports.tcss" BINDINGS = [ ("q", "quit", "Quit"), diff --git a/my_egeria/my_egeria/DemoCode/Report_Specs_Demo/my_reports.tcss b/my_egeria/my_egeria/DemoCode/Deprecated/Report_Specs_Demo/my_reports.tcss similarity index 100% rename from my_egeria/my_egeria/DemoCode/Report_Specs_Demo/my_reports.tcss rename to my_egeria/my_egeria/DemoCode/Deprecated/Report_Specs_Demo/my_reports.tcss diff --git a/my_egeria/my_egeria/DemoCode/Technology_Type_Demo/__init__.py b/my_egeria/my_egeria/DemoCode/Deprecated/Technology_Type_Demo/__init__.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/Technology_Type_Demo/__init__.py rename to my_egeria/my_egeria/DemoCode/Deprecated/Technology_Type_Demo/__init__.py diff --git a/my_egeria/my_egeria/DemoCode/Technology_Type_Demo/my_connectors.py b/my_egeria/my_egeria/DemoCode/Deprecated/Technology_Type_Demo/my_connectors.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/Technology_Type_Demo/my_connectors.py rename to my_egeria/my_egeria/DemoCode/Deprecated/Technology_Type_Demo/my_connectors.py diff --git a/my_egeria/my_egeria/DemoCode/Technology_Type_Demo/my_connectors.tcss b/my_egeria/my_egeria/DemoCode/Deprecated/Technology_Type_Demo/my_connectors.tcss similarity index 100% rename from my_egeria/my_egeria/DemoCode/Technology_Type_Demo/my_connectors.tcss rename to my_egeria/my_egeria/DemoCode/Deprecated/Technology_Type_Demo/my_connectors.tcss diff --git a/my_egeria/my_egeria/DemoCode/Technology_Type_Demo/my_connectors_splash_screen.py b/my_egeria/my_egeria/DemoCode/Deprecated/Technology_Type_Demo/my_connectors_splash_screen.py similarity index 100% rename from my_egeria/my_egeria/DemoCode/Technology_Type_Demo/my_connectors_splash_screen.py rename to my_egeria/my_egeria/DemoCode/Deprecated/Technology_Type_Demo/my_connectors_splash_screen.py diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/AddCommentScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/AddCommentScreen.py new file mode 100644 index 00000000..e9746b61 --- /dev/null +++ b/my_egeria/my_egeria/DemoCode/My_Profile/AddCommentScreen.py @@ -0,0 +1,83 @@ +"""" + PDX-License-Identifier: Apache-2.0 + Copyright Contributors to the ODPi Egeria project. + + This file provides a screen for a user to show cooments attached to a community for my_egeria. + +""" +from textual import on +from textual.app import ComposeResult +from textual.containers import ScrollableContainer +from textual.screen import ModalScreen +from textual.widgets import Header, Static, Footer, Input, Button, DataTable + +from pyegeria import load_app_config, settings + + +class AddCommentScreen(ModalScreen): + """ Add a comment to an Egeria Element the user has access to """ + + BINDINGS = [ + ("a", "add_comment", "Add Comment"), + ("c", "cancel", "Cancel"), + ("q", "quit", "Quit") + ] + + CSS_PATH = "my_profile.tcss" + + def __init__(self, table_name, element_guid, *args, **kwargs): + super().__init__(*args, **kwargs) + self.table_name = table_name + self.element_guid = element_guid or None + load_app_config() + app_config = settings.Environment + app_user = settings.User_Profile + self.view_server = app_config.egeria_view_server or "qs-view-server" + self.platform_url = (app_config.egeria_platform_url or "https://127.0.0.1:9443") + self.user_name = app_user.user_name or "garygeeke" + self.user_password = app_user.user_pwd or "secret" + + def on_mount(self): + self.title = "Egeria - My Profile" + self.subtitle = "Add a comment to an element in Egeria" + if self.element_guid == None: + self.notify(f"A row must be selected from a table on the main screen before adding a comment") + self.dismiss(400) + + def compose(self) -> ComposeResult: + """This method composes the UI for the AddCommentScreen.""" + yield Header(show_clock=True) + yield Static(classes="empty") + yield ScrollableContainer( + Static(f"[b]Add Comment[/b] to {self.element_guid}"), + Input(placeholder="Enter comment", id="add_comment_input"), + Static(f"Valid comment types are: Question, Answer, Suggestion and Requirement"), + Input(placeholder="Comment Type: Question, Suggestion, etc.", id="add_comment_type_input"), + Static("Use Cancel to return to the previous screen"), + Button("Submit", id="add_comment_button", variant="primary"), + id="add_comment_container" + ) + yield Footer() + + def action_quit(self): + self.dismiss(200) + + def action_cancel(self): + self.dismiss(200) + + @on(Button.Pressed, "#add_comment_button") + def handle_button_pressed(self): + if self.comment and self.comment_type: + self.comment_data = [self.comment, self.comment_type, self.element_guid] + self.log(f"Comment data: {self.comment_data} being returned from screen") + self.dismiss(self.comment_data) + else: + self.notify("Please enter both a comment and comment type, before submitting") + + def on_input_changed(self, event: Input.Changed): + self.log(f"Input detected: {event.input.id}, {event.input.value}") + if event.input.id == "add_comment_input": + self.comment = event.value + elif event.input.id == "add_comment_type_input": + self.comment_type = event.value + diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/AddToElementsScreens.py b/my_egeria/my_egeria/DemoCode/My_Profile/AddToElementsScreens.py index 9647425a..a5e60663 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/AddToElementsScreens.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/AddToElementsScreens.py @@ -503,7 +503,7 @@ def action_add_new_blog(self): self.query_one("#blog_entry_name", Input).clear() self.query_one("#blog_entry_text", Input).clear() self.query_one("#blog_entry_situation", Input).clear() - self.query_one("#blog_entry_container", ScrollableContainer).refresh() + self.query_one("#blog_input_container", ScrollableContainer).refresh() return @on(Input.Changed) diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/CreateSubscriptionRequestScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/CreateSubscriptionRequestScreen.py index 50ac7045..65304332 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/CreateSubscriptionRequestScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/CreateSubscriptionRequestScreen.py @@ -44,6 +44,7 @@ def __init__(self, selected_item: str = "", *args, **kwargs): def compose(self) -> ComposeResult: yield Header(show_clock=True) + yield Static(classes="empty") yield ScrollableContainer( Static(f"Please provide the following input as appropriate:"), Static("Display Name should be short but descriptive, it will also be used to create the Qualified Name as well"), diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/FeedbackScreens.py b/my_egeria/my_egeria/DemoCode/My_Profile/FeedbackScreens.py new file mode 100644 index 00000000..01479a62 --- /dev/null +++ b/my_egeria/my_egeria/DemoCode/My_Profile/FeedbackScreens.py @@ -0,0 +1,141 @@ +""" + PDX-License-Identifier: Apache-2.0 + Copyright Contributors to the ODPi Egeria project. + + This file provides the screens for the app-wide feedback mechanism in my_egeria: + a form to leave feedback from any screen, and a viewer for the private feedback + note log (see feedback_handler.py for the Egeria side). + +""" +from typing import Any + +from textual import on +from textual.app import ComposeResult +from textual.containers import ScrollableContainer, Horizontal +from textual.screen import ModalScreen +from textual.widgets import Header, Static, Footer, Button, DataTable, Select, TextArea + +FEEDBACK_CATEGORIES = ["Bug", "Suggestion", "Question", "Praise", "Other"] + + +class FeedbackScreen(ModalScreen): + """ Collect feedback about the screen the user was on when they pressed the feedback key """ + + BINDINGS = [ + ("ctrl+s", "submit", "Submit Feedback"), + ("escape", "cancel", "Cancel"), + ] + + CSS_PATH = "my_profile.tcss" + + def __init__(self, screen_name: str, screen_title: str = "", *args, **kwargs): + super().__init__(*args, **kwargs) + self.screen_name = screen_name + self.screen_title = screen_title + self.category = FEEDBACK_CATEGORIES[1] + + def on_mount(self): + self.title = "Egeria - My Profile" + self.sub_title = "Leave Feedback" + self.query_one("#feedback_text", TextArea).focus() + + def compose(self) -> ComposeResult: + """This method composes the UI for the FeedbackScreen.""" + context = self.screen_name if not self.screen_title else f"{self.screen_name} ({self.screen_title})" + yield Header(show_clock=True) + yield Static("Leave Feedback", classes="span-3", id="feedback_title") + yield ScrollableContainer( + Static(f"[b]Feedback on:[/b] {context}"), + Static("Category"), + Select( + [(category, category) for category in FEEDBACK_CATEGORIES], + value=self.category, + allow_blank=False, + id="feedback_category", + ), + Static("Feedback"), + TextArea(id="feedback_text"), + Static("Your feedback is recorded in a private feedback log. Use Cancel to return to the previous screen"), + Horizontal( + Button("Submit", id="submit_feedback_button", variant="primary"), + Button("Cancel", id="cancel_feedback_button", variant="warning"), + ), + id="feedback_container", + ) + yield Footer() + + @on(Select.Changed, "#feedback_category") + def handle_category_changed(self, event: Select.Changed): + self.category = str(event.value) + + def action_submit(self): + feedback_text = self.query_one("#feedback_text", TextArea).text.strip() + if not feedback_text: + self.notify("Please enter some feedback before submitting", timeout=5, severity="warning") + return + feedback_data = { + "screen": self.screen_name, + "screen_title": self.screen_title, + "category": self.category, + "text": feedback_text, + } + self.log(f"Feedback data: {feedback_data} being returned from screen") + self.dismiss(feedback_data) + + def action_cancel(self): + self.dismiss(None) + + @on(Button.Pressed, "#submit_feedback_button") + def handle_submit_button(self): + self.action_submit() + + @on(Button.Pressed, "#cancel_feedback_button") + def handle_cancel_button(self): + self.action_cancel() + + +class FeedbackLogScreen(ModalScreen): + """ Show the accumulated entries in the private feedback note log """ + + BINDINGS = [ + ("q", "quit", "Quit"), + ("escape", "quit", "Back"), + ] + + CSS_PATH = "my_profile.tcss" + + def __init__(self, feedback_entries: list[dict[str, Any]], *args, **kwargs): + super().__init__(*args, **kwargs) + self.feedback_entries = feedback_entries or [] + + def on_mount(self): + self.title = "Egeria - My Profile" + self.sub_title = f"Feedback Log ({len(self.feedback_entries)} entries)" + table = self.query_one("#feedback_log_table", DataTable) + table.add_columns("Date", "Submitted By", "Category", "Screen", "Feedback") + table.zebra_stripes = True + table.cursor_type = "row" + if not self.feedback_entries: + table.add_row("", "", "", "", "No feedback has been recorded yet") + for entry in self.feedback_entries: + table.add_row( + str(entry.get("date", "")), + str(entry.get("submitted_by", "")), + str(entry.get("category", "")), + str(entry.get("screen", "")), + str(entry.get("text", "")), + ) + table.focus() + + def compose(self) -> ComposeResult: + """This method composes the UI for the FeedbackLogScreen.""" + yield Header(show_clock=True) + yield Static("Feedback Log", classes="span-3", id="feedback_log_title") + yield ScrollableContainer( + DataTable(id="feedback_log_table"), + id="feedback_log_container", + ) + yield Footer() + + def action_quit(self): + self.dismiss(200) diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py index bfb80836..12d1da4e 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py @@ -19,6 +19,7 @@ class MainScreen(Screen): BINDINGS = [ ("q", "app.quit", "Quit"), + ("ctrl+a", "add_comment","add comment to selected row"), ("ctrl+b", "bookmarks", "Manage Bookmarks"), ("ctrl+s", "show_comments", "Show Comments for Selected Row"), ("ctrl+t", "edit_table", "Edit Selected Table"), @@ -58,6 +59,8 @@ def compose(self) -> ComposeResult: Option("[] Subscriptions"), Option("[] Technology Types"), Option("[] User Bookmarks"), + Option("[] Leave Feedback"), + Option("[] Feedback Log"), id="other_function_list" ), id="other_function_container" @@ -181,6 +184,23 @@ async def action_show_comments(self): else: self.notify("Please select a row and table to show comments.", timeout=5, severity="warning") + async def action_add_comment(self): + """ Add a comment to the selected table and row """ + table_name, row_k = self.get_current_table_and_row() + if table_name and row_k: + self.selected_table = table_name + self.selected_row = row_k + add_comment_return = self.app.add_comment(table_name, row_k) + if add_comment_return: + if add_comment_return == 200: + self.log("Successfully added comment") + return + else: + self.log("Failed to add comment.") + self.notify("Failed to add comment.", timeout=20, severity="error") + else: + self.notify("Please select a row and table to add a comment.", timeout=5, severity="warning") + async def action_add_to_table(self): """ Add to the selected table. diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/RETURN_CODES.md b/my_egeria/my_egeria/DemoCode/My_Profile/RETURN_CODES.md index 90664068..de3e376e 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/RETURN_CODES.md +++ b/my_egeria/my_egeria/DemoCode/My_Profile/RETURN_CODES.md @@ -47,6 +47,7 @@ In accordance with application conventions: | **`422`** | `DATA_SPECIFICATION_RETRIEVAL_ERROR` | Error retrieving data specification details from Egeria in Shop for Data handler. | `shop_for_data_handler.py` | | **`423`** | `BUSINESS_DOMAIN_SPEC_ERROR` | Error executing BusinessCapabilities report specification in Shop for Data handler. | `shop_for_data_handler.py` | | **`429`** | `SAMPLE_DATA_RETRIEVAL_ERROR` | Failed to retrieve or parse sample data from Egeria Asset Catalog / Data Engine in Shop for Data handler. | `shop_for_data_handler.py` | +| **`430`** | `INCOMPATIBLE_ENVIRONMENT` | App started under a Python whose pydantic is older than pyegeria requires, so request bodies would reach Egeria with their parameters silently missing. The app exits at startup with a message naming the interpreter and how to run from the project venv instead. | `my_profile_app.py`, `profile_utils.py` | | **`440`** | `TEAM_ROLES_OR_GLOSSARY_ERROR` | Error communicating with Egeria or retrieving team members, user roles, or glossary term details. | `team_roles_handler.py` | --- @@ -57,4 +58,5 @@ In accordance with application conventions: - **400–409**: Screen UI widget creation, mount, and fallback recovery errors. - **410–419**: Tree resolution errors in selection overview, and technology type data extraction / callback payload errors. - **420–429**: Data retrieval exceptions, report specification execution failures, and template instantiation failures across Shop for Data and Technology Types. +- **430**: Incompatible Python environment detected at startup. - **440**: Team roles and glossary term query communication errors. diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/ShowCommentScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/ShowCommentScreen.py deleted file mode 100644 index 116cb4ae..00000000 --- a/my_egeria/my_egeria/DemoCode/My_Profile/ShowCommentScreen.py +++ /dev/null @@ -1,89 +0,0 @@ -"""" - PDX-License-Identifier: Apache-2.0 - Copyright Contributors to the ODPi Egeria project. - - This file provides a screen for a user to show cooments attached to a community for my_egeria. - -""" - -from textual.app import ComposeResult -from textual.containers import ScrollableContainer -from textual.screen import ModalScreen -from textual.widgets import Header, Static, TextArea, Footer -from pyegeria import Egeria - -class ShowCommentScreen(ModalScreen): - """ Add a comment to a community the user belongs to """ - - BINDINGS = [ - ("a", "add_comment", "Add Comment"), - ("c", "cancel", "Cancel"), - ("q", "quit", "Quit") - ] - - CSS_PATH = "my_profile.tcss" - - def __init__(self, community_GUID, attached_messages): - super().__init__() - self.community_GUID = community_GUID - self.attached_messages = attached_messages - self.log(f"Attached Messages: {self.attached_messages}") - self.log(f"Community GUID: {self.community_GUID}") - - def on_mount(self): - self.title = "Egeria - My Profile" - self.subtitle = "Show Comments attached to a Community" - all_comments = self.extract_comments(self.attached_messages) - for comment in all_comments: - self.query_one("#show_comment_textarea_container", ScrollableContainer).mount(Static(comment)) - - - - def compose(self) -> ComposeResult: - """This method composes the UI for the AddCommentScreen.""" - yield Header(show_clock=True) - yield ScrollableContainer( - Static("Use Cancel to return to the previous screen"), - Static("Comments:\n"), - id="show_comment_textarea_container" - ) - yield Footer() - - def action_quit(self): - self.dismiss([200, ""]) - - def action_cancel(self): - self.dismiss([200, ""]) - - def action_add_comment(self): - self.dismiss([200, self.community_GUID]) - - def extract_comments(self, data): - """ - Recursively searches through a data structure (dict or list) to find - all comments identified by 'class': 'CommentProperties' and returns - their 'description' values. - - Parameters: - data (dict|list): The data structure received from the external system. - - Returns: - list: A list of comment strings found in the structure. - """ - comments = [] - - if isinstance(data, dict): - # Check if this dictionary is a CommentProperties object - if data.get('class') == 'CommentProperties' and 'description' in data: - comments.append(data['description']) - - # Recursively search all values in the dictionary - for value in data.values(): - comments.extend(self.extract_comments(value)) - - elif isinstance(data, list): - # Recursively search all items in the list - for item in data: - comments.extend(self.extract_comments(item)) - - return comments \ No newline at end of file diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/ShowCommentsScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/ShowCommentsScreen.py index ae824dba..9536940d 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/ShowCommentsScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/ShowCommentsScreen.py @@ -7,6 +7,7 @@ """ from typing import Any +from textual import on from textual.app import ComposeResult from textual.containers import ScrollableContainer from textual.css.query import NoMatches @@ -17,11 +18,12 @@ class ShowCommentsScreen(ModalScreen): - """Main Screen for My Profile App.""" + """Show any comments for an element Screen for My Profile App.""" BINDINGS = [ ("q", "quit", "Quit"), - ("ctrl+a", "add_comment", "Add Comment") + ("ctrl+a", "add_comment", "Add Comment"), + ("ctrl+r", "add_response", "Add Response"), ] CSS_PATH = "my_profile.tcss" @@ -30,81 +32,99 @@ def __init__( self, table_name: str | None = None, table_row: Any = None, - view_server: str | None = None, - platform_url: str | None = None, - user_name: str | None = None, - user_password: str | None = None, - platfgorm_url: str | None = None, *args, + selected_comment_guid: str | None = None, **kwargs, ): super().__init__(*args, **kwargs) load_app_config() + # The comment whose responses this screen shows (None = show an element's comments) + self.selected_comment_guid = selected_comment_guid or None + # The comment currently highlighted in the DataTable (target for Ctrl+R) + self.highlighted_comment_guid: str | None = None app_config = settings.Environment app_user = settings.User_Profile self.table = table_name self.row = table_row - self.view_server = view_server or app_config.egeria_view_server or "qs-view-server" - self.platform_url = ( - platform_url or platfgorm_url or kwargs.get("platfgorm_url") or app_config.egeria_platform_url or "https://127.0.0.1:9443" - ) - self.user_name = user_name or app_user.user_name or "garygeeke" - self.user_password = user_password or app_user.user_pwd or "secret" + self.view_server = app_config.egeria_view_server or "qs-view-server" + self.platform_url = app_config.egeria_platform_url or "https://127.0.0.1:9443" + self.user_name = app_user.user_name or "garygeeke" + self.user_password = app_user.user_pwd or "secret" self.selected_row = "" self.comment_text = "" self.comment_type = "" self.backend_id = "" - self.show_comments_datatable: DataTable = DataTable(id="show_comments_dt") + # Columns are added in on_mount: DataTable.add_columns needs an active app + self.show_comments_datatable: DataTable = DataTable(id="show_comments_dt", cursor_type="row", zebra_stripes=True) + self.dt_row_selected = None + self.comment_guid = "" def on_mount(self): """On mount, find the GUID for the Row """ self.title = "Egeria - My Profile" self.sub_title = "Show Comments" - # Extract the correct string identifier - backend_id = self.extract_backend_identifier(self.table, self.row) - self.log(f"Backend identifier: {backend_id} extracted") - if backend_id: - self.notify(f"Accessing backend system with identifier: {backend_id}") - self.backend_id = backend_id - try: - comments_list = exec_report_spec( - format_set_name="Comment-by-Element", - output_format="DICT", - params={"element_guid": backend_id}, - view_server=self.view_server, - view_url=self.platform_url, - user=self.user_name, - user_pass=self.user_password, - ) - self.log(f"comments_list: {comments_list}") - - if isinstance(comments_list, dict): - comment_count = 0 - structured_comments: list[list] = [] - comment_text: list[Any] = [] - for key, value in comments_list.items(): - structured_comments[comment_count] = (key, - value["Display Name"], - value["Qualified Name"], - value["Comment Guid"], - value["Description"]) - comment_count += 1 - for row in structured_comments: - comment_text.append[row] - self.query_one("#show_comments_container", ScrollableContainer).mount(Static(row)) - elif isinstance(comments_list, str): - self.log(f"processing str comment: {comments_list}") - comment_text = str(comments_list) - self.query_one("#show_comments_container", ScrollableContainer).mount(Static(comment_text)) - else: - self.log(f"processing list of: {len(comments_list)} comments") - for comment in comments_list: - comment_text = str(comment) - self.query_one("#show_comments_container", ScrollableContainer).mount(Static(comment_text)) - except PyegeriaException as e: - self.notify(f"No comments found: {str(e)}", severity="warning") + self.dt_row_selected = None + self.show_comments_datatable.add_columns("Comment", "GUID", "Qualified Name", "Name") + if self.selected_comment_guid: + # Responding to a comment: a response is just a comment attached to the comment element + backend_id = self.selected_comment_guid else: + # Extract the correct string identifier + backend_id = self.extract_backend_identifier(self.table, self.row) + self.log(f"Backend identifier: {backend_id} extracted") + if not backend_id: self.notify("Selected table does not contain valid GUID or Qualified Name columns.", severity="error") + return + + self.notify(f"Accessing backend system with identifier: {backend_id}") + self.backend_id = backend_id + self.load_comments(backend_id) + + def load_comments(self, element_guid: str) -> None: + """Fetch the comments attached to element_guid and show them in the DataTable.""" + try: + comments_list = exec_report_spec( + format_set_name="Comment-by-Element", + output_format="DICT", + params={"element_guid": element_guid}, + view_server=self.view_server, + view_url=self.platform_url, + user=self.user_name, + user_pass=self.user_password, + ) + except PyegeriaException as e: + self.notify(f"No comments found: {str(e)}", severity="warning") + return + + self.log(f"comments_list: {comments_list}") + comment_table = self.show_comments_datatable + if isinstance(comments_list, dict): + if comments_list.get("kind") == "json": + comments_list_ext = comments_list.get("data") or [] + self.log(f"comments_list_ext: {comments_list_ext}, len: {len(comments_list_ext)}") + for comment in comments_list_ext: + self.log(f"processing value: {comment}") + comment_table.add_row(comment.get('Description'), comment.get('Comment Guid'), + comment.get('Qualified Name'), comment.get('Display Name')) + elif isinstance(comments_list, str): + self.log(f"processing str comment: {comments_list}") + comment_table.add_row(comments_list, "", "", "") + elif comments_list: + self.log(f"processing list of: {len(comments_list)} comments") + for comment in comments_list: + comment_table.add_row(str(comment), "", "", "") + + if comment_table.row_count == 0: + if self.selected_comment_guid: + self.notify("This comment has no responses yet - use Ctrl+A to add one", timeout=10) + else: + self.notify("Selected element does not have any comments - use Ctrl+A to add one", timeout=10) + return + + # Mount the table once, after all rows have been added + container = self.query_one("#show_comments_container", ScrollableContainer) + container.mount(comment_table) + container.mount(Static("Ctrl+R on a highlighted comment to view and add responses to it")) def extract_backend_identifier(self, table_name, row_key): try: @@ -160,15 +180,20 @@ def extract_backend_identifier(self, table_name, row_key): def compose(self) -> ComposeResult: + if self.selected_comment_guid: + heading = f"Responses to comment: {self.selected_comment_guid}" + else: + heading = f"Show Comments for: {self.table}" yield Header(show_clock=True) + yield Static(classes="empty") yield ScrollableContainer( - Static(f"Show Comments for: {self.table}", id="show_comments_static"), + Static(heading, id="show_comments_static"), id="show_comments_container" ) yield Footer() def action_add_comment(self): - """ Add an attached Comment to thew selected row """ + """ Add an attached Comment to the selected row """ if self.backend_id: self.log(f"Selected row: {self.backend_id}") container = (self.query_one("#show_comments_container", ScrollableContainer)) @@ -181,6 +206,14 @@ def action_add_comment(self): else: self.notify("No row selected, a selected row is required to add a comment!") + def action_add_response(self): + """ Reopen this screen focused on the highlighted comment, to view/add responses to it """ + if self.highlighted_comment_guid: + self.log(f"Add response to comment: Selected row: {self.highlighted_comment_guid}") + self.dismiss([250, self.highlighted_comment_guid]) + else: + self.notify("Highlight a comment in the table first, to respond to it", severity="warning") + def on_input_changed(self, event: Input.Changed): self.log(f"Input detected: {event.input.id}, {event.input.value}") if event.input.id == "add_comment_input": @@ -194,17 +227,17 @@ def on_input_changed(self, event: Input.Changed): return self.log(f"Comment text: {self.comment_text}, Comment type: {self.comment_type}") - def on_button_pressed(self, event: Button.Pressed): - if event.button.id == "add_comment_button": - self.log(f"Button pressed: {event.button.id}") - self.log(f"Comment text: {self.comment_text}, Comment type: {self.comment_type}") - if self.comment_text and self.comment_type: - self.add_comment() - else: - self.notify("Both comment text and comment type are required!") - return + @on(Button.Pressed, "#add_comment_button") + def handle_button_pressed(self, event: Button.Pressed): + """ Handle user request to add a comment to the selected element """ + self.log(f"Button pressed: {event.button.id}") + self.log(f"Comment text: {self.comment_text}, Comment type: {self.comment_type}") + if self.comment_text and self.comment_type: + # add_comment dismisses the screen itself on success, and stays open on failure + self.add_comment() else: - self.notify("Button not recognized! Please try again") + self.notify("Both comment text and comment type are required!") + return def add_comment(self): if not self.comment_text or not self.comment_type or not self.backend_id: @@ -226,7 +259,7 @@ def add_comment(self): try: # Add comment to selected element - self.comment_type = self.comment_type.upper() + self.comment_type = self.comment_type.strip().upper().replace(" ", "_") self.log(f"Adding comment to element: {self.backend_id}, values: {self.comment_text}, {self.comment_type}") response = egeria_tech.add_comment_to_element( element_guid=self.backend_id, @@ -237,12 +270,33 @@ def add_comment(self): self.notify(f"Comment successfully added! New Comment GUID: {response}") container = (self.query_one("#show_comments_container", ScrollableContainer)) container.remove_children() - rc = 200 except Exception as e: self.log(f"Failed to add comment: {e}") self.notify(f"Failed to add comment: {e} \n Please try again.") - rc = 400 - self.dismiss() + return + self.dismiss(200) + + @on(DataTable.RowHighlighted, "#show_comments_dt") + def on_row_highlighted(self, event: DataTable.RowHighlighted): + self.log(f"Row highlighted: {event.row_key}") + self._track_comment_row(event.row_key) + + @on(DataTable.RowSelected, "#show_comments_dt") + def on_row_selected(self, event: DataTable.RowSelected): + self.log(f"Row selected: {event.row_key}") + self._track_comment_row(event.row_key) + def _track_comment_row(self, row_key) -> None: + """Remember the GUID (column 1) of the comment under the cursor.""" + self.dt_row_selected = row_key + try: + row_data = self.show_comments_datatable.get_row(row_key) + except Exception as e: + self.log(f"Unable to read comment row {row_key}: {e}") + self.highlighted_comment_guid = None + return + self.highlighted_comment_guid = row_data[1] or None + self.comment_guid = self.highlighted_comment_guid or "" + def action_quit(self): self.app.pop_screen() \ No newline at end of file diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/SplashScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/SplashScreen.py new file mode 100644 index 00000000..c77bbf25 --- /dev/null +++ b/my_egeria/my_egeria/DemoCode/My_Profile/SplashScreen.py @@ -0,0 +1,101 @@ +""" + PDX-License-Identifier: Apache-2.0 + Copyright Contributors to the ODPi Egeria project. + + This file provides a splash scrren and optional change of logged in user for my_egeria. + +""" + +from typing import Any +from textual import on +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Header, Footer, Button, Label, Input, Static + +from pyegeria import Egeria, load_app_config, settings, PyegeriaException + + +class SplashScreen(ModalScreen): + """ SplashScreen for my_profile_app """ + + BINDINGS = [("escape", "app.pop_screen", "Exit")] + + CSS_PATH = "my_profile.tcss" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.title = "Egeria" + self.subtitle = "Welcome to my_profile_app" + load_app_config() + app_config = settings.Environment + app_user = settings.User_Profile + self.user_name = app_user.user_name + self.user_password = app_user.user_pwd + self.view_server = app_config.egeria_view_server + self.platform_url = app_config.egeria_platform_url + + def compose(self) -> ComposeResult: + yield Header(show_clock=True,id="splash") + # 1. Define multi-line layout with line breaks + splash_content = ( + f"Welcome to My_Profile for Egeria Users, {self.user_name}!\n" + "----------------------------------------\n" + "Initializing secure packages...\n\n" + "© PDX-License-Identifier: Apache-2.0\n" + "Copyright Contributors to the ODPi Egeria project.\n\n" + "This file provides a set of profile related functions for my_egeria." + ) + + # 2. Build the UI components + yield Static(classes="empty") + with Vertical(id="splash-card"): + yield Label(splash_content, id="splash-text") + # yield Static(classes="empty") + # yield Static(classes="empty") + yield Horizontal( + Button("Continue to App", id="continue", variant="success", classes="splash_buttons"), + Button("Change User", variant="warning", id="change_user", classes="splash_buttons"), + id="SplashButtons" + ) + # yield Static(classes="empty") + yield Footer() + + @on(Button.Pressed, "#continue") + def handle_continue(self): + self.dismiss() + + @on(Button.Pressed, "#change_user") + def handle_change_user(self): + self.query_one("#splash-card").remove_children() + self.query_one("#change_user").remove() + self.query_one("#splash-card").mount( + Input(placeholder="Enter new user name", id="new_user_name"), + Input(placeholder="Enter password for new user", id="new_user_password", password=True), + Button("Submit", id="submit_new_user", variant="success") + ) + + @on(Button.Pressed, "#submit_new_user") + def handle_submit_new_user(self): + self.new_user = self.query_one("#new_user_name", Input).value + self.new_password = self.query_one("#new_user_password", Input).value + if not self.new_user or not self.new_password: + self.notify("Please enter both user name and password", severity="error", timeout=20) + return + # Validate new user credentials + eclient = None + try: + eclient = Egeria( + self.view_server, + self.platform_url, + self.new_user, + self.new_password, + ) + except PyegeriaException as e: + self.notify(f"User credential validation failed: {e}", timeout=30, severity="error") + return + finally: + if eclient: + eclient.close_session() + self.dismiss([self.new_user, self.new_password]) + diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/VIDEO_SCRIPT_DRAFT.md b/my_egeria/my_egeria/DemoCode/My_Profile/VIDEO_SCRIPT_DRAFT.md new file mode 100644 index 00000000..4109a9c7 --- /dev/null +++ b/my_egeria/my_egeria/DemoCode/My_Profile/VIDEO_SCRIPT_DRAFT.md @@ -0,0 +1,116 @@ +# My Profile — Video Blog Script (DRAFT) + +**Working title:** "Meeting Egeria Through My Profile — Callie Quartile, Data Scientist at Coco Pharmaceuticals — Video Blog" +**Target length:** ~4 minutes (≈550 spoken words) +**Format:** In-character video blog set at Coco Pharmaceuticals, in the same style as the Jules Keeper series. Talking head / voice-over, cutting to screen capture of `my_profile_app.py` running in a terminal. +**Presenter persona:** Callie Quartile, data scientist at Coco Pharmaceuticals (swap in another Coco persona if you prefer; see the notes at the end). + +--- + +## 0. Cold open (0:00 – 0:15) + +**[VISUAL]** Presenter to camera, or a Coco Pharmaceuticals title card: *"Better data for everyone"*. + +**CALLIE:** +> Hi, I'm Callie Quartile. I'm a data scientist at Coco Pharmaceuticals. When Jules Keeper, our Chief Data Officer, launched *"better data for everyone"*, my first question was pretty simple: where do I actually start? It turns out I start with **me**. + +--- + +## 1. The problem (0:15 – 0:40) + +**[VISUAL]** Simple slide: a person in the middle, with icons around them for teams, roles, projects, data, and to-dos. Lines connect them. + +**CALLIE:** +> Egeria already knows a lot about me: which teams I'm on, the roles I hold, my projects, the data I use. But that knowledge is spread across the metadata. I wanted one place where I could see it, keep it up to date, and act on it. That place is **My Profile**. + +--- + +## 2. Launch and splash screen (0:40 – 1:00) + +**[VISUAL]** Terminal. Type `python my_profile_app.py`. The splash screen appears with *"Welcome to My_Profile for Egeria Users, callie!"* and the **Continue to App** / **Change User** buttons. + +**CALLIE:** +> My Profile is a terminal app built on pyegeria. While this welcome screen is showing, it's already loading my profile from Egeria in the background, so by the time I press Continue it's ready. +> If I'm sharing a desk, or testing as a colleague, **Change User** lets me sign in as someone else. Egeria checks the credentials before letting me through. + +**[ON-SCREEN CALLOUT]** "Profile loads in parallel with the splash screen" + +**[OPTIONAL CUTAWAY]** If a user has no profile yet, the app offers the **Create Profile** form (courtesy title, job title, and so on). + +--- + +## 3. The main dashboard (1:00 – 1:45) + +**[VISUAL]** Main screen. Slowly pan or highlight each panel as it's mentioned. The header shows "Egeria — My Profile" and the clock. + +**CALLIE:** +> This is my home screen. Everything here comes live from Egeria. +> Up here are my **associations** and my **collections**. Next are the **roles** I've been appointed to and the **teams** I belong to. +> This panel is my working life: my **blog**, my **journal**, and my **to-dos**. +> Down here is my **user identity**, which is how Egeria recognises me across the tools I use. + +**[ON-SCREEN CALLOUT]** Point out the keyboard shortcuts in the footer: `Ctrl+T` Edit, `Ctrl+S` Comments, `Ctrl+B` Bookmarks. + +--- + +## 4. Keeping it current (1:45 – 2:20) + +**[VISUAL]** Select the To-Dos table, press `Ctrl+T`, and add a to-do: *"Review clinical trial data quality report"*. Back on the main screen the new row appears. Then highlight a row and press `Ctrl+S` to show and add a comment. + +**CALLIE:** +> The profile is only useful if it's accurate. I pick any table and press **Ctrl+T** to edit it: add a row, update one, or remove something that's no longer true. Here I'm adding a to-do from this morning's stand-up. +> **Ctrl+S** opens the comments on whatever row I've selected, so a conversation stays attached to the thing it's about, not lost in email. + +**[OPTIONAL]** Quick shot of **Edit Profile** from the Other Functions list, updating the job title. + +--- + +## 5. Shopping for data (2:20 – 3:05) ← *the "hero" moment* + +**[VISUAL]** Other Functions → **Catalogs / Shop for Data**. Show the Glossary, Digital Product Catalog, Data Dictionary, Business Domain, and Root Collection panels. Select a data product, press `s` to **sample** it, then `u` to **subscribe**. Fill in the subscription request and submit. Then open **Subscriptions** to show it listed. + +**CALLIE:** +> This is the part I use most. **Shop for Data** brings together our glossary, the digital product catalog, data dictionaries, and business domains in one place. +> I can find the product I need and **sample** it before I commit. When it's right, I **subscribe**. That sends a proper subscription request through Egeria, so the data owner knows who's using their data and why. +> And under **Subscriptions** I can see everything I've signed up for. + +**[ON-SCREEN CALLOUT]** "Governed access, requested in seconds" + +--- + +## 6. Bookmarks and technology types (3:05 – 3:30) + +**[VISUAL]** Press `Ctrl+B` to show the bookmarks list and add one. Then Other Functions → **Technology Types**. Pick a technology type and show its templates and processes. + +**CALLIE:** +> **Ctrl+B** keeps my bookmarks, the elements I come back to again and again. +> And for the more technical among us, **Technology Types** shows what Egeria knows about each technology, including the templates and processes it can run for you. + +--- + +## 7. Close and call to action (3:30 – 4:00) + +**[VISUAL]** Back to presenter, or the main dashboard. End card with links. + +**CALLIE:** +> So that's My Profile: one place to see how I fit into Coco Pharmaceuticals, keep it accurate, and get the data I need, with governance built in rather than bolted on. +> Better data for everyone really does start with everyone. Next time, I'll show how my team uses this to share projects and to-dos. +> The app is open source, part of the Egeria Python project. Links are below. Give it a try against the Coco Pharmaceuticals demo environment. + +**[END CARD]** +- Egeria project: https://egeria-project.org +- egeria-python on GitHub (link to `my_egeria/DemoCode/My_Profile`) +- "Previous in series: Jules Keeper video blogs" (link to the playlist) + +--- + +## Production notes + +- **Presenter persona:** Callie Quartile was chosen because Shop for Data and Subscribe fit a data scientist's day. Other Coco personas that would work: Peter Profile (information specialist, and a natural fit for "My Profile"), Tessa Tube (lead researcher), or Jules Keeper as a cameo in the opening. Check the names and roles against the current Coco Pharmaceuticals docs. +- **Tone:** first person, warm, practical, a little wry. It's one colleague showing another, not a feature tour. Each feature should be tied to a job Callie actually needs to get done. +- **Screen capture:** use a large terminal font (≥18pt) and a dark theme matching `my_profile.tcss`. Record at 1920×1080 and zoom in on the panel being discussed. Pause about half a second before each keypress so viewers can follow. +- **Demo data:** record against the Coco Pharmaceuticals demo data, so the teams, roles, and data products shown match the rest of the series. Pre-create a few to-dos, blog and journal entries, and one subscribable data product. +- **Captions:** burn in the keyboard shortcuts (`Ctrl+T`, `Ctrl+S`, `Ctrl+B`, `s`, `u`) as they're used. +- **Before recording:** check every step against the current build, especially Change User, Create Profile, and Subscribe, since they've changed recently. +- **LinkedIn post (draft):** + > Meet My Profile 👋 A terminal app for Egeria users that shows your teams, roles, projects, and to-dos in one place, and lets you shop for governed data and subscribe to it in a few keystrokes. Watch Callie Quartile from Coco Pharmaceuticals take it for a spin: [YouTube link] #Egeria #OpenMetadata #DataGovernance #LFAIData diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/elements_crud_handler.py b/my_egeria/my_egeria/DemoCode/My_Profile/elements_crud_handler.py index d8bb1ea6..b8cc2123 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/elements_crud_handler.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/elements_crud_handler.py @@ -23,7 +23,6 @@ from pyegeria import Egeria, PyegeriaException from EditElementsScreens import ( - EditProfileScreen, EditCollectionsScreen, EditCommunitiesScreen, EditIdentitiesScreen, @@ -238,13 +237,19 @@ async def delete_element(self, source_table: str, guid: str) -> bool: finally: client.close_session() - def show_comments(self, table_name: str, row_k: Any) -> None: - """Show comments for the selected table.""" - self.log(f"Showing comments for table: {table_name}, row: {row_k}") + def show_comments(self, table_name: str | None = None, row_k: Any = None, + selected_comment_guid: str | None = None) -> None: + """Show comments for the selected table row, or the responses to a selected comment. + + When selected_comment_guid is given, the screen shows the comments attached to that + comment (its responses) and Ctrl+A adds a new response to it. + """ + self.log(f"Showing comments for table: {table_name}, row: {row_k}, comment: {selected_comment_guid}") self.push_screen( ShowCommentsScreen( table_name=table_name, table_row=row_k, + selected_comment_guid=selected_comment_guid, ), callback=self.show_comments_callback, ) @@ -255,6 +260,11 @@ def show_comments_callback(self, return_c: Any) -> None: if return_c is None: self.log("No return from ShowCommentsScreen") self._show_main_screen() + elif isinstance(return_c, list) and len(return_c) == 2 and return_c[0] == 250: + # User asked to respond to a comment: reopen the screen focused on that comment + self.show_comments(selected_comment_guid=return_c[1]) + elif return_c == 200: + self._show_main_screen() else: self.log(f"Unexpected return type from ShowCommentsScreen: {type(return_c)}") self._show_main_screen() diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/feedback_handler.py b/my_egeria/my_egeria/DemoCode/My_Profile/feedback_handler.py new file mode 100644 index 00000000..c44688b6 --- /dev/null +++ b/my_egeria/my_egeria/DemoCode/My_Profile/feedback_handler.py @@ -0,0 +1,169 @@ +""" + PDX-License-Identifier: Apache-2.0 + Copyright Contributors to the ODPi Egeria project. + + App-wide feedback handler mixin for My Profile Textual App. + + Feedback left from any screen is accumulated as Notes in a single NoteLog owned by + FEEDBACK_LOG_OWNER. The log is found by its fixed qualified name, so any user can + add to it; only the owner can open it from within the app. + + Note: the Egeria server itself does not restrict who can read the log (the + quickstart deployment enforces no zone security), so "private" is enforced by + this app, not by Egeria. +""" + +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +# Ensure project root is on sys.path +root_path = Path(__file__).resolve().parents[4] +if str(root_path) not in sys.path: + sys.path.append(str(root_path)) + +current_dir = Path(__file__).resolve().parent +if str(current_dir) not in sys.path: + sys.path.append(str(current_dir)) + +from pyegeria import Egeria, PyegeriaException +from FeedbackScreens import FeedbackScreen, FeedbackLogScreen + +FEEDBACK_LOG_OWNER = "garygeeke" +FEEDBACK_LOG_QN = f"NoteLog::{FEEDBACK_LOG_OWNER}::My-Profile-App-Feedback" +FEEDBACK_LOG_NAME = "My Profile App Feedback" + + +class FeedbackMixin: + """Mixin class providing the app-wide feedback mechanism for MyProfileApp.""" + + def _feedback_client(self) -> Egeria: + client = Egeria(self.view_server, self.platform_url, self.user_name, self.user_password) + client.create_egeria_bearer_token(self.user_name, self.user_password) + return client + + async def action_feedback(self) -> None: + """Open the feedback form for whichever screen is currently active.""" + current = self.screen + if isinstance(current, (FeedbackScreen, FeedbackLogScreen)): + return + screen_name = type(current).__name__ + screen_title = str(getattr(current, "sub_title", "") or "") + await self.push_screen(FeedbackScreen(screen_name, screen_title), callback=self.feedback_callback) + + async def feedback_callback(self, feedback: Any) -> None: + """Record the feedback returned from the FeedbackScreen in the feedback log.""" + if not isinstance(feedback, dict): + self.log("Feedback cancelled") + return + client = None + try: + client = self._feedback_client() + log_guid = await self._get_or_create_feedback_log(client) + if not log_guid: + self.notify("Unable to locate or create the feedback log, feedback not saved", + timeout=10, severity="error") + return + note_guid = await client._async_create_note(log_guid, body=self._build_feedback_note(log_guid, feedback)) + self.log(f"Feedback recorded as note: {note_guid}") + self.notify("Thank you, your feedback has been recorded") + except PyegeriaException as e: + self.log(f"Failed to record feedback: {e}") + self.notify(f"Failed to record feedback: {e}", timeout=10, severity="error") + finally: + if client: + client.close_session() + + def _build_feedback_note(self, log_guid: str, feedback: dict) -> dict: + timestamp = datetime.now().isoformat(timespec="seconds") + return { + "class": "NoteProperties", + "typeName": "Note", + "qualifiedName": f"Note::{log_guid}::{self.user_name}::{timestamp}", + "displayName": f"{feedback.get('category')} - {feedback.get('screen')}", + "description": feedback.get("text"), + "additionalProperties": { + "category": str(feedback.get("category") or ""), + "screen": str(feedback.get("screen") or ""), + "screenTitle": str(feedback.get("screen_title") or ""), + "submittedBy": self.user_name, + "submittedAt": timestamp, + }, + } + + async def _find_feedback_log(self, client: Egeria) -> str | None: + response = await client._async_get_note_logs_by_name(FEEDBACK_LOG_QN, graph_query_depth=0, + output_format="JSON") + if isinstance(response, list) and response: + return response[0].get("elementHeader", {}).get("guid") + return None + + async def _get_or_create_feedback_log(self, client: Egeria) -> str | None: + """Return the feedback log's GUID, creating the log on first use. + + When the owner creates it, it is attached to their profile. If another user + gets there first the log is created standalone - it is still found by name. + """ + log_guid = await self._find_feedback_log(client) + if log_guid: + return log_guid + properties = { + "class": "NoteLogProperties", + "typeName": "NoteLog", + "qualifiedName": FEEDBACK_LOG_QN, + "displayName": FEEDBACK_LOG_NAME, + "description": f"Private log of feedback on the My Profile app, owned by {FEEDBACK_LOG_OWNER}.", + } + owner_guid = self.user_GUID if self.user_name == FEEDBACK_LOG_OWNER else None + if owner_guid: + body = {"class": "NewAttachmentRequestBody", "properties": properties} + else: + body = {"class": "NewElementRequestBody", "properties": properties} + log_guid = await client._async_create_note_log(element_guid=owner_guid, body=body) + self.log(f"Created feedback log: {log_guid}") + return log_guid + + async def action_view_feedback_log(self) -> None: + """Show the accumulated feedback - only available to the log's owner.""" + if self.user_name != FEEDBACK_LOG_OWNER: + self.notify("The feedback log is private", timeout=5, severity="warning") + return + if isinstance(self.screen, (FeedbackScreen, FeedbackLogScreen)): + return + client = None + try: + client = self._feedback_client() + entries = await self.get_feedback_entries(client) + except PyegeriaException as e: + self.log(f"Failed to retrieve feedback log: {e}") + self.notify(f"Failed to retrieve feedback log: {e}", timeout=10, severity="error") + return + finally: + if client: + client.close_session() + await self.push_screen(FeedbackLogScreen(entries)) + + async def get_feedback_entries(self, client: Egeria) -> list[dict]: + """Retrieve the notes in the feedback log, newest first.""" + log_guid = await self._find_feedback_log(client) + if not log_guid: + return [] + notes = await client._async_get_notes_for_note_log(log_guid, metadata_element_type_name="Note", + graph_query_depth=0, output_format="JSON") + if not isinstance(notes, list): + return [] + entries = [] + for note in notes: + props = note.get("properties") or {} + extra = props.get("additionalProperties") or {} + versions = (note.get("elementHeader") or {}).get("versions") or {} + entries.append({ + "date": extra.get("submittedAt") or versions.get("createTime", ""), + "submitted_by": extra.get("submittedBy") or versions.get("createdBy", ""), + "category": extra.get("category", ""), + "screen": extra.get("screen", ""), + "text": props.get("description", ""), + }) + entries.sort(key=lambda e: str(e["date"]), reverse=True) + return entries diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/my_profile.tcss b/my_egeria/my_egeria/DemoCode/My_Profile/my_profile.tcss index 8d24831f..96ecd1c7 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/my_profile.tcss +++ b/my_egeria/my_egeria/DemoCode/My_Profile/my_profile.tcss @@ -56,11 +56,17 @@ ModalScreen { grid-rows: auto auto 1fr auto; } +/* Header and Footer are docked, so the grid holds only the title and the container */ +FeedbackScreen, FeedbackLogScreen { + grid-rows: auto 1fr; + background: $background; +} + ModalScreen > Header, ModalScreen > Footer { column-span: 3; } -#edit_profile_title, #create_profile_title, #edit_teams_title, #edit_roles_title, #edit_communities_title, #edit_identities_title, #status_title, #generic_data_view_title, .span-3 { +#edit_profile_title, #create_profile_title, #edit_teams_title, #edit_roles_title, #edit_communities_title, #edit_identities_title, #status_title, #generic_data_view_title, #feedback_title, #feedback_log_title, .span-3 { column-span: 3; text-align: center; background: darkblue; @@ -93,6 +99,8 @@ ModalScreen > Header, ModalScreen > Footer { #status_container, #user_identities_container, #generic_data_view_container, +#feedback_container, +#feedback_log_container, #main_identities_container { column-span: 3; width: 100%; @@ -107,6 +115,7 @@ ModalScreen > Header, ModalScreen > Footer { #digital_product_catalog_table, #glossary_table, #generic_data_view_table, +#feedback_log_table, #projects_table, #roles_table, #teams_table, @@ -129,8 +138,9 @@ ModalScreen > Header, ModalScreen > Footer { } Button { - background: greenyellow; + /*background: greenyellow;*/ color: black; + border: solid white; } DataTable, TextArea, SelectionList, Tree, OptionList { @@ -191,4 +201,44 @@ Input { width: 100%; height: 1fr; border: solid white; -} \ No newline at end of file +} +#feedback_text { + width: 100%; + height: 10; + border: solid white; +} +#feedback_container Horizontal { + height: auto; +} +.SplashScreen { + align: center middle; /* 1. Center the entire card on the screen */ + background: rgba(0, 0, 0, 0.6); /* Dim the 3x3 grid behind it */ + } +#splash-card { + background: $panel; + border: thick $accent; + padding: 2 4; + width: 80; + height: 24; + /* 2. Vertically pushes layout content down and horizontally centers items */ + align: center bottom; + } +#splash-text { + text-align: center; + width: 100%; + margin-bottom: 2; +} + +#splash_buttons { + layout: horizontal; + align: center middle; + height: auto; + margin-bottom: 1; +} + +#splash_buttons Button { + margin: 0 1; /* Space out the two buttons side by side */ +} +.empty { + background: transparent; /* Invisible structural spacer */ + } \ No newline at end of file diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/my_profile_app.py b/my_egeria/my_egeria/DemoCode/My_Profile/my_profile_app.py index bef8f6ad..6b47c769 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/my_profile_app.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/my_profile_app.py @@ -8,6 +8,9 @@ import sys from pathlib import Path from typing import Any +import asyncio + +from textual.containers import ScrollableContainer # Add the project root to sys.path to allow running this script from any directory root_path = Path(__file__).resolve().parents[4] @@ -28,8 +31,9 @@ ) from textual import on from textual.app import App, ComposeResult +from textual.binding import Binding from textual.widgets import DataTable, OptionList, Header, Footer - +from SplashScreen import SplashScreen from CreateProfileScreen import CreateProfileScreen from EditElementsScreens import ( EditProfileScreen, @@ -70,35 +74,43 @@ ) from ViewSubscriptionsScreen import ViewSubscriptionsScreen from GenericDataViewScreen import GenericDataViewScreen, DataViewScreen - +from AddCommentScreen import AddCommentScreen from profile_utils import ( truncate_at_sequence, clean_structure, bools_to_strings, extract_glossary_terms, + check_request_serialization, ) from tech_types_handler import TechTypesMixin from shop_for_data_handler import ShopForDataMixin from team_roles_handler import TeamRolesMixin from elements_crud_handler import ElementsCrudMixin +from feedback_handler import FeedbackMixin, FEEDBACK_LOG_OWNER from MyBookMarksScreen import MyBookMarksScreen -class MyProfileApp(App, TechTypesMixin, ShopForDataMixin, TeamRolesMixin, ElementsCrudMixin): +class MyProfileApp(App, TechTypesMixin, ShopForDataMixin, TeamRolesMixin, ElementsCrudMixin, FeedbackMixin): """My Profile App. Retrieves a user's profile from Egeria and displays current work items. If no profile is found, offers a UI to create one. """ + # The feedback bindings are priority bindings: Textual ignores ordinary App bindings + # while a ModalScreen is active, and most screens in this app are modal. Priority also + # means ctrl+f wins over the Input/TextArea "delete word right" binding on the same key. BINDINGS = [ ("q", "quit", "Quit"), ("r", "refresh", "Refresh Data"), + Binding("ctrl+f", "feedback", "Feedback", priority=True), + Binding("f3", "view_feedback_log", "Feedback Log", priority=True), ] CSS_PATH = "my_profile.tcss" SCREENS = { + "splash": SplashScreen, "main": MainScreen, "create_profile": CreateProfileScreen, "edit_profile": EditProfileScreen, @@ -122,6 +134,7 @@ class MyProfileApp(App, TechTypesMixin, ShopForDataMixin, TeamRolesMixin, Elemen "create_subscription": CreateSubscriptionRequestScreen, "my_team": MyTeam, "show_comments": ShowCommentsScreen, + "add_comment": AddCommentScreen, "add_role": AddRoleScreen, "add_project": AddProjectScreen, "add_community": AddCommunityScreen, @@ -185,23 +198,68 @@ def __init__(self, *args, **kwargs): self.digital_glossary_data_extract = None self.team_members: list[list] = [] self.max_mermaid_node_count = 0 # This is to tell egeria we dont want mermaid graphs in the response packet. - self.graph_query_depth = 0 # This tell egeria not to include relationships in the response packet + self.graph_query_depth = 0 # This tells egeria not to include relationships in the response packet self.user_GUID = "" self.user_data = {} self.user_identities = [] self.user_identity = [] def compose(self) -> ComposeResult: - yield Header() + yield Header(show_clock=True) yield Footer() + def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: + """Only show the feedback log binding to the log's owner.""" + if action == "view_feedback_log": + return self.user_name == FEEDBACK_LOG_OWNER + return True + async def on_mount(self) -> None: - """Load profile; if missing, prompt to create it; then populate tables.""" + """Mount the main screen, start loading profile data in parallel, and display the splash screen.""" + environment_error = check_request_serialization() + if environment_error: + self.exit(430, return_code=1, message=environment_error) + return await self.push_screen("main") - await self._load_or_create_profile() - await self._populate_tables() - - async def _load_or_create_profile(self) -> None: + self._load_task = asyncio.create_task(self._load_profile_and_populate()) + await self.push_screen("splash", callback=self.mainline) + + async def mainline(self, splash_return: Any = None) -> None: + """Process any return values from the splash screen, then + ensure user profile is loaded and tables are populated.""" + if isinstance(splash_return, list): + if hasattr(self, "_load_task") and not self._load_task.done(): + self._load_task.cancel() + self.user_name = splash_return[0] + self.user_password = splash_return[1] + self.refresh_bindings() + await self._load_or_create_profile() + else: + # Splash screen finished (timeout or dismissed) + if hasattr(self, "_load_task"): + try: + profile_found = await self._load_task + except asyncio.CancelledError: + return + except Exception as e: + self.log(f"Error in parallel initial load: {e}") + profile_found = False + + if not profile_found and (not hasattr(self, "my_profile_data") or not self.my_profile_data): + self.log("No profile found. Prompting to create one...") + await self.push_screen( + CreateProfileScreen( + user=self.user_name, + password=self.user_password, + view_server=self.view_server, + platform_url=self.platform_url, + ), + callback=self.new_profile_return, + ) + + async def _load_profile_and_populate(self) -> bool: + """Retrieve the user's profile and populate UI tables in parallel. + Returns True if profile exists and tables were populated, False if profile is missing.""" try: self.my_profile_inst = MyProfile(self.view_server, self.platform_url, self.user_name, self.user_password) self.my_profile_inst.create_egeria_bearer_token(self.user_name, self.user_password) @@ -214,47 +272,34 @@ async def _load_or_create_profile(self) -> None: self.log(f"Error retrieving profile: {e!s}") print_basic_exception(e) self.exit(402) - return - - if self.my_profile_data == []: - self.log("Error retrieving profile. Prompting to create one...") - self.log("To create a profile you must have a valid userid in the system, please contact your system administrator to create one if needed") - await self.push_screen( - CreateProfileScreen(), - callback=self.new_profile_return, - ) - else: - self.new_profile_return(200) + return False - def new_profile_return(self, result: int) -> None: - """This function handles either the return from the create new profile screen or - when the user already has a profile continue processing.""" - self.log(f"Profile creation result: {result}") - if not result or result != 200: - self.log(f"Profile creation cancelled/failed; return: {result}, exiting.") - self.exit(403) - return + if not self.my_profile_data: + self.log("No profile found for user.") + return False - self.result = result + self._process_profile_data(self.my_profile_data) + await self._populate_tables() + return True - # Retry after creation if necessary - try: - self.user_profile_struct = self.my_profile_inst.get_my_profile( - output_format="DICT", - report_spec="My-User-MD", + async def _load_or_create_profile(self) -> None: + """Load user profile; if missing, prompt to create it.""" + profile_found = await self._load_profile_and_populate() + if not profile_found and (not hasattr(self, "my_profile_data") or not self.my_profile_data): + self.log("No profile found. Prompting to create one...") + await self.push_screen( + CreateProfileScreen( + user=self.user_name, + password=self.user_password, + view_server=self.view_server, + platform_url=self.platform_url, + ), + callback=self.new_profile_return, ) - self.log(f"Profile retrieved successfully: {self.user_profile_struct}") - self.show_main_screen() - except PyegeriaException as e2: - self.log(f"Error retrieving user profile: {e2!s}") - self.exit(412) - return - - if not self.user_profile_struct or self.user_profile_struct == []: - self.log("Error retrieving user profile. Exiting.") - self.exit(413) - return + def _process_profile_data(self, profile_struct: list[dict]) -> None: + """Parse raw profile structure and extract individual elements.""" + self.user_profile_struct = profile_struct # clear the target data structures. self.my_blogs_data = [{}] self.my_journal_data = [{}] @@ -360,7 +405,39 @@ def new_profile_return(self, result: int) -> None: else: self.user_identity = self.user_identities.get("User-Identities") or [] - async def _populate_tables(self) -> Any: + def new_profile_return(self, result: int) -> None: + """This function handles either the return from the create new profile screen or + when the user already has a profile continue processing.""" + self.log(f"Profile creation result: {result}") + if not result or result != 200: + self.log(f"Profile creation cancelled/failed; return: {result}, exiting.") + self.exit(403) + return + + self.result = result + + # Retry after creation if necessary + try: + self.user_profile_struct = self.my_profile_inst.get_my_profile( + output_format="DICT", + report_spec="My-User-MD", + ) + self.log(f"Profile retrieved successfully: {self.user_profile_struct}") + self.show_main_screen() + except PyegeriaException as e2: + self.log(f"Error retrieving user profile: {e2!s}") + self.exit(412) + return + + if not self.user_profile_struct or self.user_profile_struct == []: + self.log("Error retrieving user profile. Exiting.") + self.exit(413) + return + + self._process_profile_data(self.user_profile_struct) + self._populate_tables_sync() + + def _populate_tables_sync(self) -> Any: """Populates tables from normalized profile data.""" main_screen = self.get_screen("main") @@ -388,62 +465,62 @@ async def _populate_tables(self) -> Any: self.projects_table.add_columns("Status or Type", "Name", "Description", "GUID") self.projects_table.zebra_stripes = True self.projects_table.cursor_type = "row" - self.projects_table.loading=True + self.projects_table.loading = True if self.communities_table: self.communities_table.clear(columns=True) self.communities_table.add_columns("Assignment Type", "Community Name", "Description", "GUID") self.communities_table.zebra_stripes = True self.communities_table.cursor_type = "row" - self.communities_table.loading=True + self.communities_table.loading = True self.digital_product_catalog_table: DataTable = DataTable(id="digital_product_catalog_table") self.digital_product_catalog_table.add_columns("Digital Product Catalog Name", "Description", "Qualified Name", "GUID") self.digital_product_catalog_table.cursor_type = "row" self.digital_product_catalog_table.zebra_stripes = True - self.digital_product_catalog_table.loading=True + self.digital_product_catalog_table.loading = True self.roles_table.clear(columns=True) self.roles_table.add_columns("Role Name", "Role Type", "Description", "GUID") self.roles_table.zebra_stripes = True self.roles_table.cursor_type = "row" - self.roles_table.loading=True + self.roles_table.loading = True self.teams_table.clear(columns=True) self.teams_table.add_columns("Assignment Type", "Team Name", "Description", "GUID") self.teams_table.zebra_stripes = True self.teams_table.cursor_type = "row" - self.teams_table.loading=True + self.teams_table.loading = True self.blogs_table.clear(columns=True) self.blogs_table.add_columns("Blog Title", "Date", "Text", "GUID") self.blogs_table.zebra_stripes = True self.blogs_table.cursor_type = "row" - self.blogs_table.loading=True + self.blogs_table.loading = True self.journal_table.clear(columns=True) self.journal_table.add_columns("Journal Entry", "Date", "Text", "GUID") self.journal_table.zebra_stripes = True self.journal_table.cursor_type = "row" - self.journal_table.loading=True + self.journal_table.loading = True self.todos_table.clear(columns=True) self.todos_table.add_columns("To-Do Name", "Activity Status", "Description", "GUID") self.todos_table.zebra_stripes = True self.todos_table.cursor_type = "row" - self.todos_table.loading=True + self.todos_table.loading = True self.user_identity_table.clear(columns=True) self.user_identity_table.add_columns("Display Name", "User ID", "Distinguished Name", "GUID") self.user_identity_table.zebra_stripes = True self.user_identity_table.cursor_type = "row" - self.user_identity_table.loading=True + self.user_identity_table.loading = True self.associations_table.clear(columns=True) self.associations_table.add_columns("Status or Type", "Name", "Description", "GUID") self.associations_table.zebra_stripes = True self.associations_table.cursor_type = "row" - self.associations_table.loading=True + self.associations_table.loading = True self.my_collections_table.clear(columns=True) self.my_collections_table.add_columns("Collection Name", "Collection Description", "Collection GUID") @@ -459,7 +536,7 @@ async def _populate_tables(self) -> Any: str(p.get("Description", "")), str(p.get("GUID", p.get("guid", ""))), ) - self.projects_table.loading=False + self.projects_table.loading = False if self.communities_table: for c in self.communities if isinstance(self.communities, list) else []: self.communities_table.add_row( @@ -468,7 +545,7 @@ async def _populate_tables(self) -> Any: str(c.get("Description", "")), str(c.get("GUID", c.get("guid", ""))), ) - self.communities_table.loading=False + self.communities_table.loading = False for r in self.roles if isinstance(self.roles, list) else []: self.roles_table.add_row( str(r.get("Name", "")), @@ -476,7 +553,7 @@ async def _populate_tables(self) -> Any: str(r.get("Description", "")), str(r.get("GUID", r.get("guid", ""))), ) - self.roles_table.loading=False + self.roles_table.loading = False for t in self.teams if isinstance(self.teams, list) else []: self.teams_table.add_row( str(t.get("Assignment Type", "")), @@ -484,15 +561,15 @@ async def _populate_tables(self) -> Any: str(t.get("Description", "")), str(t.get("GUID", t.get("guid", ""))), ) - self.teams_table.loading=False + self.teams_table.loading = False for b in self.blogs if isinstance(self.blogs, list) else []: self.blogs_table.add_row( str(b.get("qualifiedName", "")), str(b.get("time", "")), str(b.get("text", "")), str(b.get("GUID", "")), - ) - self.blogs_table.loading=False + ) + self.blogs_table.loading = False for j in self.journal if isinstance(self.journal, list) else []: self.journal_table.add_row( str(j.get("qualifiedName", "")), @@ -500,7 +577,7 @@ async def _populate_tables(self) -> Any: str(j.get("text", "")), str(j.get("GUID", j.get("guid", ""))), ) - self.journal_table.loading=False + self.journal_table.loading = False for td in self.todos if isinstance(self.todos, list) else []: self.todos_table.add_row( str(td.get("Name", "")), @@ -508,7 +585,7 @@ async def _populate_tables(self) -> Any: str(td.get("Description", "")), str(td.get("GUID", td.get("guid", ""))), ) - self.todos_table.loading=False + self.todos_table.loading = False for ui in self.user_identity if isinstance(self.user_identity, list) else []: self.user_identity_table.add_row( str(ui.get("Display Name", "")), @@ -516,7 +593,7 @@ async def _populate_tables(self) -> Any: str(ui.get("Distinguished Name", "")), str(ui.get("GUID", ui.get("guid", ""))), ) - self.user_identity_table.loading=False + self.user_identity_table.loading = False for c in self.communities if isinstance(self.communities, list) else []: self.associations_table.add_row( str(c.get("Assignment Type", "")), @@ -524,7 +601,10 @@ async def _populate_tables(self) -> Any: str(c.get("Description", "")), str(c.get("GUID", c.get("guid", ""))), ) - self.associations_table.loading=False + self.associations_table.loading = False + + async def _populate_tables(self) -> Any: + return self._populate_tables_sync() def action_quit(self) -> Any: self.exit(200) @@ -565,6 +645,10 @@ async def handle_option_selected(self, event: OptionList.OptionSelected) -> None self.show_my_bookmarks() elif selected_option == "Subscriptions": await self.push_screen(ViewSubscriptionsScreen(), callback=self.view_subscriptions_callback) + elif selected_option == "Leave Feedback": + await self.action_feedback() + elif selected_option == "Feedback Log": + await self.action_view_feedback_log() def status_callback(self, status_callback_rc: Any) -> None: """Callback routine from the status screen.""" @@ -781,6 +865,118 @@ def delete_my_bookmark(self, target_guid) -> None: if 'eclient' in locals(): eclient.close_session() + def add_comment(self, table_name, table_row): + self.table_name = table_name + self.table_row = table_row + main_screen = self.get_screen("main") + try: + table = main_screen.query_one("#"+self.table_name, DataTable) + table_row = table.get_row(self.table_row) + target_heading = "GUID" + column_key = None + # find the column containing the GUID + for key, column in table.columns.items(): + if column.label.plain == target_heading: + self.log(f"GUID column found: {column.label.plain}") + column_key = key + break + + if column_key: + # If GUID is found, use the row and column to get the selected row's GUID + self.log(f"GUID found in table: row: {table_row}, column: {column_key}") + else: + # No GUID in table so look for Qualified Name to use instead + target_heading = "Qualified Name" + column_key = None + for key, column in table.columns.items(): + if column.label.plain == target_heading: + self.log(f"Qualified Name column found: {column.label.plain}") + column_key = key + break + + if column_key: + self.log (f"Row_key is : {self.table_row}") + self.log(f"Column key is: {column_key}") + row_id = table.get_cell(self.table_row, column_key) + self.log(f"Row ID is: {row_id}") + else: + self.notify(f"Error retrieving row from table to add comment, no GUID or Qualified Name column found", severity="error", timeout=30) + self.log(f"Column_key is: {column_key} not valid to access Egeria") + return (400) + + if row_id == "": + self.log(f"Row ID is: {row_id}, not valid to access Egeria") + return (400) + + except PyegeriaException as e: + self.notify(f"Error retrieving row from table to add comment: {e}", severity="error", timeout=30) + return(400) + + self.push_screen(AddCommentScreen(table_name = self.table_name, + element_guid = row_id), + callback = self.add_comment_callback) + + def add_comment_callback(self, comment_content): + """ Process feedback from thew add comment functions and then return to main screen""" + # If the return is an integer, then either success or failure, return to main screen + self.log(f"Return from Add Comment Screen: {comment_content}, length: {len(comment_content)}") + if isinstance(comment_content, int): + self.switch_screen(MainScreen()) + return + # Check that all required data has been returned (Comment, Type, and the GUID of the element the comment applies to) + if len(comment_content) != 3: + self.notify("Return from Add Comment Screen incomplete, please retry", severity="error", timeout=30) + self.log(f"Return from Add Comment Screen: {comment_content}, length: {len(comment_content)}") + self.switch_screen(MainScreen()) + return + # We have 3 return data items so extract them to individual variables + self.comment = comment_content[0] + self.type = comment_content[1] + self.element_guid = comment_content[2] + # Make sure we really have all the required data + if not self.comment or not self.type or not self.element_guid: + self.notify("A row must be selected and Both comment text and comment type are required!") + self.log(f"One or more element missing: {comment_content}, length: {len(comment_content)}") + self.switch_screen(MainScreen()) + return + # We have all the required data, then try to create the Egeria client + try: + eclient = Egeria(self.view_server, + self.platform_url, + self.user_name, + self.user_password) + eclient.create_egeria_bearer_token(self.user_name, self.user_password) + except PyegeriaException as e: + self.log(f"Error creating Egeria client: {e}") + self.notify( + "Unable to connect to the Egeria Server, please check the server is running and your credentials and try again", + timeout=10, + severity="error") + return + # If we get here, we have a valid Egeria client, so try to add the comment + try: + # Add comment to selected element + self.comment_type = self.type.upper() + self.log(f"Adding comment to element: {self.element_guid}, values: {self.comment}, {self.comment_type}") + response = eclient.add_comment_to_element( + element_guid=self.element_guid, + comment=self.comment, + comment_type=self.comment_type + ) + self.log(f"Comment successfully added! New Comment GUID: {response}") + self.notify(f"Comment successfully added! New Comment GUID: {response}") + container = (self.query_one("#show_comments_container", ScrollableContainer)) + container.remove_children() + except Exception as e: + self.log(f"Failed to add comment: {e}") + self.notify(f"Failed to add comment: {e} \n Please try again.") + return + finally: + if eclient: + eclient.close_session() + self.switch_screen(MainScreen()) + return(200) + # Compatibility wrappers delegating to profile_utils def clean_structure(self, data: Any, target: str = "specificationMermaidGraph") -> Any: return clean_structure(data, target) diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/profile_utils.py b/my_egeria/my_egeria/DemoCode/My_Profile/profile_utils.py index 0c80335c..1d4f804b 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/profile_utils.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/profile_utils.py @@ -6,8 +6,32 @@ """ import re +import sys from typing import Any +# pyegeria's declared minimum (pyproject.toml); older pydantic ignores serialize_by_alias +MIN_PYDANTIC_VERSION = "2.12.3" + + +def check_request_serialization() -> str | None: + """Return an error message if pyegeria request bodies won't serialize the way Egeria expects. + + Under an older pydantic, request bodies go out with Python field names + ("filter_string") rather than Egeria's ("filter"), so Egeria silently sees + empty parameters. This happens when the app is run by a Python other than + the project venv, e.g. a global `textual run`. + """ + import pydantic + from pyegeria.models.models import FilterRequestBody + + body = FilterRequestBody.model_validate({"class": "FilterRequestBody", "filter": "check"}) + if '"filter":"check"' in body.model_dump_json(exclude_none=True): + return None + return (f"This Python ({sys.executable}) has pydantic {pydantic.VERSION}, but pyegeria needs " + f"pydantic >= {MIN_PYDANTIC_VERSION} - requests to Egeria would lose their parameters.\n" + "Run the app from the project venv instead: `source .venv/bin/activate` in the egeria-python " + "folder (then `textual run my_profile_app.py --dev`), or `uv run python my_profile_app.py`.") + def truncate_at_sequence(data: Any, target: str = "specificationMermaidGraph") -> tuple[Any, bool]: """ diff --git a/pyproject.toml b/pyproject.toml index 6551d9ad..6f36bf9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -205,6 +205,7 @@ dev = [ "pytest>=8.4.2", "wheel>=0.45.1", "my-egeria", + "textual-dev>=1.8.0", ] [build-system] diff --git a/tests/micro-tests/my_profile/conftest.py b/tests/micro-tests/my_profile/conftest.py index 77cc2d20..ccc80207 100644 --- a/tests/micro-tests/my_profile/conftest.py +++ b/tests/micro-tests/my_profile/conftest.py @@ -221,7 +221,6 @@ def live_template_placeholders(backend, live_catalog_template): values[f"{name.replace(' ', '_')}_placeholder_input"] = value return values - @pytest.fixture def sample_profile_data(): """Sample full profile dictionary returned from Egeria.""" diff --git a/tests/micro-tests/my_profile/test_feedback_handler.py b/tests/micro-tests/my_profile/test_feedback_handler.py new file mode 100644 index 00000000..c4cf41ae --- /dev/null +++ b/tests/micro-tests/my_profile/test_feedback_handler.py @@ -0,0 +1,242 @@ +""" + PDX-License-Identifier: Apache-2.0 + Copyright Contributors to the ODPi Egeria project. + + Unit tests for the app-wide feedback mechanism (feedback_handler + FeedbackScreens). + + Tests marked `live_capable` run against fakes by default and against a real + Egeria view server when PYEG_LIVE_EGERIA=1 is set. +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from textual.app import App +from textual.widgets import DataTable, TextArea + +from feedback_handler import FeedbackMixin, FEEDBACK_LOG_OWNER, FEEDBACK_LOG_QN +from FeedbackScreens import FeedbackScreen, FeedbackLogScreen +from pyegeria import PyegeriaException + + +class DummyFeedbackApp(FeedbackMixin): + """Test harness implementing FeedbackMixin.""" + + def __init__(self, backend=None, user_name=FEEDBACK_LOG_OWNER): + self.pushed_screens = [] + self.notifications = [] + self.log_messages = [] + self.user_name = user_name + self.user_password = "secret" + self.view_server = "qs-view-server" + self.platform_url = "https://127.0.0.1:9443" + if backend is not None: + backend.apply_connection(self) + self.user_name = user_name if not backend.live else backend.user_id + self.user_GUID = "profile-guid-123" + self.screen = MagicMock() + + def log(self, msg, *args, **kwargs): + self.log_messages.append(str(msg)) + + def notify(self, msg, *args, **kwargs): + self.notifications.append((str(msg), kwargs.get("severity"))) + + async def push_screen(self, screen, callback=None): + self.pushed_screens.append((screen, callback)) + + +def fake_client(existing_log_guid=None, notes=None): + """An Egeria client stand-in whose feedback calls are AsyncMocks.""" + client = MagicMock() + client._async_get_note_logs_by_name = AsyncMock( + return_value=[{"elementHeader": {"guid": existing_log_guid}}] if existing_log_guid else "No elements found" + ) + client._async_create_note_log = AsyncMock(return_value="new-log-guid") + client._async_create_note = AsyncMock(return_value="note-guid") + client._async_get_notes_for_note_log = AsyncMock(return_value=notes if notes is not None else "No elements found") + return client + + +FEEDBACK = {"screen": "MainScreen", "screen_title": "My Profile", "category": "Bug", "text": "Table is empty"} + + +class TestFeedbackCallback: + + async def test_cancelled_feedback_makes_no_egeria_call(self, backend): + app = DummyFeedbackApp() + egeria = backend.always_fake("feedback_handler.Egeria") + await app.feedback_callback(None) + egeria.assert_not_called() + + async def test_creates_log_attached_to_owner_profile_on_first_use(self, backend): + app = DummyFeedbackApp() + client = fake_client() + backend.always_fake("feedback_handler.Egeria", returns=client) + + await app.feedback_callback(FEEDBACK) + + client._async_get_note_logs_by_name.assert_awaited_once() + assert client._async_get_note_logs_by_name.await_args.args[0] == FEEDBACK_LOG_QN + create_kwargs = client._async_create_note_log.await_args.kwargs + assert create_kwargs["element_guid"] == "profile-guid-123" + assert create_kwargs["body"]["class"] == "NewAttachmentRequestBody" + assert create_kwargs["body"]["properties"]["qualifiedName"] == FEEDBACK_LOG_QN + + log_guid = client._async_create_note.await_args.args[0] + note = client._async_create_note.await_args.kwargs["body"] + assert log_guid == "new-log-guid" + assert note["class"] == "NoteProperties" + assert note["description"] == "Table is empty" + assert note["additionalProperties"]["category"] == "Bug" + assert note["additionalProperties"]["screen"] == "MainScreen" + assert note["additionalProperties"]["submittedBy"] == FEEDBACK_LOG_OWNER + client.close_session.assert_called_once() + assert app.notifications[-1][0].startswith("Thank you") + + async def test_existing_log_is_reused(self, backend): + app = DummyFeedbackApp() + client = fake_client(existing_log_guid="existing-log") + backend.always_fake("feedback_handler.Egeria", returns=client) + + await app.feedback_callback(FEEDBACK) + + client._async_create_note_log.assert_not_awaited() + assert client._async_create_note.await_args.args[0] == "existing-log" + + async def test_other_user_creates_standalone_log(self, backend): + app = DummyFeedbackApp(user_name="erinoverview") + client = fake_client() + backend.always_fake("feedback_handler.Egeria", returns=client) + + await app.feedback_callback(FEEDBACK) + + create_kwargs = client._async_create_note_log.await_args.kwargs + assert create_kwargs["element_guid"] is None + assert create_kwargs["body"]["class"] == "NewElementRequestBody" + assert client._async_create_note.await_args.kwargs["body"]["additionalProperties"]["submittedBy"] == "erinoverview" + + async def test_egeria_error_is_reported(self, backend): + app = DummyFeedbackApp() + client = fake_client(existing_log_guid="existing-log") + client._async_create_note.side_effect = PyegeriaException("boom") + backend.always_fake("feedback_handler.Egeria", returns=client) + + await app.feedback_callback(FEEDBACK) + + assert app.notifications[-1][1] == "error" + client.close_session.assert_called_once() + + +class TestFeedbackLog: + + async def test_log_is_private_to_owner(self, backend): + app = DummyFeedbackApp(user_name="erinoverview") + egeria = backend.always_fake("feedback_handler.Egeria") + await app.action_view_feedback_log() + egeria.assert_not_called() + assert app.pushed_screens == [] + assert app.notifications[-1] == ("The feedback log is private", "warning") + + async def test_entries_are_parsed_newest_first(self, backend): + app = DummyFeedbackApp() + notes = [ + {"elementHeader": {"versions": {"createTime": "t1", "createdBy": "x"}}, + "properties": {"description": "older", "additionalProperties": { + "submittedAt": "2026-09-01T10:00:00", "submittedBy": "garygeeke", + "category": "Bug", "screen": "MainScreen"}}}, + {"elementHeader": {"versions": {"createTime": "2026-09-02T10:00:00", "createdBy": "erinoverview"}}, + "properties": {"description": "newer"}}, + ] + client = fake_client(existing_log_guid="existing-log", notes=notes) + backend.always_fake("feedback_handler.Egeria", returns=client) + + await app.action_view_feedback_log() + + screen, _ = app.pushed_screens[0] + assert isinstance(screen, FeedbackLogScreen) + assert [e["text"] for e in screen.feedback_entries] == ["newer", "older"] + assert screen.feedback_entries[0]["submitted_by"] == "erinoverview" + assert screen.feedback_entries[1]["category"] == "Bug" + + async def test_missing_log_gives_empty_list(self, backend): + app = DummyFeedbackApp() + client = fake_client() + assert await app.get_feedback_entries(client) == [] + client._async_get_notes_for_note_log.assert_not_awaited() + + @pytest.mark.live_capable + async def test_feedback_round_trip(self, backend): + """Submitted feedback shows up in the log (really written in live mode).""" + if not backend.live: + pytest.skip("round trip only meaningful against a live server") + from pyegeria import MyProfile + + app = DummyFeedbackApp(backend) + profile_client = MyProfile(backend.view_server, backend.platform_url, backend.user_id, backend.user_pwd) + profile_client.create_egeria_bearer_token(backend.user_id, backend.user_pwd) + profile = await profile_client._async_get_my_profile(output_format="JSON") + app.user_GUID = (profile[0] if isinstance(profile, list) else profile)["elementHeader"]["guid"] + + text = backend.unique("Round trip feedback") + await app.feedback_callback({**FEEDBACK, "text": text}) + client = app._feedback_client() + entries = await app.get_feedback_entries(client) + client.close_session() + assert text in [e["text"] for e in entries] + + +class FeedbackHostApp(App): + + def __init__(self, screen_factory): + super().__init__() + self.screen_factory = screen_factory + self.target_screen = None + self.dismissed_result = "not dismissed" + + async def on_mount(self): + self.target_screen = self.screen_factory() + + def _cb(result): + self.dismissed_result = result + + await self.push_screen(self.target_screen, callback=_cb) + + +class TestFeedbackScreens: + + async def test_empty_feedback_is_not_submitted(self): + app = FeedbackHostApp(lambda: FeedbackScreen("MainScreen", "My Profile")) + async with app.run_test() as pilot: + await pilot.pause() + app.target_screen.action_submit() + await pilot.pause() + assert app.dismissed_result == "not dismissed" + + async def test_feedback_is_returned(self): + app = FeedbackHostApp(lambda: FeedbackScreen("MainScreen", "My Profile")) + async with app.run_test() as pilot: + await pilot.pause() + app.target_screen.query_one("#feedback_text", TextArea).text = "Great app" + await pilot.press("ctrl+s") + await pilot.pause() + assert app.dismissed_result == { + "screen": "MainScreen", "screen_title": "My Profile", "category": "Suggestion", "text": "Great app", + } + + async def test_cancel_returns_none(self): + app = FeedbackHostApp(lambda: FeedbackScreen("MainScreen")) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("escape") + await pilot.pause() + assert app.dismissed_result is None + + async def test_log_screen_shows_entries(self): + entries = [{"date": "d", "submitted_by": "garygeeke", "category": "Bug", "screen": "MainScreen", "text": "t"}] + app = FeedbackHostApp(lambda: FeedbackLogScreen(entries)) + async with app.run_test() as pilot: + await pilot.pause() + table = app.target_screen.query_one("#feedback_log_table", DataTable) + assert table.row_count == 1 + assert table.get_row_at(0) == ["d", "garygeeke", "Bug", "MainScreen", "t"] diff --git a/tests/micro-tests/my_profile/test_my_profile_app.py b/tests/micro-tests/my_profile/test_my_profile_app.py index fa89969e..87f9545c 100644 --- a/tests/micro-tests/my_profile/test_my_profile_app.py +++ b/tests/micro-tests/my_profile/test_my_profile_app.py @@ -10,12 +10,13 @@ from unittest.mock import MagicMock, AsyncMock, patch, PropertyMock import pytest -from textual.widgets import OptionList, DataTable +from textual.widgets import Button, OptionList, DataTable from textual.widgets._option_list import Option from my_profile_app import MyProfileApp from MainScreen import MainScreen from CreateProfileScreen import CreateProfileScreen +from SplashScreen import SplashScreen from UserIdentitiesScreen import UserIdentitiesScreen from EditElementsScreens import EditProfileScreen from egeria_backend import at_least, is_int, nonempty_str @@ -104,7 +105,26 @@ async def test_app_on_mount_prompt_create_profile(self, backend): app = MyProfileApp() async with app.run_test() as pilot: + # Wait for the splash screen to be pushed. Match on type: the screen + # itself has no id (id="splash" is on its Header widget). + for _ in range(10): + if isinstance(app.screen, SplashScreen): + break + await pilot.pause(0.1) + assert isinstance(app.screen, SplashScreen) + + # "Continue to App" dismisses with None -> mainline's no-new-user branch + # (pressed directly: at the default 80x24 test size the button is off-screen) + app.screen.query_one("#continue", Button).press() await pilot.pause() + + # Wait for the async task to finish and the callback to be processed + # and the new screen to be pushed. + for _ in range(50): + if isinstance(app.screen, CreateProfileScreen): + break + await pilot.pause(0.1) + assert isinstance(app.screen, CreateProfileScreen) @pytest.mark.asyncio diff --git a/tests/micro-tests/my_profile/test_profile_utils.py b/tests/micro-tests/my_profile/test_profile_utils.py index 5550eeba..d2d6d866 100644 --- a/tests/micro-tests/my_profile/test_profile_utils.py +++ b/tests/micro-tests/my_profile/test_profile_utils.py @@ -11,6 +11,7 @@ clean_structure, bools_to_strings, extract_glossary_terms, + check_request_serialization, ) @@ -181,3 +182,20 @@ def test_extract_terms_with_whitespace_and_quotes(self): terms = extract_glossary_terms(text) assert len(terms) == 2 assert terms == ["Hospital Patient", "Medication Prescription"] + + +class TestCheckRequestSerialization: + """Tests for check_request_serialization function.""" + + def test_project_environment_passes(self): + assert check_request_serialization() is None + + def test_python_field_names_are_reported(self, monkeypatch): + # Simulate pydantic < 2.11, which ignores serialize_by_alias + from pyegeria.models.models import FilterRequestBody + monkeypatch.setattr(FilterRequestBody, "model_dump_json", + lambda self, **kwargs: '{"class_":"FilterRequestBody","filter_string":"check"}') + message = check_request_serialization() + assert message is not None + assert "pydantic" in message + assert ".venv" in message diff --git a/uv.lock b/uv.lock index 1ee89432..ccf96748 100644 --- a/uv.lock +++ b/uv.lock @@ -1933,6 +1933,7 @@ test = [ dev = [ { name = "my-egeria" }, { name = "pytest" }, + { name = "textual-dev" }, { name = "wheel" }, ] @@ -1977,6 +1978,7 @@ provides-extras = ["test", "spec-editor"] dev = [ { name = "my-egeria", editable = "my_egeria" }, { name = "pytest", specifier = ">=8.4.2" }, + { name = "textual-dev", specifier = ">=1.8.0" }, { name = "wheel", specifier = ">=0.45.1" }, ] @@ -2398,6 +2400,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, ] +[[package]] +name = "textual-dev" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "click" }, + { name = "msgpack" }, + { name = "textual" }, + { name = "textual-serve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/fd/fd5ad9527b536c306a5a860a33a45e13983a59804b48b91ea52b42ba030a/textual_dev-1.8.0.tar.gz", hash = "sha256:7e56867b0341405a95e938cac0647e6d2763d38d0df08710469ad6b6a8db76df", size = 25026, upload-time = "2025-10-11T09:47:01.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/c6/1dc08ceee6d0bdf219c500cb2fbd605b681b63507c4ff27a6477f12f8245/textual_dev-1.8.0-py3-none-any.whl", hash = "sha256:227b6d24a485fbbc77e302aa21f4fdf3083beb57eb45cd95bae082c81cbeddeb", size = 27541, upload-time = "2025-10-11T09:47:00.531Z" }, +] + [[package]] name = "textual-serve" version = "1.1.3"