From 4b35912d96d52f453c878d5e5dc3174640b8ec35 Mon Sep 17 00:00:00 2001 From: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:53:07 -0500 Subject: [PATCH 01/14] feat(my_egeria): add experimental my_network script Co-authored-by: Junie Signed-off-by: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> --- .../DemoCode/Experimental Code/my_network.py | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py diff --git a/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py b/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py new file mode 100644 index 00000000..f775b700 --- /dev/null +++ b/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py @@ -0,0 +1,177 @@ +""" + PDX-License-Identifier: Apache-2.0 + Copyright Contributors to the ODPi Egeria project. + + This file provides the main entry point and core lifecycle for the My Profile Textual App. +""" + +import sys +from pathlib import Path +from typing import Any +import asyncio + +from textual import on +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import ScrollableContainer +from textual.widgets import Header, Footer, Static, Placeholder, Input, DataTable + +# Add the project root to sys.path to allow running this script from any directory +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 ( + load_app_config, + settings, + MyProfile, + PyegeriaException, + print_basic_exception, + exec_report_spec, Egeria, +) + +class MyNetworkApp(App): + """My Network App. + + Finds user ids based on a search parameter to allow the current user to add the user(s) + found to their private "peer network" in their profile in Egeria. + """ + + 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" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.title = "My_Network" + self.sub_title = "Egeria network of contacts for the current user" + self.description = "Display the user's network of (Egeria) contacts." + load_app_config() + app_config = settings.Environment + self.log(f"Application Config: {app_config}") + app_user = settings.User_Profile + self.log(f"User Profile: {app_user}") + self.user_name = app_user.user_name or "garygeeke" + self.user_password = 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.log(f"Platform URL: {self.platform_url}") + self.log(f"View Server: {self.view_server}") + self.log(f"User: {self.user_name}") + self.log(f"User PWD: {self.user_password}") + self.users_found: DataTable = DataTable(id="users_found") + self.my_network: DataTable = DataTable(id="my_network") + + def on_mount(self) -> None: + """Perform actions when the application is mounted.""" + self.my_network.add_column("Nickname", key="nickname") + self.my_network.add_column("User ID", key="user_id") + self.my_network.add_column("GUID", key="guid") + self.my_network.add_column("Qualified Name", key="qualified_name") + self.my_network.add_column("Notes", key="notes") + self.my_network.cursor_type="row" + self.my_network.zebra_stripes=True + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + yield Static(self.description) + yield ScrollableContainer( + Placeholder(label="Placeholder for contacts list", id="users_network_placeholder"), + id="users_network_container") + yield ScrollableContainer( + Static("Search for user, enter the (partial) name to search for and press return:"), + Input(placeholder="Name", id="user_search_input"), + id="user_input_container") + yield Footer() + + @on(Input.Submitted, "#user_search_input") + def handle_input_submitted(self, event: Input.Submitted) -> None: + """Handle the submission of the user search input.""" + self.log(f"User search input submitted: {event.value}") + user_found = self.search_users(str(event.value)) + self.users_found.clear(columns=True) + self.users_found.add_columns("Id", "GUID", "Qualified Name") + self.users_found.cursor_type = "row" + self.users_found.zebra_stripes=True + self.notify(f"Found {len(user_found)} users with name: {event.value}", severity="information", timeout=20) + if isinstance(user_found, list): + for user in user_found: + self.users_found.add_row(user["User ID"], user["GUID"], user["Qualified Name"]) + self.query_one("#users_network_container", ScrollableContainer).mount(self.users_found, + Static("Please select a row to add to your network")) + elif isinstance(user_found, dict): + self.users_found.add_row(user_found["User ID"], user_found["GUID"], user_found["Qualified Name"]) + self.query_one("#users_network_container", ScrollableContainer).mount(self.users_found, + Static( + "Please select a row to add to your network")) + else: + self.log(f"Unexpected response type: {type(user_found)}") + self.notify(f"Unexpected response type: {type(user_found)}") + self.users_found.refresh() + self.query_one("#user_input_container", ScrollableContainer).remove_children() + + def search_users(self, user_name: str) -> Any: + self.log(f"Searching for users with name: {user_name}") + try: + eclient=Egeria(self.view_server, self.platform_url, self.user_name, self.user_password) + token=eclient.create_egeria_bearer_token(self.user_name, self.user_password) + user_found=eclient.find_user_identities( + search_string=user_name, + output_format="DICT", + report_spec="User-Identities", + query_depth=0, + page_size=10, + ) + self.log(f"Found {user_found} users with name: {user_name}") + except PyegeriaException as e: + self.log(f"Error searching for users: {e}") + user_found="Error" + self.notify(f"Error searching for users: {e}", severity="error", timeout=20) + finally: + if eclient: + eclient.close_session() + return user_found + + @on(DataTable.RowSelected, "#users_found") + def on_users_found_row_selected(self, event: DataTable.RowSelected) -> None: + self.log(f"User selected: {event.row_key}") + self.row_selected = event.row_key + row_data = self.users_found.get_row(self.row_selected) + self.log(f"Selected row data: {row_data}") + self.selected_user_id = row_data[0] + self.selected_GUID = row_data[1] + self.selected_qualified_name = row_data[2] + self.log(f"Selected user ID: {self.selected_user_id}") + self.log(f"Selected GUID: {self.selected_GUID}") + self.log(f"Selected qualified name: {self.selected_qualified_name}") + self.query_one("#user_input_container", ScrollableContainer).remove_children() + self.query_one("#users_input_container", ScrollableContainer).mount( + Static("Selected ID for new network contact is:"), + Static(f"{self.selected_user_id, " ", self.selected_qualified_name}"), + Static("Please provide a NickName to Store this contact under:"), + Input(placeholder="Nickname", id="nickname_input"), + ) + + @on(Input.Submitted, "#nickname_input") + def handle_nickname_input(self, event: Input.Submitted): + """Handle the submission of the user nickname input.""" + self.log(f"User nickname input submitted: {event.value}") + self.nickname = event.value + self.my_network.add_row(self.nickname, self.selected_user_id, self.selected_GUID, self.selected_qualified_name) + +def main() -> None: + """Entry point for the my_profile console script.""" + MyNetworkApp().run() + + +if __name__ == "__main__": + main() \ No newline at end of file From 3b62625283647ba8f82e05330ad0b78a41c02592 Mon Sep 17 00:00:00 2001 From: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:24:08 -0500 Subject: [PATCH 02/14] wip(my_egeria): my_network loads profile and existing peers, links new peers - Load the user's profile and existing Peer relationships on mount - Search actor profiles (case-insensitive) instead of user identities - Add description input and a Link Peer button to create the relationship Signed-off-by: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> --- .../DemoCode/Experimental Code/my_network.py | 126 ++++++++++++++++-- 1 file changed, 117 insertions(+), 9 deletions(-) diff --git a/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py b/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py index f775b700..5de0c0ac 100644 --- a/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py +++ b/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py @@ -6,6 +6,7 @@ """ import sys +from datetime import datetime, timezone from pathlib import Path from typing import Any import asyncio @@ -14,7 +15,7 @@ from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import ScrollableContainer -from textual.widgets import Header, Footer, Static, Placeholder, Input, DataTable +from textual.widgets import Header, Footer, Static, Placeholder, Input, DataTable, Button # Add the project root to sys.path to allow running this script from any directory root_path = Path(__file__).resolve().parents[4] @@ -60,7 +61,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.log(f"Application Config: {app_config}") app_user = settings.User_Profile self.log(f"User Profile: {app_user}") - self.user_name = app_user.user_name or "garygeeke" + self.user_name = app_user.user_name or "tessatube" self.user_password = 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" @@ -70,6 +71,8 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.log(f"User PWD: {self.user_password}") self.users_found: DataTable = DataTable(id="users_found") self.my_network: DataTable = DataTable(id="my_network") + self.nickname = "" + self.description = "" def on_mount(self) -> None: """Perform actions when the application is mounted.""" @@ -80,6 +83,59 @@ def on_mount(self) -> None: self.my_network.add_column("Notes", key="notes") self.my_network.cursor_type="row" self.my_network.zebra_stripes=True + try: + eclient = Egeria(self.view_server, self.platform_url, self.user_name, self.user_password) + token=eclient.create_egeria_bearer_token(self.user_name, self.user_password) + self.my_profile=eclient.get_my_profile(output_format="DICT") + self.log(f"User profile retrieved successfully, {self.my_profile}") + except PyegeriaException as e: + self.log(f"Error: {e}") + self.notify(f"Error, unable to retrieve user profile: {e}", severity="error", timeout=20) + self.exit(400) + finally: + if eclient: + eclient.close_session() + if self.my_profile: + if isinstance(self.my_profile, list): + self.user_GUID = self.my_profile[0].get("GUID") or None + else: + self.notify(f"User profile is not a list: {self.my_profile}", severity="error", timeout=20) + self.exit(400) + else: + self.notify(f"User profile not found: {self.user_name}", severity="error", timeout=20) + self.exit(400) + + # Get existing peer relationship list + + try: + eclient = Egeria(self.view_server, self.platform_url, self.user_name, self.user_password) + token = eclient.create_egeria_bearer_token(self.user_name, self.user_password) + peers = eclient.get_relationships( + relationship_type="Peer", + # source_guid=self.user_GUID, + output_format="JSON", + page_size=30) + self.log(f"Peers: {peers}") + except PyegeriaException as e: + self.log(f"Error creating peer relationship: {e}") + self.notify(f"Error creating peer relationship: {e}", timeout=15, severity="error") + self.exit(400) + finally: + if eclient: + eclient.close_session() + for peer in peers: + self.log(f"Peer: {peer}") + self.my_network.add_row(peer.get("Label"), + peer.get("Display Name"), + peer.get("GUID"), + peer.get("Qualified Name"), + peer.get("Description")) + continue + self.my_network.refresh() + container=self.query_one("#users_network_container", ScrollableContainer) + container.remove_children() + container.mount(self.my_network) + return def compose(self) -> ComposeResult: yield Header(show_clock=True) @@ -90,7 +146,7 @@ def compose(self) -> ComposeResult: yield ScrollableContainer( Static("Search for user, enter the (partial) name to search for and press return:"), Input(placeholder="Name", id="user_search_input"), - id="user_input_container") + id="users_input_container") yield Footer() @on(Input.Submitted, "#user_search_input") @@ -103,9 +159,16 @@ def handle_input_submitted(self, event: Input.Submitted) -> None: self.users_found.cursor_type = "row" self.users_found.zebra_stripes=True self.notify(f"Found {len(user_found)} users with name: {event.value}", severity="information", timeout=20) + if len(user_found) == 0: + self.notify(f"No users found with name: {event.value}", severity="warning", timeout=20) + return + if isinstance(user_found, str): + self.notify(f"Error searching for users: {user_found}", severity="error", timeout=20) + return if isinstance(user_found, list): for user in user_found: - self.users_found.add_row(user["User ID"], user["GUID"], user["Qualified Name"]) + self.users_found.add_row(user["Display Name"], user["GUID"], user["Qualified Name"]) + self.query_one("#users_network_container", ScrollableContainer).remove_children() self.query_one("#users_network_container", ScrollableContainer).mount(self.users_found, Static("Please select a row to add to your network")) elif isinstance(user_found, dict): @@ -117,15 +180,16 @@ def handle_input_submitted(self, event: Input.Submitted) -> None: self.log(f"Unexpected response type: {type(user_found)}") self.notify(f"Unexpected response type: {type(user_found)}") self.users_found.refresh() - self.query_one("#user_input_container", ScrollableContainer).remove_children() + self.query_one("#users_input_container", ScrollableContainer).remove_children() def search_users(self, user_name: str) -> Any: self.log(f"Searching for users with name: {user_name}") try: eclient=Egeria(self.view_server, self.platform_url, self.user_name, self.user_password) token=eclient.create_egeria_bearer_token(self.user_name, self.user_password) - user_found=eclient.find_user_identities( + user_found=eclient.find_actor_profiles( search_string=user_name, + ignore_case=True, output_format="DICT", report_spec="User-Identities", query_depth=0, @@ -147,26 +211,70 @@ def on_users_found_row_selected(self, event: DataTable.RowSelected) -> None: self.row_selected = event.row_key row_data = self.users_found.get_row(self.row_selected) self.log(f"Selected row data: {row_data}") + self.query_one("#users_input_container", ScrollableContainer).remove_children() self.selected_user_id = row_data[0] self.selected_GUID = row_data[1] self.selected_qualified_name = row_data[2] self.log(f"Selected user ID: {self.selected_user_id}") self.log(f"Selected GUID: {self.selected_GUID}") self.log(f"Selected qualified name: {self.selected_qualified_name}") - self.query_one("#user_input_container", ScrollableContainer).remove_children() self.query_one("#users_input_container", ScrollableContainer).mount( Static("Selected ID for new network contact is:"), Static(f"{self.selected_user_id, " ", self.selected_qualified_name}"), Static("Please provide a NickName to Store this contact under:"), Input(placeholder="Nickname", id="nickname_input"), + Static("Optionally provide a description/comment about this relationship"), + Input(placeholder="Description", id="description_input"), + Button("Link Peer", id="link_peer_button", variant="primary") ) - @on(Input.Submitted, "#nickname_input") - def handle_nickname_input(self, event: Input.Submitted): + @on(Input.Changed, "#nickname_input") + def handle_nickname_input(self, event: Input.Changed): """Handle the submission of the user nickname input.""" self.log(f"User nickname input submitted: {event.value}") self.nickname = event.value + + @on(Input.Changed, "#description_input") + def handle_description_input(self, event: Input.Changed): + """Handle the submission of the user description input.""" + self.log(f"User description input submitted: {event.value}") + self.description = event.value + + @on(Button.Pressed, "#link_peer_button") + def handle_link_peer_button(self, event: Button.Pressed): + """Handle the submission of the link peer button. + Since there is no validity check and both NickName and Description are optional + we link the peer person without further upfront validation """ self.my_network.add_row(self.nickname, self.selected_user_id, self.selected_GUID, self.selected_qualified_name) + #add link to peer + self.log(f"Owner GUID: {self.user_GUID} Peer selected GUID: {self.selected_GUID}") + now = datetime.now(timezone.utc).isoformat(timespec="milliseconds") + body = { + "class": "NewRelationshipRequestBody", + "properties": { + "class": "PeerProperties", + "peer_relationship_name": self.nickname, + "description": self.description, + "effective_from" : None, + "effective_to": None + } + } + self.log(f"Linking peer person: body {body} Owner GUID: {self.user_GUID} and Peer selected GUID: {self.selected_GUID}") + try: + eclient = Egeria(self.view_server, + self.platform_url, + self.user_name, + self.user_password) + token = eclient.create_egeria_bearer_token(self.user_name, self.user_password) + eclient.link_peer_person(self.user_GUID, + self.selected_GUID, + body + ) + except PyegeriaException as e: + self.log(f"Error linking peer person: {e}") + self.notify(f"Error linking peer person: {e}") + finally: + return def main() -> None: """Entry point for the my_profile console script.""" From 357c96063cdc4d333a5efd5ae4db1535da67ab8d Mon Sep 17 00:00:00 2001 From: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:32:03 -0500 Subject: [PATCH 03/14] tidying up the project files, moved a number of folders to deprecated, added custom report format for listing peers Signed-off-by: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> --- config/config.json | 5 +- .../DemoCode/Experimental Code/my_network.py | 117 +++++++++++++++++- .../DemoCode/Journals_Demo/__init__.py | 7 -- my_egeria/my_egeria/README.md | 10 +- .../con_services/egeria_connection.py | 4 +- my_egeria/my_egeria/error_popup_app.py | 2 +- my_egeria/my_egeria/my_egeria_app.py | 42 +++---- .../add_governance_definition.py | 4 +- .../delete_governance_definition.py | 4 +- .../governance_officer_browser.py | 4 +- .../GovernanceOfficer/marketplace_tree.py | 1 - .../ProductManager/product_manager_browser.py | 4 +- .../ProjectManager/ProjectManagerBrowser.py | 2 +- my_egeria/my_egeria/screens/base_screen.py | 4 +- .../screens/collections/add_collection.py | 4 +- .../screens/collections/collection_browser.py | 2 +- .../screens/collections/collection_details.py | 2 +- .../collections/collection_members_screen.py | 2 +- .../screens/collections/delete_collection.py | 2 +- .../screens/glossary/glossary_browser.py | 6 +- .../screens/glossary/glossary_browser_old.py | 4 +- .../screens/glossary/glossary_list_screen.py | 6 +- .../screens/glossary/term_details.py | 3 +- .../screens/glossary/term_list_screen.py | 3 +- .../my_egeria/screens/governance_screen.py | 4 +- my_egeria/my_egeria/screens/login_screen.py | 2 +- my_egeria/my_egeria/screens/main_menu.py | 4 +- .../screens/platform/platform_services.py | 4 +- .../screens/platform/platforms_screen.py | 4 +- my_egeria/my_egeria/screens/project_screen.py | 4 +- .../my_egeria/screens/subject_area_screen.py | 4 +- my_egeria/my_egeria/services/base_service.py | 4 +- .../my_egeria/services/collection_service.py | 2 +- .../my_egeria/services/glossary_service.py | 2 +- .../services/governance_officer_service.py | 4 +- .../services/product_manager_service.py | 5 +- .../services/project_manager_service.py | 3 +- .../services/subject_area_service.py | 2 +- .../my_egeria/tests/test_login_screen.py | 4 - .../my_egeria/tests/test_startup_check.py | 2 +- .../tests/test_term_details_screen.py | 1 - my_report_specs/my_profile_report_specs.json | 47 +++++++ 42 files changed, 240 insertions(+), 106 deletions(-) delete mode 100644 my_egeria/my_egeria/DemoCode/Journals_Demo/__init__.py create mode 100644 my_report_specs/my_profile_report_specs.json diff --git a/config/config.json b/config/config.json index dac20f26..6d3e25ad 100644 --- a/config/config.json +++ b/config/config.json @@ -51,5 +51,8 @@ "Egeria Home Glossary Name": "Egeria-Markdown", "Egeria Local Qualifier": "myLocal", "Egeria Usage Level": "Basic" - } + }, + "Pyegeria Report Spec Modules": [ + "~/my_report_specs/my_profile_report_specs.json" + ] } \ No newline at end of file diff --git a/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py b/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py index 5de0c0ac..8a20343d 100644 --- a/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py +++ b/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py @@ -71,8 +71,11 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.log(f"User PWD: {self.user_password}") self.users_found: DataTable = DataTable(id="users_found") self.my_network: DataTable = DataTable(id="my_network") + self.my_user_contact_details: DataTable = DataTable(id="my_user_contact_details") + self.my_user_communities: DataTable = DataTable(id="my_user_communities") self.nickname = "" self.description = "" + self.selected_network_member = None def on_mount(self) -> None: """Perform actions when the application is mounted.""" @@ -112,7 +115,6 @@ def on_mount(self) -> None: token = eclient.create_egeria_bearer_token(self.user_name, self.user_password) peers = eclient.get_relationships( relationship_type="Peer", - # source_guid=self.user_GUID, output_format="JSON", page_size=30) self.log(f"Peers: {peers}") @@ -126,15 +128,18 @@ def on_mount(self) -> None: for peer in peers: self.log(f"Peer: {peer}") self.my_network.add_row(peer.get("Label"), - peer.get("Display Name"), - peer.get("GUID"), + peer.get("uniqueName"), + peer.get("guid"), peer.get("Qualified Name"), - peer.get("Description")) + peer.get("relationshipProperties", "description")) continue self.my_network.refresh() container=self.query_one("#users_network_container", ScrollableContainer) - container.remove_children() + container.remove_children("#users_network_placeholder") container.mount(self.my_network) + container.mount(Button(label="User Details", id="user_details1", variant="primary")) + container.refresh() + self.my_network.refresh() return def compose(self) -> ComposeResult: @@ -270,11 +275,113 @@ def handle_link_peer_button(self, event: Button.Pressed): self.selected_GUID, body ) + self.notify(f"Peer person linked successfully") except PyegeriaException as e: self.log(f"Error linking peer person: {e}") self.notify(f"Error linking peer person: {e}") finally: + # clear input fields + self.query_one("#nickname_input", Input).clear() + self.query_one("#description_input", Input).clear() + # reset display to allow another search + self.query_one("#users_input_container", ScrollableContainer).remove_children() + self.query_one("#users_network_container", ScrollableContainer).remove_children() + self.query_one("#users_input_container").mount( + Static("Search for user, enter the (partial) name to search for and press return:"), + Input(placeholder="Name", id="user_search_input") + ) + self.query_one("#users_network_container").mount( + Static("Peer person network"), + self.my_network, + Button(label="User Details", id="user_details2", variant="primary") + ) + return + + @on(DataTable.RowHighlighted, "#user_network_table") + def handle_network_member_selected(self,event: DataTable.RowHighlighted): + self.log(f"Row highlighted: {event.row_key}") + self.selected_network_member = event.row_key + + @on(DataTable.RowSelected, "#user_network_table") + def handle_network_member_selected(self, event: DataTable.RowSelected): + self.log(f"Row selected: {event.row_key}") + self.selected_network_member = event.row_key + + @on(Button.Pressed, "#user_details1") + def handle_user_details_1_button(self, event: Button.Pressed): + if self.selected_network_member: + selected_row_data = self.my_network.get_row(self.selected_network_member) + self.show_user_details(selected_row_data) + return + else: + self.notify(f"Please select a network member to view details before using the Button.", severity="warning", timeout=10) + return + + @on(Button.Pressed, "#user_details2") + def handle_user_details_2_button(self, event: Button.Pressed): + if self.selected_network_member: + selected_row_data = self.my_network.get_row(self.selected_network_member) + self.show_user_details(selected_row_data) return + else: + self.notify(f"Please select a network member to view details before using the Button.", severity="warning", + timeout=10) + return + + def show_user_details(self, selected_row_data): + """ Show both contact details and community membership details for the selected network member.""" + self.selected_network_member = selected_row_data + self.user_GUID = selected_row_data[2] + self.user_qname = selected_row_data[3] + self.user_nickname = selected_row_data[0] + #clear and set up the datatables + self.my_user_contact_details.clear(columns=True) + self.my_user_contact_details.add_columns("Name", "Method Type", "Contact Type", "Service", "Value", "GUID") + self.my_user_contact_details.cursor_type = "row" + self.my_user_contact_details.zebra_stripes=True + self.my_user_communities.clear(columns=True) + self.my_user_communities.add_columns("Name", "Assignment Type", "Description", "GUID") + self.my_user_communities.cursor_type = "row" + self.my_user_communities.zebra_stripes=True + # retrieve the users contact information + try: + eclient=Egeria( + self.view_server, + self.platform_url, + self.user_name, + self.user_password) + token=eclient.create_egeria_bearer_token(self.user_name, self.user_password) + self.user_contact_details = exec_report_spec( + format_set_name="My-User-Contact-Detail") + self.log(f"Retrieved user contact details: {self.user_contact_details}") + except Exception as e: + self.notify(f"Error retrieving user contact details: {str(e)}", severity="error", timeout=10) + return + try: + self.user_communities = exec_report_spec( + format_set_name="My-User-Communities-Detail") + self.log(f"Retrieved user community memberships: {self.user_communities}") + except Exception as e: + self.notify(f"Error retrieving user community memberships: {str(e)}", severity="error", timeout=10) + return + finally: + if eclient: + eclient.close_session() + if isinstance(self.user_contact_details, list): + for contact in self.user_contact_details: + self.my_user_contact_details.add_row(contact.get("Name"), + contact.get("Method Type"), + contact.get("Contact Type"), + contact.get("Service"), + contact.get("Value"), + contact.get("GUID")) + if isinstance(self.user_communities, list): + for community in self.user_communities: + self.my_user_communities.add_row(community.get("Name"), + community.get("Assignment Type"), + community.get("Desacription"), + community.get("GUID")) + return def main() -> None: """Entry point for the my_profile console script.""" diff --git a/my_egeria/my_egeria/DemoCode/Journals_Demo/__init__.py b/my_egeria/my_egeria/DemoCode/Journals_Demo/__init__.py deleted file mode 100644 index d7c7a021..00000000 --- a/my_egeria/my_egeria/DemoCode/Journals_Demo/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" - PDX-License-Identifier: Apache-2.0 - Copyright Contributors to the ODPi Egeria project. - - This file provides a set of report specification related functions for my_egeria. - -""" \ No newline at end of file diff --git a/my_egeria/my_egeria/README.md b/my_egeria/my_egeria/README.md index 1288dffa..fcbdbbe0 100644 --- a/my_egeria/my_egeria/README.md +++ b/my_egeria/my_egeria/README.md @@ -10,11 +10,11 @@ The `my-egeria` Textual TUI application's package root — see the parent |---|---| | `main.py` | Entry point (`uv run my-egeria` / `python -m my_egeria.main`). | | `my_egeria_app.py` | The main Textual `App` class. | -| `screens/` | Textual screens, one per feature area (`GovernanceOfficer`, `ProductManager`, `ProjectManager`, `collections`, `glossary`, `platform`). | -| `widgets/` | Reusable Textual widgets shared across screens. | -| `services/`, `con_services/` | Backend service layer wrapping pyegeria SDK calls for the screens/widgets to use. | -| `utils/` | Shared helpers. | -| `styles/` | Textual CSS. | +| `DemoCode/Deprecated/screens` | Textual screens, one per feature area (`GovernanceOfficer`, `ProductManager`, `ProjectManager`, `collections`, `glossary`, `platform`). | +| `DemoCode/Deprecated/widgets` | Reusable Textual widgets shared across screens. | +| `DemoCode/Deprecated/services`, `con_services/` | Backend service layer wrapping pyegeria SDK calls for the screens/widgets to use. | +| `DemoCode/Deprecated/utils` | Shared helpers. | +| `DemoCode/Deprecated/styles` | Textual CSS. | | `tests/` | Tests for this app. | | `error_popup_app.py`, `startup_check.py` | Startup/error-handling helpers. | | `serve.py` | Browser-mode serving via the `textual-serve` library — see the `serve_my_egeria`/`serve_my_profile` entry points in the root `pyproject.toml`. | diff --git a/my_egeria/my_egeria/con_services/egeria_connection.py b/my_egeria/my_egeria/con_services/egeria_connection.py index 3f577c6a..dd77cc04 100644 --- a/my_egeria/my_egeria/con_services/egeria_connection.py +++ b/my_egeria/my_egeria/con_services/egeria_connection.py @@ -10,8 +10,8 @@ from __future__ import annotations from typing import Optional -from ..utils.config import get_global_config -from ..utils.egeria_client import preflight_origin +from my_egeria.DemoCode.Deprecated.utils import get_global_config +from my_egeria.DemoCode.Deprecated.utils import preflight_origin class EgeriaConnectionService: """Checks if Egeria is reachable and authenticates.""" diff --git a/my_egeria/my_egeria/error_popup_app.py b/my_egeria/my_egeria/error_popup_app.py index 2ef978a6..0f036111 100644 --- a/my_egeria/my_egeria/error_popup_app.py +++ b/my_egeria/my_egeria/error_popup_app.py @@ -6,7 +6,7 @@ """ from textual.app import App -from my_egeria.screens.error_popup_screen import ErrorPopup +from my_egeria.DemoCode.Deprecated.screens import ErrorPopup class ErrorPopupApp (App): diff --git a/my_egeria/my_egeria/my_egeria_app.py b/my_egeria/my_egeria/my_egeria_app.py index 075854ae..386d29fc 100644 --- a/my_egeria/my_egeria/my_egeria_app.py +++ b/my_egeria/my_egeria/my_egeria_app.py @@ -8,12 +8,9 @@ """ import os -from typing import Any from textual import on -from . import utils - # Set safe defaults BEFORE importing anything that might import pyegeria os.environ.setdefault("EGERIA_USER", "erinoverview") os.environ.setdefault("EGERIA_USER_PASSWORD", "secret") @@ -22,26 +19,25 @@ from textual.app import App, ComposeResult from textual.widgets import Footer -from my_egeria.screens.login_screen import LoginScreen -from my_egeria.screens.main_menu import MainMenuScreen -from my_egeria.screens.glossary.glossary_browser import GlossaryBrowserScreen -from my_egeria.screens.collections.collection_browser import CollectionBrowserScreen -from my_egeria.screens.collections.collection_members_screen import CollectionMemberScreen -from my_egeria.screens.collections.collection_details import CollectionDetailsScreen -from my_egeria.screens.collections.add_collection import AddCollectionScreen -from my_egeria.screens.collections.delete_collection import DeleteCollectionScreen -from my_egeria.screens.glossary.glossary_list_screen import GlossaryListScreen -from my_egeria.screens.glossary.term_details import TermDetailsScreen -from my_egeria.screens.glossary.term_list_screen import TermListScreen -from my_egeria.screens.GovernanceOfficer.governance_officer_browser import GovernanceOfficerBrowserScreen -from my_egeria.screens.GovernanceOfficer.add_governance_definition import AddGovernanceDefinitionScreen -from my_egeria.screens.GovernanceOfficer.delete_governance_definition import DeleteGovernanceDefinitionScreen -from my_egeria.screens.GovernanceOfficer.marketplace_tree import MarketPlaceTree -from my_egeria.screens.ProductManager.product_manager_browser import ProductManagerBrowser -from my_egeria.utils.egeria_client import close_all_managers -from my_egeria.utils.config import EgeriaConfig -from my_egeria.screens.splash_screen import SplashScreen # your existing splash screen -from my_egeria.services.term_service import get_terms_for_glossary +from my_egeria.DemoCode.Deprecated.screens import LoginScreen +from my_egeria.DemoCode.Deprecated.screens.main_menu import MainMenuScreen +from my_egeria.DemoCode.Deprecated.screens.glossary import GlossaryBrowserScreen +from my_egeria.DemoCode.Deprecated.screens.collections.collection_browser import CollectionBrowserScreen +from my_egeria.DemoCode.Deprecated.screens.collections.collection_members_screen import CollectionMemberScreen +from my_egeria.DemoCode.Deprecated.screens.collections.collection_details import CollectionDetailsScreen +from my_egeria.DemoCode.Deprecated.screens.collections import AddCollectionScreen +from my_egeria.DemoCode.Deprecated.screens.collections.delete_collection import DeleteCollectionScreen +from my_egeria.DemoCode.Deprecated.screens.glossary.glossary_list_screen import GlossaryListScreen +from my_egeria.DemoCode.Deprecated.screens.glossary.term_details import TermDetailsScreen +from my_egeria.DemoCode.Deprecated.screens.glossary.term_list_screen import TermListScreen +from my_egeria.DemoCode.Deprecated.screens.GovernanceOfficer.governance_officer_browser import GovernanceOfficerBrowserScreen +from my_egeria.DemoCode.Deprecated.screens.GovernanceOfficer import AddGovernanceDefinitionScreen +from my_egeria.DemoCode.Deprecated.screens.GovernanceOfficer import DeleteGovernanceDefinitionScreen +from my_egeria.DemoCode.Deprecated.screens.GovernanceOfficer.marketplace_tree import MarketPlaceTree +from my_egeria.DemoCode.Deprecated.screens.ProductManager.product_manager_browser import ProductManagerBrowser +from my_egeria.DemoCode.Deprecated.utils.egeria_client import close_all_managers +from my_egeria.DemoCode.Deprecated.screens.splash_screen import SplashScreen # your existing splash screen + class MyEgeria(App): """Main app class of my_egeria.""" diff --git a/my_egeria/my_egeria/screens/GovernanceOfficer/add_governance_definition.py b/my_egeria/my_egeria/screens/GovernanceOfficer/add_governance_definition.py index 101099a9..4d3bafaf 100644 --- a/my_egeria/my_egeria/screens/GovernanceOfficer/add_governance_definition.py +++ b/my_egeria/my_egeria/screens/GovernanceOfficer/add_governance_definition.py @@ -17,9 +17,9 @@ from textual.widgets import Static, Input, Button, Pretty, OptionList, Footer, Header # from .governance_officer_browser import GovernanceOfficerBrowserScreen from ..base_screen import BaseScreen -from my_egeria.services.governance_officer_service import GovernanceOfficerService +from my_egeria.DemoCode.Deprecated.services.governance_officer_service import GovernanceOfficerService from typing import Dict, Any -from my_egeria.utils.config import EgeriaConfig + def parse_kv_pairs(text: str) -> Dict[str, Any]: """ diff --git a/my_egeria/my_egeria/screens/GovernanceOfficer/delete_governance_definition.py b/my_egeria/my_egeria/screens/GovernanceOfficer/delete_governance_definition.py index c24f5e98..ae3a34c1 100644 --- a/my_egeria/my_egeria/screens/GovernanceOfficer/delete_governance_definition.py +++ b/my_egeria/my_egeria/screens/GovernanceOfficer/delete_governance_definition.py @@ -9,8 +9,8 @@ """ from textual.messages import Message from typing import Dict, Any -from my_egeria.screens.base_screen import BaseScreen -from my_egeria.services.governance_officer_service import GovernanceOfficerService +from my_egeria.DemoCode.Deprecated.screens.base_screen import BaseScreen +from my_egeria.DemoCode.Deprecated.services.governance_officer_service import GovernanceOfficerService from textual.containers import Container, Vertical, Horizontal from textual.widgets import Static, Button import asyncio diff --git a/my_egeria/my_egeria/screens/GovernanceOfficer/governance_officer_browser.py b/my_egeria/my_egeria/screens/GovernanceOfficer/governance_officer_browser.py index e1ecfef9..086ce45e 100644 --- a/my_egeria/my_egeria/screens/GovernanceOfficer/governance_officer_browser.py +++ b/my_egeria/my_egeria/screens/GovernanceOfficer/governance_officer_browser.py @@ -13,12 +13,12 @@ from textual.containers import Container, Vertical, Horizontal from ..base_screen import BaseScreen from .marketplace_tree import MarketPlaceTree -from my_egeria.services.governance_officer_service import GovernanceOfficerService +from my_egeria.DemoCode.Deprecated.services.governance_officer_service import GovernanceOfficerService from .add_governance_definition import AddGovernanceDefinitionScreen from .delete_governance_definition import DeleteGovernanceDefinitionScreen import asyncio from textual import on -from my_egeria.utils.config import EgeriaConfig, get_global_config +from my_egeria.DemoCode.Deprecated.utils.config import get_global_config # ... existing imports ... diff --git a/my_egeria/my_egeria/screens/GovernanceOfficer/marketplace_tree.py b/my_egeria/my_egeria/screens/GovernanceOfficer/marketplace_tree.py index 29867d67..6d0aca88 100644 --- a/my_egeria/my_egeria/screens/GovernanceOfficer/marketplace_tree.py +++ b/my_egeria/my_egeria/screens/GovernanceOfficer/marketplace_tree.py @@ -9,7 +9,6 @@ """ from ..base_screen import BaseScreen from textual.widgets import Button, Tree, Static, Input -from my_egeria.utils.config import EgeriaConfig, get_global_config from textual import on from textual.containers import Container, Horizontal diff --git a/my_egeria/my_egeria/screens/ProductManager/product_manager_browser.py b/my_egeria/my_egeria/screens/ProductManager/product_manager_browser.py index 5ee717a1..60919390 100644 --- a/my_egeria/my_egeria/screens/ProductManager/product_manager_browser.py +++ b/my_egeria/my_egeria/screens/ProductManager/product_manager_browser.py @@ -7,8 +7,8 @@ """ # from textual.geometry import Coordinate # remove unused/unsupported import -from my_egeria.screens.base_screen import BaseScreen -from my_egeria.services.product_manager_service import ProductManagerService +from my_egeria.DemoCode.Deprecated.screens.base_screen import BaseScreen +from my_egeria.DemoCode.Deprecated.services.product_manager_service import ProductManagerService # from .collection_details import CollectionDetailsScreen # from .add_collection import AddCollectionScreen # from .delete_collection import DeleteCollectionScreen diff --git a/my_egeria/my_egeria/screens/ProjectManager/ProjectManagerBrowser.py b/my_egeria/my_egeria/screens/ProjectManager/ProjectManagerBrowser.py index 38a5d294..0cfd6055 100644 --- a/my_egeria/my_egeria/screens/ProjectManager/ProjectManagerBrowser.py +++ b/my_egeria/my_egeria/screens/ProjectManager/ProjectManagerBrowser.py @@ -7,7 +7,7 @@ """ # from textual.geometry import Coordinate # remove unused/unsupported import from ..base_screen import BaseScreen -from my_egeria.services.project_manager_service import ProjectManagerService +from my_egeria.DemoCode.Deprecated.services.project_manager_service import ProjectManagerService # from .collection_details import CollectionDetailsScreen # from .add_collection import AddCollectionScreen # from .delete_collection import DeleteCollectionScreen diff --git a/my_egeria/my_egeria/screens/base_screen.py b/my_egeria/my_egeria/screens/base_screen.py index f664fc7f..aac7be26 100644 --- a/my_egeria/my_egeria/screens/base_screen.py +++ b/my_egeria/my_egeria/screens/base_screen.py @@ -13,8 +13,8 @@ from textual.app import ComposeResult from textual import on from textual.containers import Container -from my_egeria.utils.config import get_global_config -from my_egeria.utils.egeria_client import EgeriaTechClientManager +from my_egeria.DemoCode.Deprecated.utils.config import get_global_config +from my_egeria.DemoCode.Deprecated.utils.egeria_client import EgeriaTechClientManager from my_egeria.con_services.egeria_connection import EgeriaConnectionService class BaseScreen(Screen): diff --git a/my_egeria/my_egeria/screens/collections/add_collection.py b/my_egeria/my_egeria/screens/collections/add_collection.py index a2a09939..0401b473 100644 --- a/my_egeria/my_egeria/screens/collections/add_collection.py +++ b/my_egeria/my_egeria/screens/collections/add_collection.py @@ -14,8 +14,8 @@ from textual.widgets import Static, Input, Button # from .collection_browser import CollectionBrowserScreen -from my_egeria.screens.base_screen import BaseScreen -from my_egeria.services.collection_service import CollectionService +from my_egeria.DemoCode.Deprecated.screens.base_screen import BaseScreen +from my_egeria.DemoCode.Deprecated.services.collection_service import CollectionService from typing import Dict, Any def parse_kv_pairs(text: str) -> Dict[str, Any]: diff --git a/my_egeria/my_egeria/screens/collections/collection_browser.py b/my_egeria/my_egeria/screens/collections/collection_browser.py index 1cbcd0fc..d3798f6c 100644 --- a/my_egeria/my_egeria/screens/collections/collection_browser.py +++ b/my_egeria/my_egeria/screens/collections/collection_browser.py @@ -13,7 +13,7 @@ from textual.containers import Container, Vertical, Horizontal # from textual.geometry import Coordinate # remove unused/unsupported import from ..base_screen import BaseScreen -from my_egeria.services.collection_service import CollectionService +from my_egeria.DemoCode.Deprecated.services.collection_service import CollectionService from .add_collection import AddCollectionScreen from .delete_collection import DeleteCollectionScreen import asyncio diff --git a/my_egeria/my_egeria/screens/collections/collection_details.py b/my_egeria/my_egeria/screens/collections/collection_details.py index 0f2f2f9f..3686adc8 100644 --- a/my_egeria/my_egeria/screens/collections/collection_details.py +++ b/my_egeria/my_egeria/screens/collections/collection_details.py @@ -10,7 +10,7 @@ from textual.widgets import Static, DataTable, Button, Input from textual.containers import Vertical, Horizontal -from my_egeria.services.collection_service import CollectionService +from my_egeria.DemoCode.Deprecated.services.collection_service import CollectionService from ..base_screen import BaseScreen diff --git a/my_egeria/my_egeria/screens/collections/collection_members_screen.py b/my_egeria/my_egeria/screens/collections/collection_members_screen.py index 9eeeaf1e..7b4f66d0 100644 --- a/my_egeria/my_egeria/screens/collections/collection_members_screen.py +++ b/my_egeria/my_egeria/screens/collections/collection_members_screen.py @@ -14,7 +14,7 @@ from textual.app import ComposeResult from textual import on -from my_egeria.services.collection_service import CollectionService +from my_egeria.DemoCode.Deprecated.services.collection_service import CollectionService # from utils.egeria_client import EgeriaTechClientManager # import os from ..base_screen import BaseScreen diff --git a/my_egeria/my_egeria/screens/collections/delete_collection.py b/my_egeria/my_egeria/screens/collections/delete_collection.py index 0d1514fd..2cc0632d 100644 --- a/my_egeria/my_egeria/screens/collections/delete_collection.py +++ b/my_egeria/my_egeria/screens/collections/delete_collection.py @@ -14,7 +14,7 @@ from textual.containers import Container, Vertical, Horizontal from textual.widgets import Static, Button from ..base_screen import BaseScreen -from my_egeria.services.collection_service import CollectionService +from my_egeria.DemoCode.Deprecated.services.collection_service import CollectionService from typing import Dict, Any def parse_kv_pairs(text: str) -> Dict[str, Any]: diff --git a/my_egeria/my_egeria/screens/glossary/glossary_browser.py b/my_egeria/my_egeria/screens/glossary/glossary_browser.py index d4fa4eb4..621d2fc8 100644 --- a/my_egeria/my_egeria/screens/glossary/glossary_browser.py +++ b/my_egeria/my_egeria/screens/glossary/glossary_browser.py @@ -8,12 +8,10 @@ """ from textual.app import ComposeResult -from textual.screen import Screen from textual.widgets import Static, DataTable, Input, Button from textual.containers import Horizontal, Vertical, Container -from my_egeria.screens.base_screen import BaseScreen -from my_egeria.services.glossary_service import GlossaryService -from .term_details import TermDetailsScreen +from my_egeria.DemoCode.Deprecated.screens.base_screen import BaseScreen +from my_egeria.DemoCode.Deprecated.services.glossary_service import GlossaryService import asyncio diff --git a/my_egeria/my_egeria/screens/glossary/glossary_browser_old.py b/my_egeria/my_egeria/screens/glossary/glossary_browser_old.py index ec437a3c..a5cb8501 100644 --- a/my_egeria/my_egeria/screens/glossary/glossary_browser_old.py +++ b/my_egeria/my_egeria/screens/glossary/glossary_browser_old.py @@ -10,8 +10,8 @@ from textual.widgets import Static, DataTable, Input, Button from textual.containers import Horizontal, Vertical, Container -from my_egeria.screens.base_screen import BaseScreen -from my_egeria.services.glossary_service import GlossaryService +from my_egeria.DemoCode.Deprecated.screens.base_screen import BaseScreen +from my_egeria.DemoCode.Deprecated.services.glossary_service import GlossaryService from .term_details import TermDetailsScreen import asyncio diff --git a/my_egeria/my_egeria/screens/glossary/glossary_list_screen.py b/my_egeria/my_egeria/screens/glossary/glossary_list_screen.py index c2503aa8..ceb28637 100644 --- a/my_egeria/my_egeria/screens/glossary/glossary_list_screen.py +++ b/my_egeria/my_egeria/screens/glossary/glossary_list_screen.py @@ -10,9 +10,9 @@ from textual.widgets import Static from textual.containers import Vertical -from my_egeria.widgets.editable_table import EditableDataTable -from my_egeria.services.glossary_service import GlossaryService -from my_egeria.screens.base_screen import BaseScreen +from my_egeria.DemoCode.Deprecated.widgets.editable_table import EditableDataTable +from my_egeria.DemoCode.Deprecated.services.glossary_service import GlossaryService +from my_egeria.DemoCode.Deprecated.screens.base_screen import BaseScreen class GlossaryListScreen(BaseScreen): CSS_PATH = ["../styles/common.css", "/styles/glossary_list_screen.css"] diff --git a/my_egeria/my_egeria/screens/glossary/term_details.py b/my_egeria/my_egeria/screens/glossary/term_details.py index d176f54a..36aa98f2 100644 --- a/my_egeria/my_egeria/screens/glossary/term_details.py +++ b/my_egeria/my_egeria/screens/glossary/term_details.py @@ -9,8 +9,7 @@ """ from ..base_screen import BaseScreen -from my_egeria.widgets.editable_table import EditableDataTable, CellEdited -from my_egeria.widgets.ok_popup import OkPopup + class TermDetailsScreen(BaseScreen): diff --git a/my_egeria/my_egeria/screens/glossary/term_list_screen.py b/my_egeria/my_egeria/screens/glossary/term_list_screen.py index 027befa2..9c950a1b 100644 --- a/my_egeria/my_egeria/screens/glossary/term_list_screen.py +++ b/my_egeria/my_egeria/screens/glossary/term_list_screen.py @@ -10,8 +10,7 @@ from textual.widgets import Static from textual.containers import Vertical -from my_egeria.widgets.editable_table import EditableDataTable -from my_egeria.services.term_service import get_terms_for_glossary +from my_egeria.DemoCode.Deprecated.services.term_service import get_terms_for_glossary from ..base_screen import BaseScreen diff --git a/my_egeria/my_egeria/screens/governance_screen.py b/my_egeria/my_egeria/screens/governance_screen.py index 499edd08..f0b4e5a1 100644 --- a/my_egeria/my_egeria/screens/governance_screen.py +++ b/my_egeria/my_egeria/screens/governance_screen.py @@ -14,8 +14,8 @@ from textual.containers import Container from .base_screen import BaseScreen -from my_egeria.utils.config import get_global_config -from my_egeria.utils.egeria_client import EgeriaTechClientManager +from my_egeria.DemoCode.Deprecated.utils.config import get_global_config +from my_egeria.DemoCode.Deprecated.utils.egeria_client import EgeriaTechClientManager class GovernanceScreen(BaseScreen): diff --git a/my_egeria/my_egeria/screens/login_screen.py b/my_egeria/my_egeria/screens/login_screen.py index bc77150a..387d5821 100644 --- a/my_egeria/my_egeria/screens/login_screen.py +++ b/my_egeria/my_egeria/screens/login_screen.py @@ -13,7 +13,7 @@ from textual.message import Message from textual.widgets import Static, Input, Button from .base_screen import BaseScreen -from ..con_services.egeria_connection import connect_to_egeria +from my_egeria.con_services.egeria_connection import connect_to_egeria class LoginScreen(BaseScreen): diff --git a/my_egeria/my_egeria/screens/main_menu.py b/my_egeria/my_egeria/screens/main_menu.py index df5587b5..8b722280 100644 --- a/my_egeria/my_egeria/screens/main_menu.py +++ b/my_egeria/my_egeria/screens/main_menu.py @@ -12,8 +12,8 @@ from textual.containers import Container, Vertical from textual import events from my_egeria.con_services.egeria_connection import EgeriaConnectionService -from my_egeria.widgets.popup import show_popup_and_exit -from my_egeria.screens.base_screen import BaseScreen +from my_egeria.DemoCode.Deprecated.widgets.popup import show_popup_and_exit +from my_egeria.DemoCode.Deprecated.screens.base_screen import BaseScreen class MainMenuScreen(BaseScreen): diff --git a/my_egeria/my_egeria/screens/platform/platform_services.py b/my_egeria/my_egeria/screens/platform/platform_services.py index 96f16121..188f789c 100644 --- a/my_egeria/my_egeria/screens/platform/platform_services.py +++ b/my_egeria/my_egeria/screens/platform/platform_services.py @@ -13,8 +13,8 @@ from textual.containers import Container from textual import events -from my_egeria.services.platform_service import PlatformServices -from my_egeria.widgets.popup import show_popup_and_exit +from my_egeria.DemoCode.Deprecated.services.platform_service import PlatformServices +from my_egeria.DemoCode.Deprecated.widgets.popup import show_popup_and_exit class PlatformServicesScreen(Screen): diff --git a/my_egeria/my_egeria/screens/platform/platforms_screen.py b/my_egeria/my_egeria/screens/platform/platforms_screen.py index 046bed54..f2cb1148 100644 --- a/my_egeria/my_egeria/screens/platform/platforms_screen.py +++ b/my_egeria/my_egeria/screens/platform/platforms_screen.py @@ -11,8 +11,8 @@ from textual.widgets import Static, DataTable from textual.app import ComposeResult from textual.containers import Container -from my_egeria.utils.config import get_global_config -from my_egeria.utils.egeria_client import EgeriaTechClientManager +from my_egeria.DemoCode.Deprecated.utils.config import get_global_config +from my_egeria.DemoCode.Deprecated.utils.egeria_client import EgeriaTechClientManager from ..base_screen import BaseScreen diff --git a/my_egeria/my_egeria/screens/project_screen.py b/my_egeria/my_egeria/screens/project_screen.py index de666249..813ddc55 100644 --- a/my_egeria/my_egeria/screens/project_screen.py +++ b/my_egeria/my_egeria/screens/project_screen.py @@ -10,9 +10,9 @@ from textual.widgets import DataTable from textual.containers import Container -from my_egeria.services.project_manager_service import ProjectManagerService +from my_egeria.DemoCode.Deprecated.services.project_manager_service import ProjectManagerService from .base_screen import BaseScreen -from my_egeria.widgets.back_button import BackButton +from my_egeria.DemoCode.Deprecated.widgets.back_button import BackButton class ProjectScreen(BaseScreen): CSS_PATH = ["/styles/common.css", "/styles/project_screen.css"] diff --git a/my_egeria/my_egeria/screens/subject_area_screen.py b/my_egeria/my_egeria/screens/subject_area_screen.py index 1942bdaa..df4da247 100644 --- a/my_egeria/my_egeria/screens/subject_area_screen.py +++ b/my_egeria/my_egeria/screens/subject_area_screen.py @@ -11,8 +11,8 @@ from textual.screen import Screen from textual.widgets import DataTable, Header, Footer from textual.containers import Container -from my_egeria.widgets.back_button import BackButton -from my_egeria.services.subject_area_service import SubjectAreaService +from my_egeria.DemoCode.Deprecated.widgets.back_button import BackButton +from my_egeria.DemoCode.Deprecated.services.subject_area_service import SubjectAreaService class SubjectAreaScreen(Screen): diff --git a/my_egeria/my_egeria/services/base_service.py b/my_egeria/my_egeria/services/base_service.py index 192b6ec3..5e69c65d 100644 --- a/my_egeria/my_egeria/services/base_service.py +++ b/my_egeria/my_egeria/services/base_service.py @@ -9,8 +9,8 @@ """ from typing import Any, List, Dict, Optional, Tuple -from my_egeria.utils.egeria_client import EgeriaTechClientManager -from my_egeria.utils.config import EgeriaConfig, get_global_config +from my_egeria.DemoCode.Deprecated.utils.egeria_client import EgeriaTechClientManager +from my_egeria.DemoCode.Deprecated.utils.config import EgeriaConfig, get_global_config from os import getenv class BaseService: diff --git a/my_egeria/my_egeria/services/collection_service.py b/my_egeria/my_egeria/services/collection_service.py index 8d74c717..07ecd7ac 100644 --- a/my_egeria/my_egeria/services/collection_service.py +++ b/my_egeria/my_egeria/services/collection_service.py @@ -10,7 +10,7 @@ import asyncio from typing import Any, Dict, List, Optional from .base_service import BaseService -from my_egeria.utils.config import EgeriaConfig +from my_egeria.DemoCode.Deprecated.utils.config import EgeriaConfig class CollectionService(BaseService): """Wrapper around pyegeria collection functions with token-managed client.""" diff --git a/my_egeria/my_egeria/services/glossary_service.py b/my_egeria/my_egeria/services/glossary_service.py index 209f9c8e..1d53316d 100644 --- a/my_egeria/my_egeria/services/glossary_service.py +++ b/my_egeria/my_egeria/services/glossary_service.py @@ -15,7 +15,7 @@ import asyncio from typing import Any, Dict, List, Optional from .base_service import BaseService -from my_egeria.utils.config import EgeriaConfig +from my_egeria.DemoCode.Deprecated.utils.config import EgeriaConfig # Hook for tests to inject a mock client diff --git a/my_egeria/my_egeria/services/governance_officer_service.py b/my_egeria/my_egeria/services/governance_officer_service.py index ae48699e..8d09174f 100644 --- a/my_egeria/my_egeria/services/governance_officer_service.py +++ b/my_egeria/my_egeria/services/governance_officer_service.py @@ -10,8 +10,8 @@ from typing import Optional from .base_service import BaseService -from my_egeria.utils.egeria_client import EgeriaTechClientManager -from my_egeria.utils.config import EgeriaConfig +from my_egeria.DemoCode.Deprecated.utils.egeria_client import EgeriaTechClientManager +from my_egeria.DemoCode.Deprecated.utils.config import EgeriaConfig class GovernanceOfficerService(BaseService): def __init__(self, config): diff --git a/my_egeria/my_egeria/services/product_manager_service.py b/my_egeria/my_egeria/services/product_manager_service.py index 32c8277c..74b0c203 100644 --- a/my_egeria/my_egeria/services/product_manager_service.py +++ b/my_egeria/my_egeria/services/product_manager_service.py @@ -5,10 +5,9 @@ """ -import asyncio -from typing import Any, Dict, List, Optional +from typing import Optional from .base_service import BaseService -from my_egeria.utils.config import EgeriaConfig +from my_egeria.DemoCode.Deprecated.utils.config import EgeriaConfig class ProductManagerService(BaseService): """Wrapper around pyegeria collection functions with token-managed client.""" diff --git a/my_egeria/my_egeria/services/project_manager_service.py b/my_egeria/my_egeria/services/project_manager_service.py index c10c8cf1..0981f000 100644 --- a/my_egeria/my_egeria/services/project_manager_service.py +++ b/my_egeria/my_egeria/services/project_manager_service.py @@ -8,10 +8,9 @@ """ -import asyncio from typing import Any, Dict, List, Optional from .base_service import BaseService -from my_egeria.utils.config import EgeriaConfig +from my_egeria.DemoCode.Deprecated.utils.config import EgeriaConfig class ProjectManagerService(BaseService): """Wrapper around pyegeria collection functions with token-managed client.""" diff --git a/my_egeria/my_egeria/services/subject_area_service.py b/my_egeria/my_egeria/services/subject_area_service.py index d1b7cfac..455be0af 100644 --- a/my_egeria/my_egeria/services/subject_area_service.py +++ b/my_egeria/my_egeria/services/subject_area_service.py @@ -7,7 +7,7 @@ """ -from my_egeria.services.base_service import BaseService +from my_egeria.DemoCode.Deprecated.services.base_service import BaseService class SubjectAreaService(BaseService): diff --git a/my_egeria/my_egeria/tests/test_login_screen.py b/my_egeria/my_egeria/tests/test_login_screen.py index 7ebf0135..f883995d 100644 --- a/my_egeria/my_egeria/tests/test_login_screen.py +++ b/my_egeria/my_egeria/tests/test_login_screen.py @@ -8,11 +8,7 @@ """ -import pytest -from unittest.mock import patch from textual.app import App -from textual.widgets import Input, Button -from my_egeria.screens.login_screen import LoginScreen class LoginApp(App): diff --git a/my_egeria/my_egeria/tests/test_startup_check.py b/my_egeria/my_egeria/tests/test_startup_check.py index 1500c92d..2440f52c 100644 --- a/my_egeria/my_egeria/tests/test_startup_check.py +++ b/my_egeria/my_egeria/tests/test_startup_check.py @@ -10,7 +10,7 @@ import os import pytest -from ..startup_check import validate_envs, check_connection +from my_egeria.startup_check import validate_envs, check_connection def test_validate_envs_missing(monkeypatch): diff --git a/my_egeria/my_egeria/tests/test_term_details_screen.py b/my_egeria/my_egeria/tests/test_term_details_screen.py index fca5ccf2..e2ae04dd 100644 --- a/my_egeria/my_egeria/tests/test_term_details_screen.py +++ b/my_egeria/my_egeria/tests/test_term_details_screen.py @@ -9,7 +9,6 @@ """ from textual.app import App -from my_egeria.screens.glossary.term_details import TermDetailsScreen class TestApp(App): diff --git a/my_report_specs/my_profile_report_specs.json b/my_report_specs/my_profile_report_specs.json new file mode 100644 index 00000000..bf6d3cf4 --- /dev/null +++ b/my_report_specs/my_profile_report_specs.json @@ -0,0 +1,47 @@ +{ + "//_comment1": "PDX-License-Identifier: Apache-2.0", + "//_comment2": "Copyright Contributors to the ODPi Egeria project.", + "//_comment3": " ", + "//_comment4": " This file provides the custom report specs for My Profile Textual App. ", + "//_comment5": " Author: Peter A Coldicott ", + + "My-User-Peers": { + "target_type": "Peer", + "heading": "Peers", + "description": "Detailed Peer Connections", + "family": "MyProfile", + "formats": [ + { + "types": [ + "LIST", + "REPORT", + "DICT", + "TABLE" + ], + "attributes": [ + { + "name": "Label", + "key": "label" + }, + { + "name": "Qualified Name", + "key": "uniqueName" + }, + { + "name": "Scope", + "key": "scope" + }, + { + "name": "Description", + "key": "relationshipProperties.description" + }, + { + "name": "GUID", + "key": "guid", + "format": "True" + } + ] + } + ] + } +} \ No newline at end of file From 024c5988ed6928e2f52f5d3458a655a628117aeb Mon Sep 17 00:00:00 2001 From: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:11:50 -0500 Subject: [PATCH 04/14] Further reformatting to clear out deprecated files and add in the use of the custome report format Signed-off-by: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> --- .../DemoCode/Experimental Code/my_network.py | 8 ++-- my_egeria/my_egeria/README.md | 10 ++--- .../con_services/egeria_connection.py | 4 +- my_egeria/my_egeria/error_popup_app.py | 2 +- my_egeria/my_egeria/my_egeria_app.py | 42 ++++++++++--------- .../add_governance_definition.py | 4 +- .../delete_governance_definition.py | 4 +- .../governance_officer_browser.py | 4 +- .../GovernanceOfficer/marketplace_tree.py | 1 + .../ProductManager/product_manager_browser.py | 4 +- .../ProjectManager/ProjectManagerBrowser.py | 2 +- my_egeria/my_egeria/screens/base_screen.py | 4 +- .../screens/collections/add_collection.py | 4 +- .../screens/collections/collection_browser.py | 2 +- .../screens/collections/collection_details.py | 2 +- .../collections/collection_members_screen.py | 2 +- .../screens/collections/delete_collection.py | 2 +- .../screens/glossary/glossary_browser.py | 6 ++- .../screens/glossary/glossary_browser_old.py | 4 +- .../screens/glossary/glossary_list_screen.py | 6 +-- .../screens/glossary/term_details.py | 3 +- .../screens/glossary/term_list_screen.py | 3 +- .../my_egeria/screens/governance_screen.py | 4 +- my_egeria/my_egeria/screens/login_screen.py | 2 +- my_egeria/my_egeria/screens/main_menu.py | 4 +- .../screens/platform/platform_services.py | 4 +- .../screens/platform/platforms_screen.py | 4 +- my_egeria/my_egeria/screens/project_screen.py | 4 +- .../my_egeria/screens/subject_area_screen.py | 4 +- my_egeria/my_egeria/services/base_service.py | 4 +- .../my_egeria/services/collection_service.py | 2 +- .../my_egeria/services/glossary_service.py | 2 +- .../services/governance_officer_service.py | 4 +- .../services/product_manager_service.py | 5 ++- .../services/project_manager_service.py | 3 +- .../services/subject_area_service.py | 2 +- .../my_egeria/tests/test_login_screen.py | 4 ++ .../my_egeria/tests/test_startup_check.py | 2 +- .../tests/test_term_details_screen.py | 1 + 39 files changed, 97 insertions(+), 81 deletions(-) diff --git a/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py b/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py index 8a20343d..baa7da53 100644 --- a/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py +++ b/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py @@ -113,10 +113,10 @@ def on_mount(self) -> None: try: eclient = Egeria(self.view_server, self.platform_url, self.user_name, self.user_password) token = eclient.create_egeria_bearer_token(self.user_name, self.user_password) - peers = eclient.get_relationships( - relationship_type="Peer", - output_format="JSON", - page_size=30) + peers = eclient.exec_report_spec( + format_set_name="My-User-Peers", + output_format="DICT", + ) self.log(f"Peers: {peers}") except PyegeriaException as e: self.log(f"Error creating peer relationship: {e}") diff --git a/my_egeria/my_egeria/README.md b/my_egeria/my_egeria/README.md index fcbdbbe0..1288dffa 100644 --- a/my_egeria/my_egeria/README.md +++ b/my_egeria/my_egeria/README.md @@ -10,11 +10,11 @@ The `my-egeria` Textual TUI application's package root — see the parent |---|---| | `main.py` | Entry point (`uv run my-egeria` / `python -m my_egeria.main`). | | `my_egeria_app.py` | The main Textual `App` class. | -| `DemoCode/Deprecated/screens` | Textual screens, one per feature area (`GovernanceOfficer`, `ProductManager`, `ProjectManager`, `collections`, `glossary`, `platform`). | -| `DemoCode/Deprecated/widgets` | Reusable Textual widgets shared across screens. | -| `DemoCode/Deprecated/services`, `con_services/` | Backend service layer wrapping pyegeria SDK calls for the screens/widgets to use. | -| `DemoCode/Deprecated/utils` | Shared helpers. | -| `DemoCode/Deprecated/styles` | Textual CSS. | +| `screens/` | Textual screens, one per feature area (`GovernanceOfficer`, `ProductManager`, `ProjectManager`, `collections`, `glossary`, `platform`). | +| `widgets/` | Reusable Textual widgets shared across screens. | +| `services/`, `con_services/` | Backend service layer wrapping pyegeria SDK calls for the screens/widgets to use. | +| `utils/` | Shared helpers. | +| `styles/` | Textual CSS. | | `tests/` | Tests for this app. | | `error_popup_app.py`, `startup_check.py` | Startup/error-handling helpers. | | `serve.py` | Browser-mode serving via the `textual-serve` library — see the `serve_my_egeria`/`serve_my_profile` entry points in the root `pyproject.toml`. | diff --git a/my_egeria/my_egeria/con_services/egeria_connection.py b/my_egeria/my_egeria/con_services/egeria_connection.py index dd77cc04..3f577c6a 100644 --- a/my_egeria/my_egeria/con_services/egeria_connection.py +++ b/my_egeria/my_egeria/con_services/egeria_connection.py @@ -10,8 +10,8 @@ from __future__ import annotations from typing import Optional -from my_egeria.DemoCode.Deprecated.utils import get_global_config -from my_egeria.DemoCode.Deprecated.utils import preflight_origin +from ..utils.config import get_global_config +from ..utils.egeria_client import preflight_origin class EgeriaConnectionService: """Checks if Egeria is reachable and authenticates.""" diff --git a/my_egeria/my_egeria/error_popup_app.py b/my_egeria/my_egeria/error_popup_app.py index 0f036111..2ef978a6 100644 --- a/my_egeria/my_egeria/error_popup_app.py +++ b/my_egeria/my_egeria/error_popup_app.py @@ -6,7 +6,7 @@ """ from textual.app import App -from my_egeria.DemoCode.Deprecated.screens import ErrorPopup +from my_egeria.screens.error_popup_screen import ErrorPopup class ErrorPopupApp (App): diff --git a/my_egeria/my_egeria/my_egeria_app.py b/my_egeria/my_egeria/my_egeria_app.py index 386d29fc..075854ae 100644 --- a/my_egeria/my_egeria/my_egeria_app.py +++ b/my_egeria/my_egeria/my_egeria_app.py @@ -8,9 +8,12 @@ """ import os +from typing import Any from textual import on +from . import utils + # Set safe defaults BEFORE importing anything that might import pyegeria os.environ.setdefault("EGERIA_USER", "erinoverview") os.environ.setdefault("EGERIA_USER_PASSWORD", "secret") @@ -19,25 +22,26 @@ from textual.app import App, ComposeResult from textual.widgets import Footer -from my_egeria.DemoCode.Deprecated.screens import LoginScreen -from my_egeria.DemoCode.Deprecated.screens.main_menu import MainMenuScreen -from my_egeria.DemoCode.Deprecated.screens.glossary import GlossaryBrowserScreen -from my_egeria.DemoCode.Deprecated.screens.collections.collection_browser import CollectionBrowserScreen -from my_egeria.DemoCode.Deprecated.screens.collections.collection_members_screen import CollectionMemberScreen -from my_egeria.DemoCode.Deprecated.screens.collections.collection_details import CollectionDetailsScreen -from my_egeria.DemoCode.Deprecated.screens.collections import AddCollectionScreen -from my_egeria.DemoCode.Deprecated.screens.collections.delete_collection import DeleteCollectionScreen -from my_egeria.DemoCode.Deprecated.screens.glossary.glossary_list_screen import GlossaryListScreen -from my_egeria.DemoCode.Deprecated.screens.glossary.term_details import TermDetailsScreen -from my_egeria.DemoCode.Deprecated.screens.glossary.term_list_screen import TermListScreen -from my_egeria.DemoCode.Deprecated.screens.GovernanceOfficer.governance_officer_browser import GovernanceOfficerBrowserScreen -from my_egeria.DemoCode.Deprecated.screens.GovernanceOfficer import AddGovernanceDefinitionScreen -from my_egeria.DemoCode.Deprecated.screens.GovernanceOfficer import DeleteGovernanceDefinitionScreen -from my_egeria.DemoCode.Deprecated.screens.GovernanceOfficer.marketplace_tree import MarketPlaceTree -from my_egeria.DemoCode.Deprecated.screens.ProductManager.product_manager_browser import ProductManagerBrowser -from my_egeria.DemoCode.Deprecated.utils.egeria_client import close_all_managers -from my_egeria.DemoCode.Deprecated.screens.splash_screen import SplashScreen # your existing splash screen - +from my_egeria.screens.login_screen import LoginScreen +from my_egeria.screens.main_menu import MainMenuScreen +from my_egeria.screens.glossary.glossary_browser import GlossaryBrowserScreen +from my_egeria.screens.collections.collection_browser import CollectionBrowserScreen +from my_egeria.screens.collections.collection_members_screen import CollectionMemberScreen +from my_egeria.screens.collections.collection_details import CollectionDetailsScreen +from my_egeria.screens.collections.add_collection import AddCollectionScreen +from my_egeria.screens.collections.delete_collection import DeleteCollectionScreen +from my_egeria.screens.glossary.glossary_list_screen import GlossaryListScreen +from my_egeria.screens.glossary.term_details import TermDetailsScreen +from my_egeria.screens.glossary.term_list_screen import TermListScreen +from my_egeria.screens.GovernanceOfficer.governance_officer_browser import GovernanceOfficerBrowserScreen +from my_egeria.screens.GovernanceOfficer.add_governance_definition import AddGovernanceDefinitionScreen +from my_egeria.screens.GovernanceOfficer.delete_governance_definition import DeleteGovernanceDefinitionScreen +from my_egeria.screens.GovernanceOfficer.marketplace_tree import MarketPlaceTree +from my_egeria.screens.ProductManager.product_manager_browser import ProductManagerBrowser +from my_egeria.utils.egeria_client import close_all_managers +from my_egeria.utils.config import EgeriaConfig +from my_egeria.screens.splash_screen import SplashScreen # your existing splash screen +from my_egeria.services.term_service import get_terms_for_glossary class MyEgeria(App): """Main app class of my_egeria.""" diff --git a/my_egeria/my_egeria/screens/GovernanceOfficer/add_governance_definition.py b/my_egeria/my_egeria/screens/GovernanceOfficer/add_governance_definition.py index 4d3bafaf..101099a9 100644 --- a/my_egeria/my_egeria/screens/GovernanceOfficer/add_governance_definition.py +++ b/my_egeria/my_egeria/screens/GovernanceOfficer/add_governance_definition.py @@ -17,9 +17,9 @@ from textual.widgets import Static, Input, Button, Pretty, OptionList, Footer, Header # from .governance_officer_browser import GovernanceOfficerBrowserScreen from ..base_screen import BaseScreen -from my_egeria.DemoCode.Deprecated.services.governance_officer_service import GovernanceOfficerService +from my_egeria.services.governance_officer_service import GovernanceOfficerService from typing import Dict, Any - +from my_egeria.utils.config import EgeriaConfig def parse_kv_pairs(text: str) -> Dict[str, Any]: """ diff --git a/my_egeria/my_egeria/screens/GovernanceOfficer/delete_governance_definition.py b/my_egeria/my_egeria/screens/GovernanceOfficer/delete_governance_definition.py index ae3a34c1..c24f5e98 100644 --- a/my_egeria/my_egeria/screens/GovernanceOfficer/delete_governance_definition.py +++ b/my_egeria/my_egeria/screens/GovernanceOfficer/delete_governance_definition.py @@ -9,8 +9,8 @@ """ from textual.messages import Message from typing import Dict, Any -from my_egeria.DemoCode.Deprecated.screens.base_screen import BaseScreen -from my_egeria.DemoCode.Deprecated.services.governance_officer_service import GovernanceOfficerService +from my_egeria.screens.base_screen import BaseScreen +from my_egeria.services.governance_officer_service import GovernanceOfficerService from textual.containers import Container, Vertical, Horizontal from textual.widgets import Static, Button import asyncio diff --git a/my_egeria/my_egeria/screens/GovernanceOfficer/governance_officer_browser.py b/my_egeria/my_egeria/screens/GovernanceOfficer/governance_officer_browser.py index 086ce45e..e1ecfef9 100644 --- a/my_egeria/my_egeria/screens/GovernanceOfficer/governance_officer_browser.py +++ b/my_egeria/my_egeria/screens/GovernanceOfficer/governance_officer_browser.py @@ -13,12 +13,12 @@ from textual.containers import Container, Vertical, Horizontal from ..base_screen import BaseScreen from .marketplace_tree import MarketPlaceTree -from my_egeria.DemoCode.Deprecated.services.governance_officer_service import GovernanceOfficerService +from my_egeria.services.governance_officer_service import GovernanceOfficerService from .add_governance_definition import AddGovernanceDefinitionScreen from .delete_governance_definition import DeleteGovernanceDefinitionScreen import asyncio from textual import on -from my_egeria.DemoCode.Deprecated.utils.config import get_global_config +from my_egeria.utils.config import EgeriaConfig, get_global_config # ... existing imports ... diff --git a/my_egeria/my_egeria/screens/GovernanceOfficer/marketplace_tree.py b/my_egeria/my_egeria/screens/GovernanceOfficer/marketplace_tree.py index 6d0aca88..29867d67 100644 --- a/my_egeria/my_egeria/screens/GovernanceOfficer/marketplace_tree.py +++ b/my_egeria/my_egeria/screens/GovernanceOfficer/marketplace_tree.py @@ -9,6 +9,7 @@ """ from ..base_screen import BaseScreen from textual.widgets import Button, Tree, Static, Input +from my_egeria.utils.config import EgeriaConfig, get_global_config from textual import on from textual.containers import Container, Horizontal diff --git a/my_egeria/my_egeria/screens/ProductManager/product_manager_browser.py b/my_egeria/my_egeria/screens/ProductManager/product_manager_browser.py index 60919390..5ee717a1 100644 --- a/my_egeria/my_egeria/screens/ProductManager/product_manager_browser.py +++ b/my_egeria/my_egeria/screens/ProductManager/product_manager_browser.py @@ -7,8 +7,8 @@ """ # from textual.geometry import Coordinate # remove unused/unsupported import -from my_egeria.DemoCode.Deprecated.screens.base_screen import BaseScreen -from my_egeria.DemoCode.Deprecated.services.product_manager_service import ProductManagerService +from my_egeria.screens.base_screen import BaseScreen +from my_egeria.services.product_manager_service import ProductManagerService # from .collection_details import CollectionDetailsScreen # from .add_collection import AddCollectionScreen # from .delete_collection import DeleteCollectionScreen diff --git a/my_egeria/my_egeria/screens/ProjectManager/ProjectManagerBrowser.py b/my_egeria/my_egeria/screens/ProjectManager/ProjectManagerBrowser.py index 0cfd6055..38a5d294 100644 --- a/my_egeria/my_egeria/screens/ProjectManager/ProjectManagerBrowser.py +++ b/my_egeria/my_egeria/screens/ProjectManager/ProjectManagerBrowser.py @@ -7,7 +7,7 @@ """ # from textual.geometry import Coordinate # remove unused/unsupported import from ..base_screen import BaseScreen -from my_egeria.DemoCode.Deprecated.services.project_manager_service import ProjectManagerService +from my_egeria.services.project_manager_service import ProjectManagerService # from .collection_details import CollectionDetailsScreen # from .add_collection import AddCollectionScreen # from .delete_collection import DeleteCollectionScreen diff --git a/my_egeria/my_egeria/screens/base_screen.py b/my_egeria/my_egeria/screens/base_screen.py index aac7be26..f664fc7f 100644 --- a/my_egeria/my_egeria/screens/base_screen.py +++ b/my_egeria/my_egeria/screens/base_screen.py @@ -13,8 +13,8 @@ from textual.app import ComposeResult from textual import on from textual.containers import Container -from my_egeria.DemoCode.Deprecated.utils.config import get_global_config -from my_egeria.DemoCode.Deprecated.utils.egeria_client import EgeriaTechClientManager +from my_egeria.utils.config import get_global_config +from my_egeria.utils.egeria_client import EgeriaTechClientManager from my_egeria.con_services.egeria_connection import EgeriaConnectionService class BaseScreen(Screen): diff --git a/my_egeria/my_egeria/screens/collections/add_collection.py b/my_egeria/my_egeria/screens/collections/add_collection.py index 0401b473..a2a09939 100644 --- a/my_egeria/my_egeria/screens/collections/add_collection.py +++ b/my_egeria/my_egeria/screens/collections/add_collection.py @@ -14,8 +14,8 @@ from textual.widgets import Static, Input, Button # from .collection_browser import CollectionBrowserScreen -from my_egeria.DemoCode.Deprecated.screens.base_screen import BaseScreen -from my_egeria.DemoCode.Deprecated.services.collection_service import CollectionService +from my_egeria.screens.base_screen import BaseScreen +from my_egeria.services.collection_service import CollectionService from typing import Dict, Any def parse_kv_pairs(text: str) -> Dict[str, Any]: diff --git a/my_egeria/my_egeria/screens/collections/collection_browser.py b/my_egeria/my_egeria/screens/collections/collection_browser.py index d3798f6c..1cbcd0fc 100644 --- a/my_egeria/my_egeria/screens/collections/collection_browser.py +++ b/my_egeria/my_egeria/screens/collections/collection_browser.py @@ -13,7 +13,7 @@ from textual.containers import Container, Vertical, Horizontal # from textual.geometry import Coordinate # remove unused/unsupported import from ..base_screen import BaseScreen -from my_egeria.DemoCode.Deprecated.services.collection_service import CollectionService +from my_egeria.services.collection_service import CollectionService from .add_collection import AddCollectionScreen from .delete_collection import DeleteCollectionScreen import asyncio diff --git a/my_egeria/my_egeria/screens/collections/collection_details.py b/my_egeria/my_egeria/screens/collections/collection_details.py index 3686adc8..0f2f2f9f 100644 --- a/my_egeria/my_egeria/screens/collections/collection_details.py +++ b/my_egeria/my_egeria/screens/collections/collection_details.py @@ -10,7 +10,7 @@ from textual.widgets import Static, DataTable, Button, Input from textual.containers import Vertical, Horizontal -from my_egeria.DemoCode.Deprecated.services.collection_service import CollectionService +from my_egeria.services.collection_service import CollectionService from ..base_screen import BaseScreen diff --git a/my_egeria/my_egeria/screens/collections/collection_members_screen.py b/my_egeria/my_egeria/screens/collections/collection_members_screen.py index 7b4f66d0..9eeeaf1e 100644 --- a/my_egeria/my_egeria/screens/collections/collection_members_screen.py +++ b/my_egeria/my_egeria/screens/collections/collection_members_screen.py @@ -14,7 +14,7 @@ from textual.app import ComposeResult from textual import on -from my_egeria.DemoCode.Deprecated.services.collection_service import CollectionService +from my_egeria.services.collection_service import CollectionService # from utils.egeria_client import EgeriaTechClientManager # import os from ..base_screen import BaseScreen diff --git a/my_egeria/my_egeria/screens/collections/delete_collection.py b/my_egeria/my_egeria/screens/collections/delete_collection.py index 2cc0632d..0d1514fd 100644 --- a/my_egeria/my_egeria/screens/collections/delete_collection.py +++ b/my_egeria/my_egeria/screens/collections/delete_collection.py @@ -14,7 +14,7 @@ from textual.containers import Container, Vertical, Horizontal from textual.widgets import Static, Button from ..base_screen import BaseScreen -from my_egeria.DemoCode.Deprecated.services.collection_service import CollectionService +from my_egeria.services.collection_service import CollectionService from typing import Dict, Any def parse_kv_pairs(text: str) -> Dict[str, Any]: diff --git a/my_egeria/my_egeria/screens/glossary/glossary_browser.py b/my_egeria/my_egeria/screens/glossary/glossary_browser.py index 621d2fc8..d4fa4eb4 100644 --- a/my_egeria/my_egeria/screens/glossary/glossary_browser.py +++ b/my_egeria/my_egeria/screens/glossary/glossary_browser.py @@ -8,10 +8,12 @@ """ from textual.app import ComposeResult +from textual.screen import Screen from textual.widgets import Static, DataTable, Input, Button from textual.containers import Horizontal, Vertical, Container -from my_egeria.DemoCode.Deprecated.screens.base_screen import BaseScreen -from my_egeria.DemoCode.Deprecated.services.glossary_service import GlossaryService +from my_egeria.screens.base_screen import BaseScreen +from my_egeria.services.glossary_service import GlossaryService +from .term_details import TermDetailsScreen import asyncio diff --git a/my_egeria/my_egeria/screens/glossary/glossary_browser_old.py b/my_egeria/my_egeria/screens/glossary/glossary_browser_old.py index a5cb8501..ec437a3c 100644 --- a/my_egeria/my_egeria/screens/glossary/glossary_browser_old.py +++ b/my_egeria/my_egeria/screens/glossary/glossary_browser_old.py @@ -10,8 +10,8 @@ from textual.widgets import Static, DataTable, Input, Button from textual.containers import Horizontal, Vertical, Container -from my_egeria.DemoCode.Deprecated.screens.base_screen import BaseScreen -from my_egeria.DemoCode.Deprecated.services.glossary_service import GlossaryService +from my_egeria.screens.base_screen import BaseScreen +from my_egeria.services.glossary_service import GlossaryService from .term_details import TermDetailsScreen import asyncio diff --git a/my_egeria/my_egeria/screens/glossary/glossary_list_screen.py b/my_egeria/my_egeria/screens/glossary/glossary_list_screen.py index ceb28637..c2503aa8 100644 --- a/my_egeria/my_egeria/screens/glossary/glossary_list_screen.py +++ b/my_egeria/my_egeria/screens/glossary/glossary_list_screen.py @@ -10,9 +10,9 @@ from textual.widgets import Static from textual.containers import Vertical -from my_egeria.DemoCode.Deprecated.widgets.editable_table import EditableDataTable -from my_egeria.DemoCode.Deprecated.services.glossary_service import GlossaryService -from my_egeria.DemoCode.Deprecated.screens.base_screen import BaseScreen +from my_egeria.widgets.editable_table import EditableDataTable +from my_egeria.services.glossary_service import GlossaryService +from my_egeria.screens.base_screen import BaseScreen class GlossaryListScreen(BaseScreen): CSS_PATH = ["../styles/common.css", "/styles/glossary_list_screen.css"] diff --git a/my_egeria/my_egeria/screens/glossary/term_details.py b/my_egeria/my_egeria/screens/glossary/term_details.py index 36aa98f2..d176f54a 100644 --- a/my_egeria/my_egeria/screens/glossary/term_details.py +++ b/my_egeria/my_egeria/screens/glossary/term_details.py @@ -9,7 +9,8 @@ """ from ..base_screen import BaseScreen - +from my_egeria.widgets.editable_table import EditableDataTable, CellEdited +from my_egeria.widgets.ok_popup import OkPopup class TermDetailsScreen(BaseScreen): diff --git a/my_egeria/my_egeria/screens/glossary/term_list_screen.py b/my_egeria/my_egeria/screens/glossary/term_list_screen.py index 9c950a1b..027befa2 100644 --- a/my_egeria/my_egeria/screens/glossary/term_list_screen.py +++ b/my_egeria/my_egeria/screens/glossary/term_list_screen.py @@ -10,7 +10,8 @@ from textual.widgets import Static from textual.containers import Vertical -from my_egeria.DemoCode.Deprecated.services.term_service import get_terms_for_glossary +from my_egeria.widgets.editable_table import EditableDataTable +from my_egeria.services.term_service import get_terms_for_glossary from ..base_screen import BaseScreen diff --git a/my_egeria/my_egeria/screens/governance_screen.py b/my_egeria/my_egeria/screens/governance_screen.py index f0b4e5a1..499edd08 100644 --- a/my_egeria/my_egeria/screens/governance_screen.py +++ b/my_egeria/my_egeria/screens/governance_screen.py @@ -14,8 +14,8 @@ from textual.containers import Container from .base_screen import BaseScreen -from my_egeria.DemoCode.Deprecated.utils.config import get_global_config -from my_egeria.DemoCode.Deprecated.utils.egeria_client import EgeriaTechClientManager +from my_egeria.utils.config import get_global_config +from my_egeria.utils.egeria_client import EgeriaTechClientManager class GovernanceScreen(BaseScreen): diff --git a/my_egeria/my_egeria/screens/login_screen.py b/my_egeria/my_egeria/screens/login_screen.py index 387d5821..bc77150a 100644 --- a/my_egeria/my_egeria/screens/login_screen.py +++ b/my_egeria/my_egeria/screens/login_screen.py @@ -13,7 +13,7 @@ from textual.message import Message from textual.widgets import Static, Input, Button from .base_screen import BaseScreen -from my_egeria.con_services.egeria_connection import connect_to_egeria +from ..con_services.egeria_connection import connect_to_egeria class LoginScreen(BaseScreen): diff --git a/my_egeria/my_egeria/screens/main_menu.py b/my_egeria/my_egeria/screens/main_menu.py index 8b722280..df5587b5 100644 --- a/my_egeria/my_egeria/screens/main_menu.py +++ b/my_egeria/my_egeria/screens/main_menu.py @@ -12,8 +12,8 @@ from textual.containers import Container, Vertical from textual import events from my_egeria.con_services.egeria_connection import EgeriaConnectionService -from my_egeria.DemoCode.Deprecated.widgets.popup import show_popup_and_exit -from my_egeria.DemoCode.Deprecated.screens.base_screen import BaseScreen +from my_egeria.widgets.popup import show_popup_and_exit +from my_egeria.screens.base_screen import BaseScreen class MainMenuScreen(BaseScreen): diff --git a/my_egeria/my_egeria/screens/platform/platform_services.py b/my_egeria/my_egeria/screens/platform/platform_services.py index 188f789c..96f16121 100644 --- a/my_egeria/my_egeria/screens/platform/platform_services.py +++ b/my_egeria/my_egeria/screens/platform/platform_services.py @@ -13,8 +13,8 @@ from textual.containers import Container from textual import events -from my_egeria.DemoCode.Deprecated.services.platform_service import PlatformServices -from my_egeria.DemoCode.Deprecated.widgets.popup import show_popup_and_exit +from my_egeria.services.platform_service import PlatformServices +from my_egeria.widgets.popup import show_popup_and_exit class PlatformServicesScreen(Screen): diff --git a/my_egeria/my_egeria/screens/platform/platforms_screen.py b/my_egeria/my_egeria/screens/platform/platforms_screen.py index f2cb1148..046bed54 100644 --- a/my_egeria/my_egeria/screens/platform/platforms_screen.py +++ b/my_egeria/my_egeria/screens/platform/platforms_screen.py @@ -11,8 +11,8 @@ from textual.widgets import Static, DataTable from textual.app import ComposeResult from textual.containers import Container -from my_egeria.DemoCode.Deprecated.utils.config import get_global_config -from my_egeria.DemoCode.Deprecated.utils.egeria_client import EgeriaTechClientManager +from my_egeria.utils.config import get_global_config +from my_egeria.utils.egeria_client import EgeriaTechClientManager from ..base_screen import BaseScreen diff --git a/my_egeria/my_egeria/screens/project_screen.py b/my_egeria/my_egeria/screens/project_screen.py index 813ddc55..de666249 100644 --- a/my_egeria/my_egeria/screens/project_screen.py +++ b/my_egeria/my_egeria/screens/project_screen.py @@ -10,9 +10,9 @@ from textual.widgets import DataTable from textual.containers import Container -from my_egeria.DemoCode.Deprecated.services.project_manager_service import ProjectManagerService +from my_egeria.services.project_manager_service import ProjectManagerService from .base_screen import BaseScreen -from my_egeria.DemoCode.Deprecated.widgets.back_button import BackButton +from my_egeria.widgets.back_button import BackButton class ProjectScreen(BaseScreen): CSS_PATH = ["/styles/common.css", "/styles/project_screen.css"] diff --git a/my_egeria/my_egeria/screens/subject_area_screen.py b/my_egeria/my_egeria/screens/subject_area_screen.py index df4da247..1942bdaa 100644 --- a/my_egeria/my_egeria/screens/subject_area_screen.py +++ b/my_egeria/my_egeria/screens/subject_area_screen.py @@ -11,8 +11,8 @@ from textual.screen import Screen from textual.widgets import DataTable, Header, Footer from textual.containers import Container -from my_egeria.DemoCode.Deprecated.widgets.back_button import BackButton -from my_egeria.DemoCode.Deprecated.services.subject_area_service import SubjectAreaService +from my_egeria.widgets.back_button import BackButton +from my_egeria.services.subject_area_service import SubjectAreaService class SubjectAreaScreen(Screen): diff --git a/my_egeria/my_egeria/services/base_service.py b/my_egeria/my_egeria/services/base_service.py index 5e69c65d..192b6ec3 100644 --- a/my_egeria/my_egeria/services/base_service.py +++ b/my_egeria/my_egeria/services/base_service.py @@ -9,8 +9,8 @@ """ from typing import Any, List, Dict, Optional, Tuple -from my_egeria.DemoCode.Deprecated.utils.egeria_client import EgeriaTechClientManager -from my_egeria.DemoCode.Deprecated.utils.config import EgeriaConfig, get_global_config +from my_egeria.utils.egeria_client import EgeriaTechClientManager +from my_egeria.utils.config import EgeriaConfig, get_global_config from os import getenv class BaseService: diff --git a/my_egeria/my_egeria/services/collection_service.py b/my_egeria/my_egeria/services/collection_service.py index 07ecd7ac..8d74c717 100644 --- a/my_egeria/my_egeria/services/collection_service.py +++ b/my_egeria/my_egeria/services/collection_service.py @@ -10,7 +10,7 @@ import asyncio from typing import Any, Dict, List, Optional from .base_service import BaseService -from my_egeria.DemoCode.Deprecated.utils.config import EgeriaConfig +from my_egeria.utils.config import EgeriaConfig class CollectionService(BaseService): """Wrapper around pyegeria collection functions with token-managed client.""" diff --git a/my_egeria/my_egeria/services/glossary_service.py b/my_egeria/my_egeria/services/glossary_service.py index 1d53316d..209f9c8e 100644 --- a/my_egeria/my_egeria/services/glossary_service.py +++ b/my_egeria/my_egeria/services/glossary_service.py @@ -15,7 +15,7 @@ import asyncio from typing import Any, Dict, List, Optional from .base_service import BaseService -from my_egeria.DemoCode.Deprecated.utils.config import EgeriaConfig +from my_egeria.utils.config import EgeriaConfig # Hook for tests to inject a mock client diff --git a/my_egeria/my_egeria/services/governance_officer_service.py b/my_egeria/my_egeria/services/governance_officer_service.py index 8d09174f..ae48699e 100644 --- a/my_egeria/my_egeria/services/governance_officer_service.py +++ b/my_egeria/my_egeria/services/governance_officer_service.py @@ -10,8 +10,8 @@ from typing import Optional from .base_service import BaseService -from my_egeria.DemoCode.Deprecated.utils.egeria_client import EgeriaTechClientManager -from my_egeria.DemoCode.Deprecated.utils.config import EgeriaConfig +from my_egeria.utils.egeria_client import EgeriaTechClientManager +from my_egeria.utils.config import EgeriaConfig class GovernanceOfficerService(BaseService): def __init__(self, config): diff --git a/my_egeria/my_egeria/services/product_manager_service.py b/my_egeria/my_egeria/services/product_manager_service.py index 74b0c203..32c8277c 100644 --- a/my_egeria/my_egeria/services/product_manager_service.py +++ b/my_egeria/my_egeria/services/product_manager_service.py @@ -5,9 +5,10 @@ """ -from typing import Optional +import asyncio +from typing import Any, Dict, List, Optional from .base_service import BaseService -from my_egeria.DemoCode.Deprecated.utils.config import EgeriaConfig +from my_egeria.utils.config import EgeriaConfig class ProductManagerService(BaseService): """Wrapper around pyegeria collection functions with token-managed client.""" diff --git a/my_egeria/my_egeria/services/project_manager_service.py b/my_egeria/my_egeria/services/project_manager_service.py index 0981f000..c10c8cf1 100644 --- a/my_egeria/my_egeria/services/project_manager_service.py +++ b/my_egeria/my_egeria/services/project_manager_service.py @@ -8,9 +8,10 @@ """ +import asyncio from typing import Any, Dict, List, Optional from .base_service import BaseService -from my_egeria.DemoCode.Deprecated.utils.config import EgeriaConfig +from my_egeria.utils.config import EgeriaConfig class ProjectManagerService(BaseService): """Wrapper around pyegeria collection functions with token-managed client.""" diff --git a/my_egeria/my_egeria/services/subject_area_service.py b/my_egeria/my_egeria/services/subject_area_service.py index 455be0af..d1b7cfac 100644 --- a/my_egeria/my_egeria/services/subject_area_service.py +++ b/my_egeria/my_egeria/services/subject_area_service.py @@ -7,7 +7,7 @@ """ -from my_egeria.DemoCode.Deprecated.services.base_service import BaseService +from my_egeria.services.base_service import BaseService class SubjectAreaService(BaseService): diff --git a/my_egeria/my_egeria/tests/test_login_screen.py b/my_egeria/my_egeria/tests/test_login_screen.py index f883995d..7ebf0135 100644 --- a/my_egeria/my_egeria/tests/test_login_screen.py +++ b/my_egeria/my_egeria/tests/test_login_screen.py @@ -8,7 +8,11 @@ """ +import pytest +from unittest.mock import patch from textual.app import App +from textual.widgets import Input, Button +from my_egeria.screens.login_screen import LoginScreen class LoginApp(App): diff --git a/my_egeria/my_egeria/tests/test_startup_check.py b/my_egeria/my_egeria/tests/test_startup_check.py index 2440f52c..1500c92d 100644 --- a/my_egeria/my_egeria/tests/test_startup_check.py +++ b/my_egeria/my_egeria/tests/test_startup_check.py @@ -10,7 +10,7 @@ import os import pytest -from my_egeria.startup_check import validate_envs, check_connection +from ..startup_check import validate_envs, check_connection def test_validate_envs_missing(monkeypatch): diff --git a/my_egeria/my_egeria/tests/test_term_details_screen.py b/my_egeria/my_egeria/tests/test_term_details_screen.py index e2ae04dd..fca5ccf2 100644 --- a/my_egeria/my_egeria/tests/test_term_details_screen.py +++ b/my_egeria/my_egeria/tests/test_term_details_screen.py @@ -9,6 +9,7 @@ """ from textual.app import App +from my_egeria.screens.glossary.term_details import TermDetailsScreen class TestApp(App): From f8b76d30da868270c4bc9153865c5e27d6c2565d Mon Sep 17 00:00:00 2001 From: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:46:45 -0500 Subject: [PATCH 05/14] Updates to Support Custom Report Spec Signed-off-by: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> --- config/config.json | 7 +++---- .../DemoCode/Experimental Code/my_network.py | 2 +- my_report_specs/my_profile_report_specs.json | 12 +++++------- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/config/config.json b/config/config.json index 6d3e25ad..08e0ce2e 100644 --- a/config/config.json +++ b/config/config.json @@ -28,7 +28,8 @@ "Pyegeria Publishing Root": "http://localhost:8085/outbox", "Pyegeria User Report Specs Dir": "~/.pyegeria/report_specs", "Pyegeria Report Spec Modules": [ - "pyegeria.view.analytic_demo_specs:get_analytic_demo_specs" + "pyegeria.view.analytic_demo_specs:get_analytic_demo_specs", + "~/my_report_specs/my_profile_report_specs.json" ] }, "feature_x_enabled": false, @@ -52,7 +53,5 @@ "Egeria Local Qualifier": "myLocal", "Egeria Usage Level": "Basic" }, - "Pyegeria Report Spec Modules": [ - "~/my_report_specs/my_profile_report_specs.json" - ] + } \ No newline at end of file diff --git a/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py b/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py index baa7da53..3eadf97b 100644 --- a/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py +++ b/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py @@ -113,7 +113,7 @@ def on_mount(self) -> None: try: eclient = Egeria(self.view_server, self.platform_url, self.user_name, self.user_password) token = eclient.create_egeria_bearer_token(self.user_name, self.user_password) - peers = eclient.exec_report_spec( + peers = exec_report_spec( format_set_name="My-User-Peers", output_format="DICT", ) diff --git a/my_report_specs/my_profile_report_specs.json b/my_report_specs/my_profile_report_specs.json index bf6d3cf4..73f18b1f 100644 --- a/my_report_specs/my_profile_report_specs.json +++ b/my_report_specs/my_profile_report_specs.json @@ -1,10 +1,4 @@ { - "//_comment1": "PDX-License-Identifier: Apache-2.0", - "//_comment2": "Copyright Contributors to the ODPi Egeria project.", - "//_comment3": " ", - "//_comment4": " This file provides the custom report specs for My Profile Textual App. ", - "//_comment5": " Author: Peter A Coldicott ", - "My-User-Peers": { "target_type": "Peer", "heading": "Peers", @@ -42,6 +36,10 @@ } ] } - ] + ], + "action": { + "function": "classification_manager.get_elements", + "metadataElementTypeName": ["Peer", "PeerProperties"] + } } } \ No newline at end of file From 121c9b200b934571200a55e556e1d669943e5d6b Mon Sep 17 00:00:00 2001 From: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:05:08 -0500 Subject: [PATCH 06/14] start of week clean up and synch Signed-off-by: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> --- config/config.json | 7 +- .../DemoCode/Experimental Code/my_network.py | 26 +- .../related_elements_return_data.json | 252 ++++++++++++++++++ my_report_specs/my_profile_report_specs.json | 8 +- .../test_classification_manager.py | 5 +- 5 files changed, 279 insertions(+), 19 deletions(-) create mode 100644 my_egeria/my_egeria/DemoCode/Experimental Code/related_elements_return_data.json diff --git a/config/config.json b/config/config.json index 08e0ce2e..d41ff2e2 100644 --- a/config/config.json +++ b/config/config.json @@ -29,7 +29,7 @@ "Pyegeria User Report Specs Dir": "~/.pyegeria/report_specs", "Pyegeria Report Spec Modules": [ "pyegeria.view.analytic_demo_specs:get_analytic_demo_specs", - "~/my_report_specs/my_profile_report_specs.json" + "/Users/petercoldicott/PycharmProjects/egeria/egeria-workspaces/egeria-python/my_report_specs/my_profile_report_specs.json" ] }, "feature_x_enabled": false, @@ -43,7 +43,7 @@ "console_logging_level": "ERROR", "enable_logging": true, "file_logging_level": "WARNING", - "log_directory": "/Users/dwolfson/localGit/egeria-v5-3/egeria-python/logs", + "log_directory": "/Users/petercoldicott/PycharmProjects/egeria/egeria-workspaces/egeria-python/logs", "logging_console_format": "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{line} - {message}", "logging_file_format": "{time:YYYY-MM-DD HH:mm:ss} | {level} | {module}-{function}:{line}-{message}-{extra}" }, @@ -52,6 +52,5 @@ "Egeria Home Glossary Name": "Egeria-Markdown", "Egeria Local Qualifier": "myLocal", "Egeria Usage Level": "Basic" - }, - + } } \ No newline at end of file diff --git a/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py b/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py index 3eadf97b..b6651c10 100644 --- a/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py +++ b/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py @@ -9,7 +9,6 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any -import asyncio from textual import on from textual.app import App, ComposeResult @@ -26,6 +25,9 @@ if str(current_dir) not in sys.path: sys.path.append(str(current_dir)) +import os +os.environ.setdefault("PYEGERIA_CONFIG_DIRECTORY", str(root_path / "config")) + from pyegeria import ( load_app_config, settings, @@ -56,7 +58,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.title = "My_Network" self.sub_title = "Egeria network of contacts for the current user" self.description = "Display the user's network of (Egeria) contacts." - load_app_config() + app_config = settings.Environment self.log(f"Application Config: {app_config}") app_user = settings.User_Profile @@ -79,11 +81,11 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: def on_mount(self) -> None: """Perform actions when the application is mounted.""" - self.my_network.add_column("Nickname", key="nickname") - self.my_network.add_column("User ID", key="user_id") - self.my_network.add_column("GUID", key="guid") + self.my_network.add_column("Label", key="label") + self.my_network.add_column("Scope", key="scope") + self.my_network.add_column("GUID", key="GUID") self.my_network.add_column("Qualified Name", key="qualified_name") - self.my_network.add_column("Notes", key="notes") + self.my_network.add_column("Description", key="description") self.my_network.cursor_type="row" self.my_network.zebra_stripes=True try: @@ -116,7 +118,9 @@ def on_mount(self) -> None: peers = exec_report_spec( format_set_name="My-User-Peers", output_format="DICT", - ) + params={"element_guid": self.user_GUID}, + token = token + ) self.log(f"Peers: {peers}") except PyegeriaException as e: self.log(f"Error creating peer relationship: {e}") @@ -125,13 +129,13 @@ def on_mount(self) -> None: finally: if eclient: eclient.close_session() - for peer in peers: + for peer in peers.get("data", []): self.log(f"Peer: {peer}") self.my_network.add_row(peer.get("Label"), - peer.get("uniqueName"), - peer.get("guid"), + peer.get("Scope"), + peer.get("GUID"), peer.get("Qualified Name"), - peer.get("relationshipProperties", "description")) + peer.get("Description")) continue self.my_network.refresh() container=self.query_one("#users_network_container", ScrollableContainer) diff --git a/my_egeria/my_egeria/DemoCode/Experimental Code/related_elements_return_data.json b/my_egeria/my_egeria/DemoCode/Experimental Code/related_elements_return_data.json new file mode 100644 index 00000000..df4798a0 --- /dev/null +++ b/my_egeria/my_egeria/DemoCode/Experimental Code/related_elements_return_data.json @@ -0,0 +1,252 @@ +{ + "class": "OpenMetadataRootElement", + "elementHeader": { + "class": "ElementHeader", + "headerVersion": 0, + "status": "ACTIVE", + "type": { + "typeId": "ac406bf8-e53e-49f1-9088-2af28bbbd285", + "typeName": "Person", + "superTypeNames": [ + "ActorProfile", + "Actor", + "Referenceable", + "OpenMetadataRoot" + ], + "typeVersion": 2, + "typeDescription": "An individual.", + "typeCategory": "ENTITY_DEF" + }, + "origin": { + "sourceServer": "qs-metadata-store", + "originCategory": "CONTENT_PACK", + "homeMetadataCollectionId": "655b1965-4c29-4a0e-8a5d-3f55a37b3799", + "homeMetadataCollectionName": "Coco Pharmaceuticals Combination", + "license": "Apache 2.0" + }, + "versions": { + "createdBy": "Egeria Project", + "updatedBy": "Egeria Project", + "maintainedBy": [ + "Egeria Project" + ], + "createTime": "2021-12-20T07:20:40.038+00:00", + "updateTime": "2021-12-20T07:20:40.038+00:00", + "version": 1787846084192 + }, + "guid": "8ba14282-6c51-48fa-bc68-fd241b19cad1", + "anchor": { + "class": "ElementClassification", + "headerVersion": 0, + "status": "ACTIVE", + "type": { + "typeId": "aa44f302-2e43-4669-a1e7-edaae414fc6e", + "typeName": "Anchors", + "typeVersion": 1, + "typeDescription": "Identifies the anchor entity for an element that is part of a large composite object such as an asset.", + "typeCategory": "CLASSIFICATION_DEF" + }, + "origin": { + "sourceServer": "qs-metadata-store", + "originCategory": "CONTENT_PACK", + "homeMetadataCollectionId": "655b1965-4c29-4a0e-8a5d-3f55a37b3799", + "homeMetadataCollectionName": "Coco Pharmaceuticals Combination" + }, + "versions": { + "createdBy": "Egeria Project", + "updatedBy": "Egeria Project", + "maintainedBy": [ + "Egeria Project" + ], + "createTime": "2021-12-20T07:20:40.038+00:00", + "updateTime": "2021-12-20T07:20:40.038+00:00", + "version": 1787846084192 + }, + "classificationOrigin": "ASSIGNED", + "classificationName": "Anchors", + "classificationProperties": { + "class": "AnchorsProperties", + "typeName": "Anchors", + "anchorTypeName": "Person", + "anchorDomainName": "Actor" + } + } + }, + "contactDetails": [ + { + "class": "RelatedMetadataElementSummary", + "relationshipHeader": { + "class": "ElementHeader", + "headerVersion": 0, + "status": "ACTIVE", + "type": { + "typeId": "6cb9af43-184e-4dfa-854a-1572bcf0fe75", + "typeName": "ContactThrough", + "typeVersion": 2, + "typeDescription": "he contact details associated with an actor profile.", + "typeCategory": "RELATIONSHIP_DEF" + }, + "origin": { + "sourceServer": "qs-metadata-store", + "originCategory": "CONTENT_PACK", + "homeMetadataCollectionId": "655b1965-4c29-4a0e-8a5d-3f55a37b3799", + "homeMetadataCollectionName": "Coco Pharmaceuticals Combination", + "license": "Apache 2.0" + }, + "versions": { + "createdBy": "Egeria Project", + "updatedBy": "Egeria Project", + "maintainedBy": [ + "Egeria Project" + ], + "createTime": "2021-12-20T07:20:40.038+00:00", + "updateTime": "2021-12-20T07:20:40.038+00:00", + "version": 1787846084192 + }, + "guid": "9758d8cb-e9a0-481e-aa5c-1aa21459d6dc" + }, + "relatedElement": { + "elementHeader": { + "class": "ElementHeader", + "headerVersion": 0, + "status": "ACTIVE", + "type": { + "typeId": "79296df8-645a-4ef7-a011-912d1cdcf75a", + "typeName": "ContactDetails", + "superTypeNames": [ + "Referenceable", + "OpenMetadataRoot" + ], + "typeVersion": 1, + "typeDescription": "Information on how to send a message to an individual or automated process.", + "typeCategory": "ENTITY_DEF" + }, + "origin": { + "sourceServer": "qs-metadata-store", + "originCategory": "CONTENT_PACK", + "homeMetadataCollectionId": "655b1965-4c29-4a0e-8a5d-3f55a37b3799", + "homeMetadataCollectionName": "Coco Pharmaceuticals Combination", + "license": "Apache 2.0" + }, + "versions": { + "createdBy": "Egeria Project", + "updatedBy": "Egeria Project", + "maintainedBy": [ + "Egeria Project" + ], + "createTime": "2021-12-20T07:20:40.038+00:00", + "updateTime": "2021-12-20T07:20:40.038+00:00", + "version": 1787846084192 + }, + "guid": "1e9e83c8-7d70-42f6-a49c-0b093ccdb807", + "anchor": { + "class": "ElementClassification", + "headerVersion": 0, + "status": "ACTIVE", + "type": { + "typeId": "aa44f302-2e43-4669-a1e7-edaae414fc6e", + "typeName": "Anchors", + "typeVersion": 1, + "typeDescription": "Identifies the anchor entity for an element that is part of a large composite object such as an asset.", + "typeCategory": "CLASSIFICATION_DEF" + }, + "origin": { + "sourceServer": "qs-metadata-store", + "originCategory": "CONTENT_PACK", + "homeMetadataCollectionId": "655b1965-4c29-4a0e-8a5d-3f55a37b3799", + "homeMetadataCollectionName": "Coco Pharmaceuticals Combination" + }, + "versions": { + "createdBy": "Egeria Project", + "updatedBy": "Egeria Project", + "maintainedBy": [ + "Egeria Project" + ], + "createTime": "2021-12-20T07:20:40.038+00:00", + "updateTime": "2021-12-20T07:20:40.038+00:00", + "version": 1787846084192 + }, + "classificationOrigin": "ASSIGNED", + "classificationName": "Anchors", + "classificationProperties": { + "class": "AnchorsProperties", + "typeName": "Anchors", + "anchorGUID": "8ba14282-6c51-48fa-bc68-fd241b19cad1", + "anchorTypeName": "Person", + "anchorDomainName": "Actor" + } + } + }, + "properties": { + "class": "ContactDetailsProperties", + "typeName": "ContactDetails", + "displayName": "Work Email Address", + "contactType": "Work Email", + "contactMethodType": "EMAIL", + "contactMethodService": "Email address assigned by Coco Pharmaceuticals.", + "contactMethodValue": "TessaTube@Coco-Pharmaceuticals.org" + } + }, + "relatedElementAtEnd1": false + } + ], + "relatedBy": { + "relationshipHeader": { + "class": "ElementHeader", + "headerVersion": 0, + "status": "ACTIVE", + "type": { + "typeId": "4a316abe-bccd-4d11-ad5a-4bfb4079b80b", + "typeName": "Peer", + "typeVersion": 2, + "typeDescription": "Relationship identifying a person's peer network.", + "typeCategory": "RELATIONSHIP_DEF" + }, + "origin": { + "sourceServer": "qs-metadata-store", + "originCategory": "LOCAL_COHORT", + "homeMetadataCollectionId": "9905c3cb-94c5-4494-9229-0d6f69c0b842", + "homeMetadataCollectionName": "qs-metadata-store" + }, + "versions": { + "createdBy": "tessatube", + "updatedBy": "tessatube", + "maintainedBy": [ + "tessatube" + ], + "createTime": "2026-10-06T17:42:03.814+00:00", + "updateTime": "2026-10-06T17:42:03.814+00:00", + "version": 1, + "lastRequestId": "4a59ea13-a10f-419d-a0d2-8ad50cdc94aa" + }, + "guid": "0e9c5f02-8797-4716-ba2d-945256092924" + }, + "relationshipProperties": { + "class": "PeerProperties", + "typeName": "Peer", + "description": "Testing Peers" + } + }, + "properties": { + "class": "PersonProperties", + "typeName": "Person", + "qualifiedName": "Person::USA::302145", + "displayName": "Tessa Tube", + "additionalProperties": { + "workLocation": "3", + "countryCode": "USA", + "organization": "Coco Pharmaceuticals Ltd", + "jobLevel": "7" + }, + "courtesyTitle": "Dr", + "initials": "T", + "givenNames": "Tessa", + "surname": "Tube", + "fullName": "Dr Tessa Tube", + "pronouns": "She/her/hers", + "jobTitle": "Lead Researcher and head of Salk Research Lab", + "employeeNumber": "302145", + "employeeType": "FTP" + }, + "mermaidGraph": "---\ntitle: Person - Tessa Tube [8ba14282-6c51-48fa-bc68-fd241b19cad1]\n---\nflowchart TD\n%%{init: {\"flowchart\": {\"htmlLabels\": false}} }%%\n\n1@{ shape: rounded, label: \"*Person*\n**Tessa Tube**\"}\n2@{ shape: rounded, label: \"*Contact Details*\n**Work Email Address**\"}\n1==>|\"Contact Through\"|2\nstyle 1 color:#000000, fill:#e0ab18, stroke:#004563\nstyle 2 color:#000000, fill:#39add1, stroke:#004563\n" + } \ No newline at end of file diff --git a/my_report_specs/my_profile_report_specs.json b/my_report_specs/my_profile_report_specs.json index 73f18b1f..84eaf42f 100644 --- a/my_report_specs/my_profile_report_specs.json +++ b/my_report_specs/my_profile_report_specs.json @@ -38,8 +38,12 @@ } ], "action": { - "function": "classification_manager.get_elements", - "metadataElementTypeName": ["Peer", "PeerProperties"] + "function": "ClassificationExplorer.get_related_elements", + "required_params": ["element_guid"], + "spec_params": { "relationship_type": "ResourceList", + "property_value": "Catalog Resource", + "property_names" : ["resourceUse"] + } } } } \ No newline at end of file diff --git a/tests/functional-tests/test_classification_manager.py b/tests/functional-tests/test_classification_manager.py index 1ae4762d..ccc2c897 100644 --- a/tests/functional-tests/test_classification_manager.py +++ b/tests/functional-tests/test_classification_manager.py @@ -590,14 +590,14 @@ def test_get_all_related_elements(): def test_get_related_elements(): # metadata_element_type = 'CertificationType' # element_guid = '2ce8d10d-08a2-4fca-b0c2-1d5a335d00fc' - element_guid = "acca7cf3-40d4-40fd-9922-5c74075e9651" + element_guid = "79bd39ed-6048-4731-85c4-3cde50f99a30" # metadata_element_type = "Organization" # metadata_element_type = "CSVFile" # metadata_element_type = "InformationSupplyChain" open_metadata_type_name = "Referenceable" # element_guid = "8dca6e76-d454-4344-9c93-faa837a1a898" # relationship_type = "DataContentForDataSet" - relationship_type = "CollectionMembership" + relationship_type = "Peer" # relationship_type = "InformationSupplyChainComposition" c_client = ClassificationExplorer(view_server, platform_url) try: @@ -605,6 +605,7 @@ def test_get_related_elements(): body = { "class": "ResultsRequestBody", "metadataElementTypeName": open_metadata_type_name, + "includeOnlyRelationships": ["ContactThrough"] } response = c_client.get_related_elements( element_guid, relationship_type, body=body From 9f2782ff00cffe6fc06abf2d55a6542aa932e6c5 Mon Sep 17 00:00:00 2001 From: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:53:08 -0500 Subject: [PATCH 07/14] feat(my_profile): add My Network option for managing peers Merge the experimental my_network.py functionality into the My Profile app as a "My Network" option: list the user's peers, search for people and add them, view a peer's contact methods and communities, and remove a peer (with confirmation) via unlink_peer_person. - network_handler.py: NetworkMixin doing the Egeria calls - MyNetworkScreen.py: network screen and remove-confirmation dialog - my_network.py: add Remove from Network button with confirmation, hide stale details after removal - my_profile_report_specs.json: fix invalid JSON, query the Peer relationship, and show the person's GUID - tests for the mixin, screen and option routing Signed-off-by: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> --- .../DemoCode/Experimental Code/my_network.py | 348 ++++++++++++++---- .../related_elements_return_data.json | 2 +- .../DemoCode/My_Profile/MainScreen.py | 1 + .../DemoCode/My_Profile/MyNetworkScreen.py | 272 ++++++++++++++ .../DemoCode/My_Profile/my_profile.tcss | 44 ++- .../DemoCode/My_Profile/my_profile_app.py | 5 +- .../DemoCode/My_Profile/network_handler.py | 186 ++++++++++ my_report_specs/my_profile_report_specs.json | 35 +- .../my_profile/test_my_profile_app.py | 9 + .../my_profile/test_network_handler.py | 267 ++++++++++++++ 10 files changed, 1061 insertions(+), 108 deletions(-) create mode 100644 my_egeria/my_egeria/DemoCode/My_Profile/MyNetworkScreen.py create mode 100644 my_egeria/my_egeria/DemoCode/My_Profile/network_handler.py create mode 100644 tests/micro-tests/my_profile/test_network_handler.py diff --git a/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py b/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py index b6651c10..2fe435aa 100644 --- a/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py +++ b/my_egeria/my_egeria/DemoCode/Experimental Code/my_network.py @@ -13,8 +13,10 @@ from textual import on from textual.app import App, ComposeResult from textual.binding import Binding -from textual.containers import ScrollableContainer -from textual.widgets import Header, Footer, Static, Placeholder, Input, DataTable, Button +from textual.containers import ScrollableContainer, Grid +from textual.css.query import NoMatches +from textual.screen import ModalScreen +from textual.widgets import Header, Footer, Static, Placeholder, Input, DataTable, Button, Label # Add the project root to sys.path to allow running this script from any directory root_path = Path(__file__).resolve().parents[4] @@ -37,6 +39,61 @@ exec_report_spec, Egeria, ) +class ConfirmRemoveScreen(ModalScreen[bool]): + """Modal dialog asking the user to confirm removing a peer from their network. + Dismisses with True if the user confirms, False otherwise.""" + + DEFAULT_CSS = """ + ConfirmRemoveScreen { + align: center middle; + } + #confirm_dialog { + grid-size: 2; + grid-gutter: 1 2; + grid-rows: 1fr 3; + padding: 0 1; + width: 60; + height: 11; + border: thick $background 80%; + background: $surface; + } + #confirm_question { + column-span: 2; + height: 1fr; + width: 1fr; + content-align: center middle; + } + #confirm_dialog Button { + width: 100%; + } + """ + + BINDINGS = [("escape", "cancel", "Cancel")] + + def __init__(self, question: str) -> None: + super().__init__() + self.question = question + + def compose(self) -> ComposeResult: + yield Grid( + Label(self.question, id="confirm_question"), + Button("Remove", variant="error", id="confirm_remove"), + Button("Cancel", variant="primary", id="confirm_cancel"), + id="confirm_dialog", + ) + + @on(Button.Pressed, "#confirm_remove") + def handle_confirm(self, event: Button.Pressed) -> None: + self.dismiss(True) + + @on(Button.Pressed, "#confirm_cancel") + def handle_cancel(self, event: Button.Pressed) -> None: + self.dismiss(False) + + def action_cancel(self) -> None: + self.dismiss(False) + + class MyNetworkApp(App): """My Network App. @@ -78,16 +135,21 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.nickname = "" self.description = "" self.selected_network_member = None + self.peer_qname = None, + self.peer_dname = None, + self.peer_fname = None, + self.peer_pronouns = None, + self.peer_guid = None, + self.peer_description = None + # GUID of the peer whose details are currently displayed (get_peer_data resets self.peer_guid) + self.details_peer_guid = None def on_mount(self) -> None: """Perform actions when the application is mounted.""" - self.my_network.add_column("Label", key="label") - self.my_network.add_column("Scope", key="scope") - self.my_network.add_column("GUID", key="GUID") - self.my_network.add_column("Qualified Name", key="qualified_name") - self.my_network.add_column("Description", key="description") + self.my_network.add_columns("Qualified Name", "Display Name", "Full Name", "Pronouns", "GUID", "Description") self.my_network.cursor_type="row" self.my_network.zebra_stripes=True + error_flag = 0 try: eclient = Egeria(self.view_server, self.platform_url, self.user_name, self.user_password) token=eclient.create_egeria_bearer_token(self.user_name, self.user_password) @@ -96,31 +158,39 @@ def on_mount(self) -> None: except PyegeriaException as e: self.log(f"Error: {e}") self.notify(f"Error, unable to retrieve user profile: {e}", severity="error", timeout=20) - self.exit(400) + error_flag = 1 finally: if eclient: eclient.close_session() - if self.my_profile: - if isinstance(self.my_profile, list): - self.user_GUID = self.my_profile[0].get("GUID") or None - else: - self.notify(f"User profile is not a list: {self.my_profile}", severity="error", timeout=20) + if error_flag == 1: + # Use the error_flag to see if there was an error and if so exit self.exit(400) + if isinstance(self.my_profile, list): + self.user_GUID = self.my_profile[0].get("GUID") or None else: - self.notify(f"User profile not found: {self.user_name}", severity="error", timeout=20) + self.notify(f"User profile is not a list: {self.my_profile}", severity="error", timeout=20) self.exit(400) + self.get_peer_data() + return - # Get existing peer relationship list + def get_peer_data(self): + # clear out DataTable + self.my_network.clear() + # Get existing peer relationship list try: eclient = Egeria(self.view_server, self.platform_url, self.user_name, self.user_password) token = eclient.create_egeria_bearer_token(self.user_name, self.user_password) - peers = exec_report_spec( - format_set_name="My-User-Peers", - output_format="DICT", - params={"element_guid": self.user_GUID}, - token = token - ) + open_metadata_type_name = "Referenceable" + relationship_type = "Peer" + body = { + "class": "ResultsRequestBody", + "metadataElementTypeName": open_metadata_type_name, + "includeOnlyRelationships": ["ContactThrough"] + } + peers = eclient.get_related_elements(element_guid=self.user_GUID, + relationship_type=relationship_type, + body=body) self.log(f"Peers: {peers}") except PyegeriaException as e: self.log(f"Error creating peer relationship: {e}") @@ -129,22 +199,61 @@ def on_mount(self) -> None: finally: if eclient: eclient.close_session() - for peer in peers.get("data", []): - self.log(f"Peer: {peer}") - self.my_network.add_row(peer.get("Label"), - peer.get("Scope"), - peer.get("GUID"), - peer.get("Qualified Name"), - peer.get("Description")) - continue + if peers != "No elements found": + for peer in peers: + self.log(f"Peer: {peer}") + peer_properties = peer.get("properties", None) + self.log(f"Peer Properties: {peer_properties}") + peer_related_by = peer.get("relatedBy", None) + self.log(f"Peer Related By: {peer_related_by}") + peer_relationship_properties = peer.get("relationshipProperties", None) + self.log(f"Peer Relationship Properties: {peer_relationship_properties}") + if peer_properties is not None: + if peer_properties.get("class") == "PersonProperties" and peer_properties.get("typeName") == "Person": + self.peer_qname = peer_properties.get("qualifiedName") + self.peer_dname = peer_properties.get("displayName") + self.peer_fname = peer_properties.get("fullName") + self.peer_pronouns = peer_properties.get("pronouns") + # the peer's own GUID is in its elementHeader - relatedBy describes the Peer relationship + self.peer_guid = (peer.get("elementHeader") or {}).get("guid") + if peer_relationship_properties is not None: + self.peer_description = peer_relationship_properties.get("description") + self.my_network.add_row(self.peer_qname, + self.peer_dname, + self.peer_fname, + self.peer_pronouns, + self.peer_guid, + self.peer_description) + self.my_network.cursor_type = "row" + # reset variables + self.peer_qname = None + self.peer_dname = None + self.peer_fname = None + self.peer_pronouns = None + self.peer_guid = None + self.peer_description = None + continue + continue + else: + self.notify(f"No peers found for user: {self.user_name}", timeout=15, severity="error") self.my_network.refresh() container=self.query_one("#users_network_container", ScrollableContainer) container.remove_children("#users_network_placeholder") container.mount(self.my_network) - container.mount(Button(label="User Details", id="user_details1", variant="primary")) - container.refresh() - self.my_network.refresh() - return + try: + self.query_one("#user_details1", Button) + except NoMatches: + self.log("Button 1 not found") + try: self.query_one("#user_details2", Button) + except NoMatches: + self.log("Button 2 not found") + container.mount(Button(label="User Details", id="user_details1", variant="primary")) + try: + self.query_one("#remove_peer_button", Button) + except NoMatches: + container.mount(Button(label="Remove from Network", id="remove_peer_button", variant="error")) + # container.refresh() + # self.my_network.refresh() def compose(self) -> ComposeResult: yield Header(show_clock=True) @@ -156,6 +265,7 @@ def compose(self) -> ComposeResult: Static("Search for user, enter the (partial) name to search for and press return:"), Input(placeholder="Name", id="user_search_input"), id="users_input_container") + yield ScrollableContainer(id="user_details_container") yield Footer() @on(Input.Submitted, "#user_search_input") @@ -215,7 +325,7 @@ def search_users(self, user_name: str) -> Any: return user_found @on(DataTable.RowSelected, "#users_found") - def on_users_found_row_selected(self, event: DataTable.RowSelected) -> None: + def handle_users_found_row_selected(self, event: DataTable.RowSelected) -> None: self.log(f"User selected: {event.row_key}") self.row_selected = event.row_key row_data = self.users_found.get_row(self.row_selected) @@ -254,7 +364,6 @@ def handle_link_peer_button(self, event: Button.Pressed): """Handle the submission of the link peer button. Since there is no validity check and both NickName and Description are optional we link the peer person without further upfront validation """ - self.my_network.add_row(self.nickname, self.selected_user_id, self.selected_GUID, self.selected_qualified_name) #add link to peer self.log(f"Owner GUID: {self.user_GUID} Peer selected GUID: {self.selected_GUID}") now = datetime.now(timezone.utc).isoformat(timespec="milliseconds") @@ -293,51 +402,111 @@ def handle_link_peer_button(self, event: Button.Pressed): self.query_one("#users_input_container").mount( Static("Search for user, enter the (partial) name to search for and press return:"), Input(placeholder="Name", id="user_search_input") - ) + ) self.query_one("#users_network_container").mount( Static("Peer person network"), - self.my_network, - Button(label="User Details", id="user_details2", variant="primary") - ) + self.my_network + ) + # update the data table to display the updated list of peers + # (get_peer_data also mounts the User Details button if it isn't already present) + self.get_peer_data() return - @on(DataTable.RowHighlighted, "#user_network_table") - def handle_network_member_selected(self,event: DataTable.RowHighlighted): + @on(DataTable.RowHighlighted, "#my_network") + def handle_network_member_highlighted(self,event: DataTable.RowHighlighted): self.log(f"Row highlighted: {event.row_key}") self.selected_network_member = event.row_key + return - @on(DataTable.RowSelected, "#user_network_table") + @on(DataTable.RowSelected, "#my_network") def handle_network_member_selected(self, event: DataTable.RowSelected): self.log(f"Row selected: {event.row_key}") self.selected_network_member = event.row_key + return @on(Button.Pressed, "#user_details1") def handle_user_details_1_button(self, event: Button.Pressed): if self.selected_network_member: selected_row_data = self.my_network.get_row(self.selected_network_member) + self.log(f"Selected row data: {selected_row_data}") self.show_user_details(selected_row_data) return else: self.notify(f"Please select a network member to view details before using the Button.", severity="warning", timeout=10) return - @on(Button.Pressed, "#user_details2") - def handle_user_details_2_button(self, event: Button.Pressed): - if self.selected_network_member: - selected_row_data = self.my_network.get_row(self.selected_network_member) - self.show_user_details(selected_row_data) + @on(Button.Pressed, "#remove_peer_button") + def handle_remove_peer_button(self, event: Button.Pressed): + """Remove the Peer relationship between the current user and the highlighted network member.""" + if not self.selected_network_member: + self.notify(f"Please select a network member to remove before using the Button.", severity="warning", timeout=10) return - else: - self.notify(f"Please select a network member to view details before using the Button.", severity="warning", - timeout=10) + selected_row_data = self.my_network.get_row(self.selected_network_member) + self.log(f"Selected row data for removal: {selected_row_data}") + peer_dname = selected_row_data[1] + peer_guid = selected_row_data[4] + if not peer_guid: + self.notify(f"No GUID found for network member {peer_dname}", severity="error", timeout=10) return + def confirm_removal(confirmed: bool | None) -> None: + if confirmed: + self.remove_peer(peer_guid, peer_dname) + else: + self.notify(f"{peer_dname} was not removed from your network", severity="information", timeout=5) + + self.push_screen(ConfirmRemoveScreen(f"Remove {peer_dname} from your network?"), confirm_removal) + return + + def remove_peer(self, peer_guid: str, peer_dname: str) -> None: + """Unlink the peer from the current user's profile and refresh the network display.""" + body = { + "class": "DeleteRelationshipRequestBody" + } + self.log(f"Unlinking peer person: body {body} Owner GUID: {self.user_GUID} and Peer GUID: {peer_guid}") + eclient = None + try: + eclient = Egeria(self.view_server, + self.platform_url, + self.user_name, + self.user_password) + token = eclient.create_egeria_bearer_token(self.user_name, self.user_password) + # same GUID order as link_peer_person: owner first, peer second + eclient.unlink_peer_person(self.user_GUID, + peer_guid, + body + ) + self.notify(f"{peer_dname} removed from your network") + except PyegeriaException as e: + self.log(f"Error unlinking peer person: {e}") + self.notify(f"Error removing {peer_dname} from your network: {e}", severity="error", timeout=15) + return + finally: + if eclient: + eclient.close_session() + # the removed row's key is no longer valid, so clear the selection before reloading the peers + self.selected_network_member = None + # hide the details panel if it is showing the peer that was just removed + if self.details_peer_guid == peer_guid: + self.query_one("#user_details_container", ScrollableContainer).display = False + self.details_peer_guid = None + self.get_peer_data() + return + def show_user_details(self, selected_row_data): """ Show both contact details and community membership details for the selected network member.""" - self.selected_network_member = selected_row_data - self.user_GUID = selected_row_data[2] - self.user_qname = selected_row_data[3] - self.user_nickname = selected_row_data[0] + # keep the peer's details separate from self.user_GUID, which is the logged-in user's own profile GUID + self.log(f"Selected row data: {selected_row_data}") + self.peer_qname = selected_row_data[0] + self.peer_dname = selected_row_data[1] + self.peer_fname = selected_row_data[2] + self.peer_pronouns = selected_row_data[3] + self.peer_guid = selected_row_data[4] + self.peer_description = selected_row_data[5] + self.log(f"Peer details: GUID={self.peer_guid}, qname={self.peer_qname}, full_name={self.peer_fname}, display_name={self.peer_dname}, pronouns={self.peer_pronouns}, description={self.peer_description}") + if not self.peer_guid: + self.notify(f"No GUID found for network member {self.peer_dname}", severity="error", timeout=10) + return #clear and set up the datatables self.my_user_contact_details.clear(columns=True) self.my_user_contact_details.add_columns("Name", "Method Type", "Contact Type", "Service", "Value", "GUID") @@ -347,7 +516,9 @@ def show_user_details(self, selected_row_data): self.my_user_communities.add_columns("Name", "Assignment Type", "Description", "GUID") self.my_user_communities.cursor_type = "row" self.my_user_communities.zebra_stripes=True - # retrieve the users contact information + # retrieve the peer's profile and extract contact methods and communities using the + # My-User report spec (whose detail specs are My-User-Contact-Detail / My-User-Communities-Detail) + eclient = None try: eclient=Egeria( self.view_server, @@ -355,36 +526,53 @@ def show_user_details(self, selected_row_data): self.user_name, self.user_password) token=eclient.create_egeria_bearer_token(self.user_name, self.user_password) - self.user_contact_details = exec_report_spec( - format_set_name="My-User-Contact-Detail") - self.log(f"Retrieved user contact details: {self.user_contact_details}") + peer_profile = eclient.get_actor_profile_by_guid(self.peer_guid, output_format="JSON") + peer_details = eclient._generate_my_profile_output(peer_profile, output_format="DICT", + report_spec="My-User") + self.log(f"Retrieved peer profile details: {peer_details}") except Exception as e: - self.notify(f"Error retrieving user contact details: {str(e)}", severity="error", timeout=10) - return - try: - self.user_communities = exec_report_spec( - format_set_name="My-User-Communities-Detail") - self.log(f"Retrieved user community memberships: {self.user_communities}") - except Exception as e: - self.notify(f"Error retrieving user community memberships: {str(e)}", severity="error", timeout=10) + self.notify(f"Error retrieving details for {self.peer_dname}: {str(e)}", severity="error", timeout=10) return finally: if eclient: eclient.close_session() - if isinstance(self.user_contact_details, list): - for contact in self.user_contact_details: - self.my_user_contact_details.add_row(contact.get("Name"), - contact.get("Method Type"), - contact.get("Contact Type"), - contact.get("Service"), - contact.get("Value"), - contact.get("GUID")) - if isinstance(self.user_communities, list): - for community in self.user_communities: - self.my_user_communities.add_row(community.get("Name"), - community.get("Assignment Type"), - community.get("Desacription"), - community.get("GUID")) + if isinstance(peer_details, list) and peer_details: + peer_details = peer_details[0] + if not isinstance(peer_details, dict): + self.notify(f"No profile details found for {self.peer_dname}", severity="warning", timeout=10) + return + self.user_contact_details = peer_details.get("Contact Methods") or [] + self.user_communities = peer_details.get("Communities") or [] + self.log(f"User details: {self.user_contact_details}") + self.log(f"User communities: {self.user_communities}") + for contact in self.user_contact_details: + self.my_user_contact_details.add_row(contact.get("name"), + contact.get("contactMethodType"), + contact.get("contactType"), + contact.get("contactMethodService"), + contact.get("contactMethodValue"), + contact.get("guid")) + for community in self.user_communities: + self.my_user_communities.add_row(community.get("name"), + community.get("assignmentType"), + community.get("description"), + community.get("guid")) + # display the tables, mounting them the first time through + container = self.query_one("#user_details_container", ScrollableContainer) + container.display = True + self.details_peer_guid = self.peer_guid + try: + container.query_one("#user_details_heading", Static).update(f"Details for {self.peer_dname}") + except NoMatches: + container.mount(Static(f"Details for {self.peer_dname}", id="user_details_heading"), + Static("Contact Methods:"), + self.my_user_contact_details, + Static("Communities:"), + self.my_user_communities) + if not self.user_contact_details: + self.notify(f"No contact methods found for {self.peer_dname}", severity="information", timeout=10) + if not self.user_communities: + self.notify(f"No community memberships found for {self.peer_dname}", severity="information", timeout=10) return def main() -> None: diff --git a/my_egeria/my_egeria/DemoCode/Experimental Code/related_elements_return_data.json b/my_egeria/my_egeria/DemoCode/Experimental Code/related_elements_return_data.json index df4798a0..5652e33a 100644 --- a/my_egeria/my_egeria/DemoCode/Experimental Code/related_elements_return_data.json +++ b/my_egeria/my_egeria/DemoCode/Experimental Code/related_elements_return_data.json @@ -72,7 +72,7 @@ } } }, - "contactDetails": [ + "contactDetails":[ { "class": "RelatedMetadataElementSummary", "relationshipHeader": { diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py index 4184fe39..9b183932 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py @@ -60,6 +60,7 @@ def compose(self) -> ComposeResult: Option("[] Subscriptions"), Option("[] Technology Types"), Option("[] User Bookmarks"), + Option("[] My Network"), Option("[] Leave Feedback"), Option("[] Feedback Log"), id="other_function_list" diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/MyNetworkScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/MyNetworkScreen.py new file mode 100644 index 00000000..b2f42a50 --- /dev/null +++ b/my_egeria/my_egeria/DemoCode/My_Profile/MyNetworkScreen.py @@ -0,0 +1,272 @@ +""" + PDX-License-Identifier: Apache-2.0 + Copyright Contributors to the ODPi Egeria project. + + This file provides the My Network screen for My Profile. + +""" +from textual import on +from textual.app import ComposeResult +from textual.containers import ScrollableContainer, Container, Horizontal +from textual.screen import ModalScreen +from textual.widgets import DataTable, Header, Static, Footer, Input, Button + + +class ConfirmRemovePeerScreen(ModalScreen[bool]): + """Confirmation prompt shown before a person is removed from the user's network. + + Dismisses with True to proceed with the removal, False to cancel. + """ + + BINDINGS = [("escape", "cancel", "Cancel")] + + CSS_PATH = "my_profile.tcss" + + def __init__(self, peer_name: str, *args, **kwargs): + super().__init__(*args, **kwargs, id="confirm_remove_peer_screen") + self.peer_name = peer_name + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + yield Static("Confirm Removal", classes="span-3", id="confirm_remove_peer_title") + yield ScrollableContainer( + Static(f"Remove {self.peer_name} from your network?"), + Static("Their profile is not changed, only the link between you."), + Horizontal( + Button("Remove", id="confirm_remove_peer_btn", variant="error"), + Button("Cancel", id="cancel_remove_peer_btn", variant="primary"), + ), + id="confirm_remove_peer_container", + ) + yield Footer() + + def on_mount(self) -> None: + self.title = "Egeria - my_profile" + self.sub_title = "Confirm Removal - My Network" + + @on(Button.Pressed, "#confirm_remove_peer_btn") + def handle_confirm_button(self, event: Button.Pressed) -> None: + self.dismiss(True) + + @on(Button.Pressed, "#cancel_remove_peer_btn") + def handle_cancel_button(self, event: Button.Pressed) -> None: + self.dismiss(False) + + def action_cancel(self) -> None: + self.dismiss(False) + + +class MyNetworkScreen(ModalScreen): + """List the current user's network of peers, add people to it and remove them. + + The Egeria work is done by the app's NetworkMixin (list_my_peers, search_people, + link_peer, unlink_peer, get_peer_details); this screen only drives it. + """ + + BINDINGS = [ + ("q", "quit", "Quit"), + ("v", "view_details", "View Highlighted Person"), + ("d", "remove_peer", "Remove Highlighted Person"), + ] + + CSS_PATH = "my_profile.tcss" + + def __init__(self, my_peers, *args, **kwargs): + super().__init__(id="my_network_screen", *args, **kwargs) + self.title = "Egeria" + self.sub_title = "My Network" + # Rows of (qualified name, display name, full name, pronouns, GUID, description) + self.my_peers_data = list(my_peers or []) + # (display name, GUID) of the person picked from the search results to add + self.selected_person = None + # GUID of the peer whose details are currently displayed + self.details_peer_guid = None + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + yield Static("My Network", classes="span-3", id="my_network_title") + yield ScrollableContainer( + Static("The people in your network. 'v' shows the highlighted person's details; 'd' removes them."), + DataTable(id="my_network_table", zebra_stripes=True, cursor_type="row"), + Horizontal( + Button("Person Details", id="peer_details_btn", variant="primary"), + Button("Remove from Network", id="remove_peer_btn", variant="error"), + id="my_network_buttons", + ), + id="my_network_container", + ) + yield ScrollableContainer( + Static("Add to your network: enter a (partial) name and press return"), + Input(placeholder="Name", id="people_search_input"), + DataTable(id="people_found_table", zebra_stripes=True, cursor_type="row"), + Container(id="add_peer_container"), + id="people_search_container", + ) + yield ScrollableContainer( + Static("", id="peer_details_heading"), + Static("Contact Methods:"), + DataTable(id="peer_contacts_table", zebra_stripes=True, cursor_type="row"), + Static("Communities:"), + DataTable(id="peer_communities_table", zebra_stripes=True, cursor_type="row"), + id="peer_details_container", + ) + yield Footer() + + def on_mount(self) -> None: + self.query_one("#my_network_table", DataTable).add_columns( + "Qualified Name", "Display Name", "Full Name", "Pronouns", "GUID", "Description") + self.query_one("#people_found_table", DataTable).add_columns("Name", "GUID", "Qualified Name") + self.query_one("#peer_contacts_table", DataTable).add_columns( + "Name", "Method Type", "Contact Type", "Service", "Value", "GUID") + self.query_one("#peer_communities_table", DataTable).add_columns( + "Name", "Assignment Type", "Description", "GUID") + self.query_one("#peer_details_container").display = False + self._fill_table() + + def _fill_table(self) -> None: + table = self.query_one("#my_network_table", DataTable) + table.clear() + for row in self.my_peers_data: + table.add_row(*row, key=row[4]) + if not self.my_peers_data: + self.notify("There is no-one in your network yet - search for someone to add them", timeout=5) + + def _reload(self) -> None: + rows = self.app.list_my_peers() + if rows is not None: + self.my_peers_data = rows + self._fill_table() + + def _highlighted_peer(self) -> tuple[str, str] | None: + """(display name, GUID) of the highlighted network row, or None if the network is empty.""" + table = self.query_one("#my_network_table", DataTable) + if table.row_count == 0: + self.notify("There is no-one in your network to select", timeout=5, severity="warning") + return None + row = table.get_row_at(table.cursor_row) + return row[1] or row[0], row[4] + + def action_quit(self) -> None: + """ The user elects to quit the network function """ + self.dismiss(200) + + @on(Button.Pressed, "#peer_details_btn") + def handle_peer_details_button(self, event: Button.Pressed) -> None: + self.action_view_details() + + def action_view_details(self) -> None: + """ Show the contact methods and communities of the highlighted peer """ + peer = self._highlighted_peer() + if not peer: + return + peer_name, peer_guid = peer + details = self.app.get_peer_details(peer_guid) + if details is None: + return + contacts, communities = details + contacts_table = self.query_one("#peer_contacts_table", DataTable) + contacts_table.clear() + for contact in contacts: + contacts_table.add_row(contact.get("name"), + contact.get("contactMethodType"), + contact.get("contactType"), + contact.get("contactMethodService"), + contact.get("contactMethodValue"), + contact.get("guid")) + communities_table = self.query_one("#peer_communities_table", DataTable) + communities_table.clear() + for community in communities: + communities_table.add_row(community.get("name"), + community.get("assignmentType"), + community.get("description"), + community.get("guid")) + self.query_one("#peer_details_heading", Static).update(f"[b]Details for {peer_name}[/b]") + self.query_one("#peer_details_container").display = True + self.details_peer_guid = peer_guid + if not contacts: + self.notify(f"No contact methods found for {peer_name}", timeout=10) + if not communities: + self.notify(f"No community memberships found for {peer_name}", timeout=10) + + @on(Button.Pressed, "#remove_peer_btn") + def handle_remove_peer_button(self, event: Button.Pressed) -> None: + self.action_remove_peer() + + def action_remove_peer(self) -> None: + """ The user wants to remove the highlighted peer, after confirmation """ + peer = self._highlighted_peer() + if not peer: + return + peer_name, peer_guid = peer + + def on_confirm(confirmed: bool | None) -> None: + if not confirmed: + self.notify(f"{peer_name} was not removed from your network", timeout=5) + return + if self.app.unlink_peer(peer_guid): + # hide the details panel if it is showing the person just removed + if self.details_peer_guid == peer_guid: + self.query_one("#peer_details_container").display = False + self.details_peer_guid = None + self._reload() + + self.app.push_screen(ConfirmRemovePeerScreen(peer_name), callback=on_confirm) + + @on(Input.Submitted, "#people_search_input") + def handle_people_search(self, event: Input.Submitted) -> None: + search_string = event.value.strip() + if not search_string: + self.notify("Enter a (partial) name to search for", timeout=5, severity="warning") + return + found = self.app.search_people(search_string) + if found is None: + return + table = self.query_one("#people_found_table", DataTable) + table.clear() + in_network = {row[4] for row in self.my_peers_data} + for name, guid, qualified_name in found: + if guid not in in_network: + table.add_row(name, guid, qualified_name, key=guid) + if table.row_count == 0: + self.notify(f"No-one new found matching {search_string}", timeout=5, severity="warning") + else: + self.notify("Select a person to add them to your network", timeout=5) + table.focus() + + @on(DataTable.RowSelected, "#people_found_table") + def handle_person_selected(self, event: DataTable.RowSelected) -> None: + name, guid, qualified_name = event.data_table.get_row(event.row_key) + self.selected_person = (name, guid) + container = self.query_one("#add_peer_container", Container) + container.remove_children() + container.mount( + Static(f"Add [b]{name}[/b] ({qualified_name}) to your network"), + Input(placeholder="Nickname (optional)", id="peer_nickname_input"), + Input(placeholder="Description/comment about this relationship (optional)", id="peer_description_input"), + Horizontal( + Button("Add to Network", id="add_peer_btn", variant="primary"), + Button("Cancel", id="cancel_add_peer_btn", variant="warning"), + ), + ) + self.query_one("#peer_nickname_input", Input).focus() + + @on(Button.Pressed, "#add_peer_btn") + def handle_add_peer_button(self, event: Button.Pressed) -> None: + if not self.selected_person: + return + nickname = self.query_one("#peer_nickname_input", Input).value.strip() + description = self.query_one("#peer_description_input", Input).value.strip() + if self.app.link_peer(self.selected_person[1], nickname, description): + self._clear_search() + self._reload() + + @on(Button.Pressed, "#cancel_add_peer_btn") + def handle_cancel_add_peer_button(self, event: Button.Pressed) -> None: + self.query_one("#add_peer_container", Container).remove_children() + self.selected_person = None + + def _clear_search(self) -> None: + self.query_one("#add_peer_container", Container).remove_children() + self.query_one("#people_found_table", DataTable).clear() + self.query_one("#people_search_input", Input).clear() + self.selected_person = None 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 5793f19d..705c8252 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/my_profile.tcss +++ b/my_egeria/my_egeria/DemoCode/My_Profile/my_profile.tcss @@ -62,11 +62,52 @@ FeedbackScreen, FeedbackLogScreen { background: $background; } +/* Title row, then network + search side by side, then the selected peer's details */ +MyNetworkScreen { + grid-rows: auto 1fr 1fr; + background: $background; +} + +#my_network_container { + column-span: 2; + width: 100%; + height: 100%; +} + +#people_search_container { + width: 100%; + height: 100%; +} + +#peer_details_container { + column-span: 3; + width: 100%; + height: 100%; +} + +#my_network_table { + width: 100%; + height: 1fr; +} + +#my_network_buttons, +#add_peer_container, +#add_peer_container Horizontal { + height: auto; +} + +#people_found_table, +#peer_contacts_table, +#peer_communities_table { + height: auto; + max-height: 12; +} + 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, #feedback_title, #feedback_log_title, .span-3 { +#my_network_title, #confirm_remove_peer_title, #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; @@ -96,6 +137,7 @@ ModalScreen > Header, ModalScreen > Footer { #edit_projects_container, #edit_todos_container, #confirm_delete_container, +#confirm_remove_peer_container, #status_container, #user_identities_container, #generic_data_view_container, 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 17fea454..2b3674bf 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 @@ -88,10 +88,11 @@ from feedback_handler import FeedbackMixin, FEEDBACK_LOG_OWNER from bookmarks_handler import BookmarksMixin, bookmarks_qualified_name from MyBookMarksScreen import MyBookMarksScreen +from network_handler import NetworkMixin class MyProfileApp(App, TechTypesMixin, ShopForDataMixin, TeamRolesMixin, ElementsCrudMixin, FeedbackMixin, - BookmarksMixin): + BookmarksMixin, NetworkMixin): """My Profile App. Retrieves a user's profile from Egeria and displays current work items. @@ -678,6 +679,8 @@ async def handle_option_selected(self, event: OptionList.OptionSelected) -> None ) elif selected_option == "User Bookmarks": self.show_my_bookmarks() + elif selected_option == "My Network": + self.show_my_network() elif selected_option == "Subscriptions": await self.push_screen(ViewSubscriptionsScreen(), callback=self.view_subscriptions_callback) elif selected_option == "Leave Feedback": diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/network_handler.py b/my_egeria/my_egeria/DemoCode/My_Profile/network_handler.py new file mode 100644 index 00000000..aa1a27d8 --- /dev/null +++ b/my_egeria/my_egeria/DemoCode/My_Profile/network_handler.py @@ -0,0 +1,186 @@ +""" + PDX-License-Identifier: Apache-2.0 + Copyright Contributors to the ODPi Egeria project. + + My Network handler mixin for My Profile Textual App. + + A user's network is the set of people linked to their profile by a Peer + relationship. Peers are found with find_actor_profiles, linked with + link_peer_person and removed with unlink_peer_person. +""" + +import sys +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 MyNetworkScreen import MyNetworkScreen + +PEER_RELATIONSHIP = "Peer" + + +class NetworkMixin: + """Mixin class providing the My Network feature for MyProfileApp.""" + + def _network_client(self) -> Egeria: + client = Egeria(view_server=self.view_server, platform_url=self.platform_url, + user_id=self.user_name, user_pwd=self.user_password) + client.create_egeria_bearer_token(self.user_name, self.user_password) + return client + + def list_my_peers(self) -> list[tuple[str, str, str, str, str, str]] | None: + """The user's peers as (qualified name, display name, full name, pronouns, GUID, description) rows, + or None if Egeria could not be reached.""" + client = None + try: + client = self._network_client() + body = { + "class": "ResultsRequestBody", + "metadataElementTypeName": "Referenceable", + "includeOnlyRelationships": ["ContactThrough"], + } + peers = client.get_related_elements(element_guid=self.user_GUID, + relationship_type=PEER_RELATIONSHIP, + body=body) + except PyegeriaException as e: + self.log(f"Retrieving peers failed: {e}") + self.notify(f"Could not retrieve your network: {e}", timeout=10, severity="error") + return None + finally: + if client: + client.close_session() + self.log(f"Peers: {peers}") + rows = [] + # "No elements found" is returned, rather than an exception, when the user has no peers + for peer in peers if isinstance(peers, list) else []: + properties = peer.get("properties") or {} + if properties.get("class") != "PersonProperties": + continue + # the peer's own GUID is in its elementHeader - relatedBy describes the Peer relationship + guid = (peer.get("elementHeader") or {}).get("guid") + if not guid: + continue + relationship_properties = peer.get("relationshipProperties") or {} + rows.append((properties.get("qualifiedName") or "", + properties.get("displayName") or "", + properties.get("fullName") or "", + properties.get("pronouns") or "", + guid, + relationship_properties.get("description") or "")) + return rows + + def show_my_network(self) -> None: + """Show the My Network screen for the current user.""" + if not getattr(self, "user_GUID", ""): + self.notify("Your profile has not loaded yet, so your network can't be shown", timeout=10, + severity="warning") + return + peers = self.list_my_peers() + if peers is None: + return + self.push_screen(MyNetworkScreen(peers), callback=self.my_network_callback) + + def my_network_callback(self, result: Any) -> None: + self.log(f"My Network screen returned: {result}") + + def search_people(self, search_string: str) -> list[tuple[str, str, str]] | None: + """People whose profile matches search_string, as (display name, GUID, qualified name) rows, + or None if Egeria could not be reached.""" + client = None + try: + client = self._network_client() + found = client.find_actor_profiles(search_string=search_string, ignore_case=True, + output_format="DICT", report_spec="User-Identities", + query_depth=0, page_size=10) + except PyegeriaException as e: + self.log(f"Searching for {search_string} failed: {e}") + self.notify(f"Searching for people failed: {e}", timeout=10, severity="error") + return None + finally: + if client: + client.close_session() + self.log(f"People found for {search_string}: {found}") + if isinstance(found, dict): + found = [found] + rows = [] + for person in found if isinstance(found, list) else []: + guid = person.get("GUID") + # the current user can't be their own peer + if guid and guid != self.user_GUID: + rows.append((person.get("Display Name") or "", guid, person.get("Qualified Name") or "")) + return rows + + def link_peer(self, peer_guid: str, nickname: str = "", description: str = "") -> bool: + """Add the person with the given GUID to the user's network. Returns True on success.""" + body = { + "class": "NewRelationshipRequestBody", + "properties": { + "class": "PeerProperties", + "peer_relationship_name": nickname, + "description": description, + "effective_from": None, + "effective_to": None, + } + } + client = None + try: + client = self._network_client() + client.link_peer_person(self.user_GUID, peer_guid, body) + self.notify("Added to your network", timeout=10, severity="information") + return True + except PyegeriaException as e: + self.log(f"Linking peer {peer_guid} failed: {e}") + self.notify(f"Adding to your network failed: {e}", timeout=10, severity="error") + return False + finally: + if client: + client.close_session() + + def unlink_peer(self, peer_guid: str) -> bool: + """Remove the person with the given GUID from the user's network. Returns True on success.""" + client = None + try: + client = self._network_client() + # same GUID order as link_peer_person: owner first, peer second + client.unlink_peer_person(self.user_GUID, peer_guid, {"class": "DeleteRelationshipRequestBody"}) + self.notify("Removed from your network", timeout=10, severity="information") + return True + except PyegeriaException as e: + self.log(f"Unlinking peer {peer_guid} failed: {e}") + self.notify(f"Removing from your network failed: {e}", timeout=10, severity="error") + return False + finally: + if client: + client.close_session() + + def get_peer_details(self, peer_guid: str) -> tuple[list[dict], list[dict]] | None: + """The peer's (contact methods, communities), or None if they could not be retrieved.""" + client = None + try: + client = self._network_client() + profile = client.get_actor_profile_by_guid(peer_guid, output_format="JSON") + # My-User's detail specs are My-User-Contact-Detail / My-User-Communities-Detail + details = client._generate_my_profile_output(profile, output_format="DICT", report_spec="My-User") + except PyegeriaException as e: + self.log(f"Retrieving details for peer {peer_guid} failed: {e}") + self.notify(f"Could not retrieve the details: {e}", timeout=10, severity="error") + return None + finally: + if client: + client.close_session() + self.log(f"Peer details: {details}") + if isinstance(details, list) and details: + details = details[0] + if not isinstance(details, dict): + self.notify("No profile details found", timeout=10, severity="warning") + return None + return details.get("Contact Methods") or [], details.get("Communities") or [] diff --git a/my_report_specs/my_profile_report_specs.json b/my_report_specs/my_profile_report_specs.json index 84eaf42f..cb88fd3d 100644 --- a/my_report_specs/my_profile_report_specs.json +++ b/my_report_specs/my_profile_report_specs.json @@ -6,33 +6,19 @@ "family": "MyProfile", "formats": [ { - "types": [ - "LIST", - "REPORT", - "DICT", - "TABLE" - ], + "types": ["LIST", "REPORT", "DICT", "TABLE"], "attributes": [ { - "name": "Label", - "key": "label" + "name": "Peer", + "key": "relatedBy.relationshipHeader.type.typeName" }, { - "name": "Qualified Name", - "key": "uniqueName" + "name": "guid", + "key": "elementHeader.guid" }, { - "name": "Scope", - "key": "scope" - }, - { - "name": "Description", - "key": "relationshipProperties.description" - }, - { - "name": "GUID", - "key": "guid", - "format": "True" + "name": "peerProperties", + "key": "relatedBy.relationshipProperties.class" } ] } @@ -40,10 +26,9 @@ "action": { "function": "ClassificationExplorer.get_related_elements", "required_params": ["element_guid"], - "spec_params": { "relationship_type": "ResourceList", - "property_value": "Catalog Resource", - "property_names" : ["resourceUse"] + "spec_params": { + "relationship_type": "Peer" } } } -} \ No newline at end of file +} 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 caf2f476..ce5d9b6d 100644 --- a/tests/micro-tests/my_profile/test_my_profile_app.py +++ b/tests/micro-tests/my_profile/test_my_profile_app.py @@ -256,6 +256,15 @@ async def test_handle_option_selected(self, option_name, handler_attr, screen_cl call_args = app.push_screen.call_args[0] assert isinstance(call_args[0], screen_cls) + @pytest.mark.asyncio + async def test_my_network_option_shows_network(self): + app = MyProfileApp() + app.show_my_network = MagicMock() + event = MagicMock() + event.option.prompt = "[] My Network" + await app.handle_option_selected(event) + app.show_my_network.assert_called_once_with() + def test_status_callback(self): """Closing a status screen returns to the main screen; it must not end the app.""" app = MyProfileApp() diff --git a/tests/micro-tests/my_profile/test_network_handler.py b/tests/micro-tests/my_profile/test_network_handler.py new file mode 100644 index 00000000..04b78245 --- /dev/null +++ b/tests/micro-tests/my_profile/test_network_handler.py @@ -0,0 +1,267 @@ +""" + PDX-License-Identifier: Apache-2.0 + Copyright Contributors to the ODPi Egeria project. + + Unit tests for the My Network mixin and screen (a user's network is the people + linked to their profile by a Peer relationship). +""" + +from unittest.mock import MagicMock, patch + +import pytest +from textual.app import App +from textual.widgets import DataTable, Input, Button + +from network_handler import NetworkMixin +from MyNetworkScreen import MyNetworkScreen, ConfirmRemovePeerScreen +from pyegeria import PyegeriaException + + +def _peer(guid, display_name, description="", type_class="PersonProperties"): + return {"elementHeader": {"guid": guid}, + "properties": {"class": type_class, "qualifiedName": f"Person::{guid}", "displayName": display_name, + "fullName": f"{display_name} Full", "pronouns": "they/them"}, + "relatedBy": {"relationshipHeader": {"guid": f"rel-{guid}"}}, + "relationshipProperties": {"description": description}} + + +class DummyApp(NetworkMixin): + def __init__(self): + self.view_server = "qs-view-server" + self.platform_url = "https://127.0.0.1:9443" + self.user_name = "garygeeke" + self.user_password = "secret" + self.user_GUID = "profile-guid" + self.notices = [] + self.pushed = [] + + def log(self, *args, **kwargs): + pass + + def notify(self, message, **kwargs): + self.notices.append((message, kwargs.get("severity"))) + + def push_screen(self, screen, callback=None): + self.pushed.append((screen, callback)) + + +@pytest.fixture +def client(): + with patch("network_handler.Egeria") as mock_egeria: + yield mock_egeria.return_value + + +class TestNetworkMixin: + def test_list_returns_peer_rows_with_person_guid(self, client): + client.get_related_elements.return_value = [ + _peer("erin-guid", "Erin", "Governance buddy"), + _peer("thing-guid", "Not a person", type_class="AssetProperties"), + ] + rows = DummyApp().list_my_peers() + assert client.get_related_elements.call_args.kwargs["element_guid"] == "profile-guid" + assert client.get_related_elements.call_args.kwargs["relationship_type"] == "Peer" + # The person's own GUID, not the relationship's; non-people are skipped + assert rows == [("Person::erin-guid", "Erin", "Erin Full", "they/them", "erin-guid", "Governance buddy")] + client.close_session.assert_called_once() + + def test_list_without_peers_is_empty(self, client): + client.get_related_elements.return_value = "No elements found" + assert DummyApp().list_my_peers() == [] + + def test_list_error_returns_none(self, client): + client.get_related_elements.side_effect = PyegeriaException("boom") + app = DummyApp() + assert app.list_my_peers() is None + assert app.notices[-1][1] == "error" + + def test_show_pushes_screen(self, client): + client.get_related_elements.return_value = [_peer("erin-guid", "Erin")] + app = DummyApp() + app.show_my_network() + screen, callback = app.pushed[-1] + assert isinstance(screen, MyNetworkScreen) + assert screen.my_peers_data[0][4] == "erin-guid" + + def test_show_without_profile_is_refused(self, client): + app = DummyApp() + app.user_GUID = "" + app.show_my_network() + assert app.pushed == [] + client.get_related_elements.assert_not_called() + assert app.notices[-1][1] == "warning" + + def test_search_excludes_current_user(self, client): + client.find_actor_profiles.return_value = [ + {"Display Name": "Tanya", "GUID": "tanya-guid", "Qualified Name": "Person::tanya"}, + {"Display Name": "Gary", "GUID": "profile-guid", "Qualified Name": "Person::gary"}, + ] + assert DummyApp().search_people("a") == [("Tanya", "tanya-guid", "Person::tanya")] + + def test_search_single_result_dict(self, client): + client.find_actor_profiles.return_value = {"Display Name": "Tanya", "GUID": "tanya-guid", + "Qualified Name": "Person::tanya"} + assert DummyApp().search_people("tan") == [("Tanya", "tanya-guid", "Person::tanya")] + + def test_search_error_returns_none(self, client): + client.find_actor_profiles.side_effect = PyegeriaException("boom") + assert DummyApp().search_people("tan") is None + + def test_link_peer(self, client): + assert DummyApp().link_peer("tanya-guid", "Tan", "Met at the summit") is True + owner, peer, body = client.link_peer_person.call_args.args + assert (owner, peer) == ("profile-guid", "tanya-guid") + assert body["properties"]["class"] == "PeerProperties" + assert body["properties"]["peer_relationship_name"] == "Tan" + assert body["properties"]["description"] == "Met at the summit" + + def test_link_peer_error(self, client): + client.link_peer_person.side_effect = PyegeriaException("boom") + app = DummyApp() + assert app.link_peer("tanya-guid") is False + assert app.notices[-1][1] == "error" + + def test_unlink_peer(self, client): + assert DummyApp().unlink_peer("erin-guid") is True + client.unlink_peer_person.assert_called_once_with( + "profile-guid", "erin-guid", {"class": "DeleteRelationshipRequestBody"}) + + def test_unlink_peer_error(self, client): + client.unlink_peer_person.side_effect = PyegeriaException("boom") + app = DummyApp() + assert app.unlink_peer("erin-guid") is False + assert app.notices[-1][1] == "error" + + def test_peer_details(self, client): + client._generate_my_profile_output.return_value = [ + {"Contact Methods": [{"name": "Work email"}], "Communities": [{"name": "Council"}]}] + assert DummyApp().get_peer_details("erin-guid") == ([{"name": "Work email"}], [{"name": "Council"}]) + client.get_actor_profile_by_guid.assert_called_once_with("erin-guid", output_format="JSON") + + def test_peer_details_missing(self, client): + client._generate_my_profile_output.return_value = "No elements found" + app = DummyApp() + assert app.get_peer_details("erin-guid") is None + assert app.notices[-1][1] == "warning" + + +PEER_ROWS = [("Person::erin", "Erin", "Erin Full", "she/her", "erin-guid", "Governance buddy"), + ("Person::peter", "Peter", "Peter Full", "he/him", "peter-guid", "")] + + +class NetworkHostApp(App): + """Host app standing in for MyProfileApp's network methods.""" + + def __init__(self, rows): + super().__init__() + self.rows = list(rows) + self.link_peer = MagicMock(side_effect=self._link) + self.unlink_peer = MagicMock(side_effect=self._unlink) + self.search_people = MagicMock(return_value=[("Tanya", "tanya-guid", "Person::tanya"), + ("Erin", "erin-guid", "Person::erin")]) + self.get_peer_details = MagicMock(return_value=([{"name": "Work email"}], [{"name": "Council"}])) + self.dismissed = None + + def _link(self, guid, nickname="", description=""): + self.rows.append(("Person::new", "New", "", "", guid, description)) + return True + + def _unlink(self, guid): + self.rows = [r for r in self.rows if r[4] != guid] + return True + + def list_my_peers(self): + return list(self.rows) + + async def on_mount(self): + self.screen_under_test = MyNetworkScreen(list(self.rows)) + await self.push_screen(self.screen_under_test, callback=lambda r: setattr(self, "dismissed", r)) + + +class TestMyNetworkScreen: + @pytest.mark.asyncio + async def test_lists_peers(self): + app = NetworkHostApp(PEER_ROWS) + async with app.run_test(): + table = app.screen_under_test.query_one("#my_network_table", DataTable) + assert table.row_count == 2 + assert table.get_row_at(0) == list(PEER_ROWS[0]) + assert app.screen_under_test.query_one("#peer_details_container").display is False + + @pytest.mark.asyncio + async def test_view_details_of_highlighted_peer(self): + app = NetworkHostApp(PEER_ROWS) + async with app.run_test() as pilot: + screen = app.screen_under_test + screen.query_one("#my_network_table", DataTable).move_cursor(row=1) + screen.action_view_details() + await pilot.pause() + app.get_peer_details.assert_called_once_with("peter-guid") + assert screen.query_one("#peer_details_container").display is True + assert screen.query_one("#peer_contacts_table", DataTable).row_count == 1 + assert screen.query_one("#peer_communities_table", DataTable).row_count == 1 + + @pytest.mark.asyncio + async def test_remove_asks_for_confirmation(self): + app = NetworkHostApp(PEER_ROWS) + async with app.run_test() as pilot: + screen = app.screen_under_test + screen.action_remove_peer() + await pilot.pause() + assert isinstance(app.screen, ConfirmRemovePeerScreen) + app.screen.query_one("#cancel_remove_peer_btn", Button).press() + await pilot.pause() + app.unlink_peer.assert_not_called() + assert screen.query_one("#my_network_table", DataTable).row_count == 2 + + @pytest.mark.asyncio + async def test_remove_confirmed_hides_that_peers_details(self): + app = NetworkHostApp(PEER_ROWS) + async with app.run_test() as pilot: + screen = app.screen_under_test + screen.action_view_details() + await pilot.pause() + screen.action_remove_peer() + await pilot.pause() + app.screen.query_one("#confirm_remove_peer_btn", Button).press() + await pilot.pause() + app.unlink_peer.assert_called_once_with("erin-guid") + assert screen.query_one("#my_network_table", DataTable).row_count == 1 + assert screen.query_one("#peer_details_container").display is False + + @pytest.mark.asyncio + async def test_remove_with_empty_network(self): + app = NetworkHostApp([]) + async with app.run_test() as pilot: + app.screen_under_test.action_remove_peer() + await pilot.pause() + assert app.screen is app.screen_under_test + + @pytest.mark.asyncio + async def test_search_and_add_peer(self): + app = NetworkHostApp(PEER_ROWS) + async with app.run_test() as pilot: + screen = app.screen_under_test + search = screen.query_one("#people_search_input", Input) + search.value = "a" + await search.action_submit() + await pilot.pause() + found = screen.query_one("#people_found_table", DataTable) + # Erin is already in the network so isn't offered again + assert found.row_count == 1 + found.focus() + await pilot.press("enter") + await pilot.pause() + screen.query_one("#peer_description_input", Input).value = "Met at the summit" + screen.query_one("#add_peer_btn", Button).press() + await pilot.pause() + app.link_peer.assert_called_once_with("tanya-guid", "", "Met at the summit") + assert screen.query_one("#my_network_table", DataTable).row_count == 3 + assert found.row_count == 0 + + @pytest.mark.asyncio + async def test_quit(self): + app = NetworkHostApp([]) + async with app.run_test() as pilot: + app.screen_under_test.action_quit() + await pilot.pause() + assert app.dismissed == 200 From 5f945d8ffb05931b0a13eebf1255e736d423162c Mon Sep 17 00:00:00 2001 From: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:46:37 -0500 Subject: [PATCH 08/14] fix(my_profile): stop Technology Types "go back" from crashing TechnologyTypesScreen dismisses quit/go back with the strings "200"/"201", but tech_type_callback compared against the int 201. The back result was then treated as a tech type name, get_tech_type_detail returned the string "No elements found", and .get() on it raised AttributeError. - match "200"/"201" as either str or int and return to the main screen - if the tech type detail is not a dict, notify and return to the main screen instead of crashing - stop after showing the error screen when the detail lookup fails - use the Egeria client for get_tech_type_detail Signed-off-by: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> --- .../DemoCode/My_Profile/tech_types_handler.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/tech_types_handler.py b/my_egeria/my_egeria/DemoCode/My_Profile/tech_types_handler.py index b4a66bb9..0b2284f4 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/tech_types_handler.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/tech_types_handler.py @@ -20,7 +20,7 @@ if str(current_dir) not in sys.path: sys.path.append(str(current_dir)) -from pyegeria import AutomatedCuration, PyegeriaException, print_basic_exception, load_app_config, settings +from pyegeria import AutomatedCuration, PyegeriaException, print_basic_exception, load_app_config, settings, Egeria from StatusScreen import StatusScreen from TechnologyTypeScreens import ( TechnologyTypesScreen, @@ -64,6 +64,7 @@ async def tech_type_callback(self, result: Any) -> int: If the result is int (4xx) it indicates an error in the screen. If the result is str it contains the GUID of the selected technology type. """ + self.result = result # clear local data fields tech_type_description = "" self.tech_type_data = {} @@ -71,15 +72,21 @@ async def tech_type_callback(self, result: Any) -> int: self.tech_type_templates = [] self.tech_type_processes = [] # check that we got a valid result from the screen and process accordingly - if not result or isinstance(result, int): + if not self.result or isinstance(self.result, int): self.log(f"Technology Types screen cancelled or failed; return: {result}.") self._show_main_screen() - return result + return self.result + # The screen dismisses quit/go back as the strings "200"/"201", not ints + if str(self.result) in ("200", "201"): + self.log(f"Technology Types Screen returned on a quit/go back directive: {self.result}") + self._show_main_screen() + return self.result + self.selected_t_node = str(result) self.log(f"Technology Types screen returned: {self.selected_t_node}") # Request details for selected tech type try: - p_client = AutomatedCuration(self.view_server, self.platform_url, self.user_name, self.user_password) + p_client = Egeria(self.view_server, self.platform_url, self.user_name, self.user_password) token = p_client.create_egeria_bearer_token(self.user_name, self.user_password) self.tech_type_data = p_client.get_tech_type_detail( filter_string=self.selected_t_node, @@ -91,8 +98,15 @@ async def tech_type_callback(self, result: Any) -> int: error_message = f"Failed to retrieve technology type process details for {getattr(self, 'selected_q_name', self.selected_t_node)}: {str(e)}" self.log(f"Error retrieving technology type details: {error_category}, {error_message}") await self.push_screen(StatusScreen(f"{error_category}: {error_message}"), callback=self.status_callback) + return 417 self.log(f"Technology Type Data: {self.tech_type_data}, type: {type(self.tech_type_data)}") + # Egeria returns a plain string such as "No elements found" when there is no detail + if not isinstance(self.tech_type_data, dict): + self.notify(f"No details found for technology type '{self.selected_t_node}'", + timeout=10, severity="warning") + self._show_main_screen() + return 200 if "specificationMermaidGraph" in self.tech_type_data: del self.tech_type_data["specificationMermaidGraph"] From 689ff46b822844b50691b7ab86a6297fd5f1a99a Mon Sep 17 00:00:00 2001 From: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:46:37 -0500 Subject: [PATCH 09/14] docs(my_profile): document the My Network option - user manual: My Network section (view details, search and add, remove with confirmation), keyboard reference rows, intro mention - reference guide: NetworkMixin section with sequence diagram and function table, plus source layout, class diagram, menu dispatch, report spec, client and testing updates Signed-off-by: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> --- docs/My-Egeria-Doc.md | 72 ++++++++++++++++++++++++++++++++++- docs/my_profile_app_manual.md | 14 ++++++- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/docs/My-Egeria-Doc.md b/docs/My-Egeria-Doc.md index f90359bd..b51df45a 100644 --- a/docs/My-Egeria-Doc.md +++ b/docs/My-Egeria-Doc.md @@ -28,6 +28,7 @@ All source files named here are in `my_egeria/my_egeria/DemoCode/My_Profile/`. | `tech_types_handler.py`, `TechnologyTypeScreens.py` | `TechTypesMixin` and the technology type screens | | `team_roles_handler.py`, `MyTeamScreen.py` | `TeamRolesMixin` and the team roster screen | | `bookmarks_handler.py`, `MyBookMarksScreen.py` | `BookmarksMixin` (bookmarks kept in a personal collection) and the bookmarks screen | +| `network_handler.py`, `MyNetworkScreen.py` | `NetworkMixin` (peers linked by `Peer` relationships), `MyNetworkScreen` and `ConfirmRemovePeerScreen` | | `UserIdentitiesScreen.py` | Read-only user identity table | | `StatusScreen.py` | Result message with copy-GUID | | `profile_utils.py` | Environment check and data clean-up helpers | @@ -64,6 +65,13 @@ classDiagram list_my_bookmarks() add_my_bookmark() / delete_my_bookmark() } + class NetworkMixin { + show_my_network() + list_my_peers() + search_people() + link_peer() / unlink_peer() + get_peer_details() + } App <|-- MyProfileApp TechTypesMixin <|-- MyProfileApp ShopForDataMixin <|-- MyProfileApp @@ -71,6 +79,7 @@ classDiagram ElementsCrudMixin <|-- MyProfileApp FeedbackMixin <|-- MyProfileApp BookmarksMixin <|-- MyProfileApp + NetworkMixin <|-- MyProfileApp ``` - **App bindings:** @@ -156,6 +165,7 @@ It returns `200` on success and `401` on failure. | Subscriptions | `ViewSubscriptionsScreen()` | | Technology Types | `handle_technology_types_option()` | | User Bookmarks | `show_my_bookmarks()` | +| My Network | `show_my_network()` | | Leave Feedback | `action_feedback()` | | Feedback Log | `action_view_feedback_log()` (owner only) | @@ -481,6 +491,61 @@ After each change it re-reads the list through the app. The lookup matches on th --- +## My Network + +A user's network is the set of people linked to their profile by a `Peer` relationship. `NetworkMixin` (`network_handler.py`) makes the Egeria calls; `MyNetworkScreen` only drives it through `self.app`. Each call builds a fresh `Egeria` client with a bearer token (`_network_client()`) and closes its session afterwards. + +```mermaid +sequenceDiagram + autonumber + actor User + participant App as NetworkMixin + participant MNS as MyNetworkScreen + participant Confirm as ConfirmRemovePeerScreen + participant EG as Egeria + + User->>App: Other Functions → My Network + App->>EG: get_related_elements(user_GUID, "Peer") + App->>MNS: push(peer rows) + opt view details (v) + MNS->>App: get_peer_details(peer GUID) + App->>EG: get_actor_profile_by_guid(JSON) + App->>App: _generate_my_profile_output(DICT, "My-User") + App-->>MNS: (contact methods, communities) + end + opt add (search, select, Add to Network) + MNS->>App: search_people(name) + App->>EG: find_actor_profiles(DICT, "User-Identities", page_size=10) + App-->>MNS: (name, GUID, qualified name) rows + MNS->>App: link_peer(GUID, nickname, description) + App->>EG: link_peer_person(user_GUID, GUID, PeerProperties) + MNS->>App: list_my_peers() + end + opt remove (d) + MNS->>Confirm: push(peer name) + Confirm-->>MNS: True / False + MNS->>App: unlink_peer(GUID) + App->>EG: unlink_peer_person(user_GUID, GUID) + MNS->>App: list_my_peers() + end +``` + +| Function | What it does | +| :--- | :--- | +| `show_my_network()` | Refuses (with a notice) if `user_GUID` isn't set yet, i.e. the profile hasn't loaded; otherwise pushes `MyNetworkScreen(list_my_peers())` | +| `list_my_peers()` | `get_related_elements(user_GUID, "Peer")`, keeping only elements whose properties are `PersonProperties`. Returns `(qualified name, display name, full name, pronouns, GUID, description)` rows; the GUID comes from the peer's `elementHeader` and the description from `relationshipProperties`. `[]` when Egeria answers "No elements found", `None` if Egeria can't be reached | +| `search_people(search_string)` | `find_actor_profiles(..., ignore_case=True, report_spec="User-Identities", page_size=10)` → `(display name, GUID, qualified name)` rows, excluding the current user. The screen also drops anyone already in the network | +| `link_peer(guid, nickname, description)` | `link_peer_person(user_GUID, guid, NewRelationshipRequestBody)` with `PeerProperties` (`peer_relationship_name` = nickname, `description`) | +| `unlink_peer(guid)` | `unlink_peer_person(user_GUID, guid, DeleteRelationshipRequestBody)`, using the same GUID order as `link_peer_person` | +| `get_peer_details(guid)` | `get_actor_profile_by_guid(guid, JSON)`, then `_generate_my_profile_output(..., report_spec="My-User")`. Returns the `Contact Methods` and `Communities` lists, or `None` with a notice if there are no details | + +- **Removing only unlinks.** The peer's profile is never changed. If the details panel is showing the person just removed, it is hidden. +- **Refresh.** After a successful add or remove the screen re-reads the network with `list_my_peers()`. +- **Errors.** Every Egeria failure is reported with `notify(...)` and the screen stays open; none of them exit the app. +- **Return code.** `MyNetworkScreen` dismisses with `200` on `q`; `my_network_callback` just logs it. + +--- + ## Status Screen `StatusScreen(message)` shows a read-only message: @@ -497,7 +562,8 @@ The app's `status_callback` then returns to the main screen. | Report spec | Used by | | :--- | :--- | | `My-User-MD` | Profile load and reload | -| `User-Identities` | User identity table | +| `User-Identities` | User identity table; My Network people search | +| `My-User` | My Network peer details (contact methods, communities) | | `My-User-ToDos` | To-dos table | | `Actor-Profiles` | User GUID fallback | | `Comment-by-Element` | Comments screen | @@ -509,7 +575,7 @@ The app's `status_callback` then returns to the main screen. | Client | Used for | | :--- | :--- | | `MyProfile` | Profile, identities, to-dos, `add_my_profile` | -| `Egeria` | Element create, link and delete; comments from main; bookmarks; feedback note logs; tabular data; `update_actor_profile`; `find_collections` | +| `Egeria` | Element create, link and delete; comments from main; bookmarks; My Network; feedback note logs; tabular data; `update_actor_profile`; `find_collections` | | `EgeriaTech` | Comments screen | | `AutomatedCuration` | Technology types | | `ProductManager` | Digital subscriptions | @@ -548,6 +614,8 @@ The suite in `tests/micro-tests/my_profile/` has one module per handler and scre **Running the whole app in a test.** Tests that run `MyProfileApp.run_test()` stub the profile client with `stub_profile_client()`. +**My Network.** `test_network_handler.py` covers `NetworkMixin` and `MyNetworkScreen`, including the remove confirmation; `test_my_profile_app.py` checks the menu routing. The feature has also been tested by hand against a live server. + **Add screens.** `test_screens.py::TestBaseAddScreen` covers the `BaseAddScreen` flow: required-field checks, create, link or no link, and keeping the form after a failed create. --- diff --git a/docs/my_profile_app_manual.md b/docs/my_profile_app_manual.md index 0bd49f6d..a9cd3b6a 100644 --- a/docs/my_profile_app_manual.md +++ b/docs/my_profile_app_manual.md @@ -3,7 +3,7 @@ # My Profile App User Manual -The **My Profile App** is a terminal user interface (TUI), built with [Textual](https://textual.textualize.io/) and the `pyegeria` SDK, that gives an Egeria user a personal dashboard: their profile, roles, teams, communities, blogs, journal, to-dos and user identities, plus access to comments, feedback, data shopping, subscriptions and technology types. +The **My Profile App** is a terminal user interface (TUI), built with [Textual](https://textual.textualize.io/) and the `pyegeria` SDK, that gives an Egeria user a personal dashboard: their profile, roles, teams, communities, blogs, journal, to-dos and user identities, plus access to comments, feedback, data shopping, subscriptions, technology types and their network of peers. This manual describes how to *use* the app. For how it works internally (screens, handlers, Egeria calls and flow diagrams) see the [My Profile Reference Guide](My-Egeria-Doc.md). @@ -250,6 +250,16 @@ On the bookmarks screen: Your bookmarks are kept in Egeria as the members of your private **My Bookmarks** collection (qualified name `Bookmarks::`), and only that collection. It is created the first time you bookmark something, anchored to your profile and linked from it with a `ResourceList` relationship whose resource use is "Bookmarks". +### My Network +Your network is the people linked to your profile in Egeria. The screen lists them (qualified name, display name, full name, pronouns, GUID and the description you gave the link). If your profile has not finished loading, the app tells you to wait and does not open the screen. + +- **View details:** highlight a person and press `v` (or **Person Details**). A panel shows their **contact methods** and the **communities** they belong to. A message tells you if either is empty. +- **Add someone:** type all or part of a name in the search field and press `Enter`. Up to 10 matching people are listed, leaving out you and anyone already in your network. Select a person, optionally enter a **nickname** and a **description** of your relationship, then press **Add to Network**. **Cancel** clears the selection. +- **Remove someone:** highlight a person and press `d` (or **Remove from Network**), then confirm with **Remove**. **Cancel** or `Escape` keeps them. Only the link between you is removed; their profile is not changed. +- `q` returns to the main screen. + +Each person in your network is linked to your profile with a `Peer` relationship. The nickname and description are stored on that relationship. + ### Leave Feedback / Feedback Log See [Leaving Feedback](#leaving-feedback). @@ -267,6 +277,8 @@ See [Leaving Feedback](#leaving-feedback). | `ctrl+b` | Main screen, Shop for Data | Bookmarks, with the highlighted row's GUID ready to add | | `ctrl+k` | Main screen | Bookmark the selected row | | `ctrl+n` / `d` / `q` | Bookmarks | Add by GUID / remove highlighted / close | +| `v` / `d` / `q` | My Network | Show highlighted person's details / remove highlighted person / close | +| `Enter` | My Network search field | Search for people to add | | `a` / `d` / `Escape` | Edit screens | Add row / delete row / back | | `ctrl+a` / `q` | Add screens | Add / close (outside a text field) | | `ctrl+a` / `ctrl+r` / `q` | Comments screen | Add comment / show responses to highlighted comment / close | From 04dd837d39a865dd3c5afa7a9327b22f8c30000d Mon Sep 17 00:00:00 2001 From: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:50:10 -0500 Subject: [PATCH 10/14] chore: revert machine-specific config.json and test edits Restore config/config.json and test_classification_manager.py to their upstream versions. The config change pointed the report spec modules and log directory at local absolute paths, and the test change was local debugging of the Peer relationship query. Signed-off-by: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> --- config/config.json | 5 ++--- tests/functional-tests/test_classification_manager.py | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/config/config.json b/config/config.json index d41ff2e2..dac20f26 100644 --- a/config/config.json +++ b/config/config.json @@ -28,8 +28,7 @@ "Pyegeria Publishing Root": "http://localhost:8085/outbox", "Pyegeria User Report Specs Dir": "~/.pyegeria/report_specs", "Pyegeria Report Spec Modules": [ - "pyegeria.view.analytic_demo_specs:get_analytic_demo_specs", - "/Users/petercoldicott/PycharmProjects/egeria/egeria-workspaces/egeria-python/my_report_specs/my_profile_report_specs.json" + "pyegeria.view.analytic_demo_specs:get_analytic_demo_specs" ] }, "feature_x_enabled": false, @@ -43,7 +42,7 @@ "console_logging_level": "ERROR", "enable_logging": true, "file_logging_level": "WARNING", - "log_directory": "/Users/petercoldicott/PycharmProjects/egeria/egeria-workspaces/egeria-python/logs", + "log_directory": "/Users/dwolfson/localGit/egeria-v5-3/egeria-python/logs", "logging_console_format": "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{line} - {message}", "logging_file_format": "{time:YYYY-MM-DD HH:mm:ss} | {level} | {module}-{function}:{line}-{message}-{extra}" }, diff --git a/tests/functional-tests/test_classification_manager.py b/tests/functional-tests/test_classification_manager.py index ccc2c897..1ae4762d 100644 --- a/tests/functional-tests/test_classification_manager.py +++ b/tests/functional-tests/test_classification_manager.py @@ -590,14 +590,14 @@ def test_get_all_related_elements(): def test_get_related_elements(): # metadata_element_type = 'CertificationType' # element_guid = '2ce8d10d-08a2-4fca-b0c2-1d5a335d00fc' - element_guid = "79bd39ed-6048-4731-85c4-3cde50f99a30" + element_guid = "acca7cf3-40d4-40fd-9922-5c74075e9651" # metadata_element_type = "Organization" # metadata_element_type = "CSVFile" # metadata_element_type = "InformationSupplyChain" open_metadata_type_name = "Referenceable" # element_guid = "8dca6e76-d454-4344-9c93-faa837a1a898" # relationship_type = "DataContentForDataSet" - relationship_type = "Peer" + relationship_type = "CollectionMembership" # relationship_type = "InformationSupplyChainComposition" c_client = ClassificationExplorer(view_server, platform_url) try: @@ -605,7 +605,6 @@ def test_get_related_elements(): body = { "class": "ResultsRequestBody", "metadataElementTypeName": open_metadata_type_name, - "includeOnlyRelationships": ["ContactThrough"] } response = c_client.get_related_elements( element_guid, relationship_type, body=body From a298d8f8dbc91d18c07bcde6c0706702476172f5 Mon Sep 17 00:00:00 2001 From: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:58:54 -0500 Subject: [PATCH 11/14] test(my_profile): cover Technology Types go back and missing details - go back/quit as "201"/"200" (str or int) returns to the main screen without looking up a tech type - a string detail result such as "No elements found" notifies and returns to the main screen - patch Egeria rather than AutomatedCuration, matching the handler, so the callback tests no longer try to reach the server - a failed detail lookup now pushes only the StatusScreen Signed-off-by: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> --- .../my_profile/test_tech_types_handler.py | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/tests/micro-tests/my_profile/test_tech_types_handler.py b/tests/micro-tests/my_profile/test_tech_types_handler.py index 358e90f0..a1da2ac2 100644 --- a/tests/micro-tests/my_profile/test_tech_types_handler.py +++ b/tests/micro-tests/my_profile/test_tech_types_handler.py @@ -119,14 +119,14 @@ async def test_tech_type_callback_valid_selection( ): app = DummyTechTypesApp(backend) if backend.live: - backend.patch("tech_types_handler.AutomatedCuration") + backend.patch("tech_types_handler.Egeria") # get_tech_type_detail matches on display name, so use a real one. selection = request.getfixturevalue("live_tech_type_name") else: mock_ac = MagicMock() mock_ac.create_egeria_bearer_token.return_value = "token" mock_ac.get_tech_type_detail.return_value = sample_tech_type_detail - backend.always_fake("tech_types_handler.AutomatedCuration", returns=mock_ac) + backend.always_fake("tech_types_handler.Egeria", returns=mock_ac) selection = "tech-type-guid-999" res = await app.tech_type_callback(selection) @@ -161,12 +161,43 @@ async def test_tech_type_callback_exception(self, backend): app = DummyTechTypesApp(backend) mock_ac = MagicMock() mock_ac.create_egeria_bearer_token.side_effect = PyegeriaException("Auth failed") - backend.always_fake("tech_types_handler.AutomatedCuration", returns=mock_ac) + backend.always_fake("tech_types_handler.Egeria", returns=mock_ac) - await app.tech_type_callback("tech-type-guid-999") - assert len(app.pushed_screens) == 2 # StatusScreen pushed on error, then TechnologyTypeOptionsScreen + res = await app.tech_type_callback("tech-type-guid-999") + # Only the StatusScreen: the options screen is not opened after a failed lookup + assert res == 417 + assert len(app.pushed_screens) == 1 assert isinstance(app.pushed_screens[0][0], StatusScreen) + @pytest.mark.parametrize("result", ["201", 201, "200", 200]) + @pytest.mark.asyncio + async def test_tech_type_callback_go_back(self, backend, result): + # TechnologyTypesScreen dismisses go back/quit as the strings "201"/"200" + app = DummyTechTypesApp(backend) + mock_egeria = MagicMock() + backend.always_fake("tech_types_handler.Egeria", returns=mock_egeria) + + res = await app.tech_type_callback(result) + assert res == result + assert app.shown_main_screen is True + assert app.pushed_screens == [] + mock_egeria.get_tech_type_detail.assert_not_called() + + @pytest.mark.asyncio + async def test_tech_type_callback_no_details(self, backend): + # Always faked: Egeria answers with a string when the type has no details + app = DummyTechTypesApp(backend) + app.notify = MagicMock() + mock_egeria = MagicMock() + mock_egeria.get_tech_type_detail.return_value = "No elements found" + backend.always_fake("tech_types_handler.Egeria", returns=mock_egeria) + + res = await app.tech_type_callback("Unknown Type") + assert res == 200 + assert app.shown_main_screen is True + assert app.pushed_screens == [] + app.notify.assert_called_once() + @pytest.mark.asyncio async def test_tech_type_options_callback_template(self): app = DummyTechTypesApp() From f90844291338913623dba7d5878364220470e85a Mon Sep 17 00:00:00 2001 From: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:06:20 -0500 Subject: [PATCH 12/14] fix(my_profile): restore Shop for Data go back and letter shortcuts The screen's prompt was a TextArea, which took focus when the screen opened. A focused TextArea consumes printable keys, so b (go back), q, s, u, t and k did nothing and Textual hid them from the footer. - make the prompt a Static, and mention b/Escape in it - add Escape as a go-back key (not shown in the footer) - move Go back next to Quit so it isn't cut off on narrow terminals - tests: b and Escape pressed straight after the screen opens dismiss with [200]; the callback returns [200] to the main screen - user manual: document Escape on Shop for Data Signed-off-by: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> --- docs/my_profile_app_manual.md | 4 ++-- .../DemoCode/My_Profile/ShopForDataScreen.py | 11 +++++++---- tests/micro-tests/my_profile/test_screens.py | 12 ++++++++++++ .../my_profile/test_shop_for_data_handler.py | 6 ++++++ 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/docs/my_profile_app_manual.md b/docs/my_profile_app_manual.md index a9cd3b6a..1065387c 100644 --- a/docs/my_profile_app_manual.md +++ b/docs/my_profile_app_manual.md @@ -218,7 +218,7 @@ Opens a screen with five tables, each loading in the background: **Glossaries**, - `u` or `ctrl+s` – subscribe to the highlighted item. - `t` – search glossary terms. Back from the search (`g`) returns to Shop for Data. - `k` – bookmark the highlighted item; `ctrl+b` – open bookmarks with its GUID ready to add. -- `b` – back; `q` – quit the screen. +- `b` or `Escape` – back to the main screen; `q` – quit the screen. On the overview screen, `s` subscribes to the selected node, `b` goes back and `q` returns to the main screen. On a sample-data view, `s` subscribes, and `b`, `Escape` or `Enter` go back. @@ -282,7 +282,7 @@ See [Leaving Feedback](#leaving-feedback). | `a` / `d` / `Escape` | Edit screens | Add row / delete row / back | | `ctrl+a` / `q` | Add screens | Add / close (outside a text field) | | `ctrl+a` / `ctrl+r` / `q` | Comments screen | Add comment / show responses to highlighted comment / close | -| `s` / `u` or `ctrl+s` / `t` / `k` / `b` / `q` | Shop for Data | Sample / subscribe / search glossary terms / bookmark / back / close | +| `s` / `u` or `ctrl+s` / `t` / `k` / `b` or `Escape` / `q` | Shop for Data | Sample / subscribe / search glossary terms / bookmark / back / close | | `s` / `b` / `q` | Overview | Subscribe / back / back to main | | `c` / `q` | Create Subscription | Create / cancel | | `c` / `Enter` / `q` | Status screen | Copy GUID / close / close | diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/ShopForDataScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/ShopForDataScreen.py index 529cf4ce..b99c2937 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/ShopForDataScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/ShopForDataScreen.py @@ -11,22 +11,24 @@ from pyegeria import load_app_config, settings from textual import on from textual.app import ComposeResult +from textual.binding import Binding from textual.containers import ScrollableContainer from textual.events import Action from textual.screen import Screen -from textual.widgets import DataTable, Header, TextArea, Static, Footer +from textual.widgets import DataTable, Header, Static, Footer class ShopForDataScreen(Screen): """ Screen to Present a choice of different data sources to the user.""" BINDINGS = [("q", "dismiss(200)", "Quit"), + ("b", "back", "Go back"), + Binding("escape", "back", "Go back", show=False), ("s", "sample_data_source", "Sample data source"), ("u", "subscribe_to_data_source", "Subscribe"), ("ctrl+s", "subscribe_to_data_source", "Subscribe"), ("t", "search_for_term", "Search Glossary Terms"), ("k", "bookmark_row", "Bookmark"), - ("ctrl+b", "bookmarks", "Bookmarks"), - ("b", "back", "Go back")] + ("ctrl+b", "bookmarks", "Bookmarks")] CSS_PATH = "my_profile.tcss" @@ -86,7 +88,8 @@ def __init__( def compose(self) -> ComposeResult: """ Compose the UI components for the ShopForDataScreen screen.""" yield Header(show_clock=True) - yield TextArea(f"\n\n\nPlease click on a line to select a data source:") + # A Static, not a TextArea: a focused TextArea swallows the screen's letter key bindings (b, q, s, ...) + yield Static("\nPlease click on a line to select a data source, or press b or Escape to go back:") yield ScrollableContainer( Static("Glossary"), self.glossary_table) diff --git a/tests/micro-tests/my_profile/test_screens.py b/tests/micro-tests/my_profile/test_screens.py index 80d236b1..b2994cd5 100644 --- a/tests/micro-tests/my_profile/test_screens.py +++ b/tests/micro-tests/my_profile/test_screens.py @@ -776,6 +776,18 @@ async def test_shop_for_data_screen_default_config(self): await pilot.pause() assert app.dismissed_result == [210] + @pytest.mark.parametrize("key", ["b", "escape"]) + @pytest.mark.asyncio + async def test_shop_for_data_screen_go_back_key(self, key): + # Pressed as soon as the screen opens, without focusing a table first: nothing on the + # screen may take focus and swallow the key (a TextArea prompt used to) + app = ScreenTestHostApp(lambda: ShopForDataScreen()) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press(key) + await pilot.pause() + assert app.dismissed_result == [200] + @pytest.mark.asyncio async def test_shop_for_data_screen_subscribe_action(self): t2 = DataTable(id="digital_product_catalog_table") diff --git a/tests/micro-tests/my_profile/test_shop_for_data_handler.py b/tests/micro-tests/my_profile/test_shop_for_data_handler.py index c3720ae6..a04c3e14 100644 --- a/tests/micro-tests/my_profile/test_shop_for_data_handler.py +++ b/tests/micro-tests/my_profile/test_shop_for_data_handler.py @@ -164,6 +164,12 @@ async def test_shop_for_data_callback_exit_codes(self): assert res == 210 assert app.shown_main_screen is True + # go back (b / Escape) dismisses with [200] + app.shown_main_screen = False + res = await app.shop_for_data_callback([200]) + assert res == 200 + assert app.shown_main_screen is True + @pytest.mark.asyncio async def test_shop_for_data_callback_search_for_term(self): app = DummyShopApp() From 76e66928a21346028ed6238f0caa0408ed31afb6 Mon Sep 17 00:00:00 2001 From: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:30:27 -0500 Subject: [PATCH 13/14] feat(my_profile): use one key binding convention on every screen Every screen now follows the same rules: - escape goes back one level (every screen except Main) - q is the only plain-letter key: main menu on list screens, quit on Main; left off text-entry forms, where it would be typed into the Input - every other action is a ctrl key with one meaning app-wide: ctrl+s submit, ctrl+n add, ctrl+d delete, ctrl+o open, ctrl+k/ctrl+b bookmark row/bookmarks, ctrl+u subscribe, ctrl+l sample, ctrl+g glossary search, ctrl+y copy GUID, ctrl+r refresh (was r) Return codes are unchanged; only the keys moved. Also fixes keys that could never fire: ctrl+i (sent as Tab) and ctrl+c on Edit Profile, the "return" key name on Status and the sample-data view, and the Technology Types quit/go back mix-ups. Adds ctrl+s to Create/Edit Profile. MainScreen shows the user name as its subtitle; its config logging moves to on_mount, since self.log needs a running app. test_key_bindings.py enforces the convention; docs updated to match. Signed-off-by: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> --- docs/developer_guide_pyegeria_textual.md | 4 +- docs/my_profile_app_manual.md | 118 ++++++++-------- .../DemoCode/My_Profile/AddCommentScreen.py | 5 +- .../My_Profile/AddToElementsScreens.py | 7 +- .../My_Profile/CreateProfileScreen.py | 7 +- .../CreateSubscriptionRequestScreen.py | 4 +- .../My_Profile/EditElementsScreens.py | 26 ++-- .../DemoCode/My_Profile/FeedbackScreens.py | 4 +- .../My_Profile/GenericDataViewScreen.py | 6 +- .../DemoCode/My_Profile/MainScreen.py | 20 ++- .../DemoCode/My_Profile/MyBookMarksScreen.py | 9 +- .../DemoCode/My_Profile/MyNetworkScreen.py | 7 +- .../DemoCode/My_Profile/MyTeamScreen.py | 4 +- .../My_Profile/SearchForTermScreen.py | 3 +- .../My_Profile/SelectionOverviewScreen.py | 6 +- .../DemoCode/My_Profile/ShopForDataScreen.py | 18 ++- .../DemoCode/My_Profile/ShowCommentsScreen.py | 15 +- .../DemoCode/My_Profile/StatusScreen.py | 9 +- .../My_Profile/TechnologyTypeScreens.py | 19 ++- .../My_Profile/UserIdentitiesScreen.py | 4 +- .../DemoCode/My_Profile/VIDEO_SCRIPT_DRAFT.md | 10 +- .../My_Profile/ViewSubscriptionsScreen.py | 5 +- .../My_Profile/elements_crud_handler.py | 2 +- .../DemoCode/My_Profile/my_profile_app.py | 2 +- .../my_profile/test_key_bindings.py | 130 ++++++++++++++++++ tests/micro-tests/my_profile/test_screens.py | 4 +- 26 files changed, 301 insertions(+), 147 deletions(-) create mode 100644 tests/micro-tests/my_profile/test_key_bindings.py diff --git a/docs/developer_guide_pyegeria_textual.md b/docs/developer_guide_pyegeria_textual.md index 362462f2..6ae266a5 100644 --- a/docs/developer_guide_pyegeria_textual.md +++ b/docs/developer_guide_pyegeria_textual.md @@ -686,7 +686,7 @@ That is the second of the two standard worker patterns, so you now have both: ### 7.5 Acting on the selected row My Profile lets the user act on "the selected row" from several places: add a comment -(Ctrl+A), show comments (Ctrl+S), edit (Ctrl+T), bookmark it (Ctrl+K) and open bookmarks with +(Ctrl+N), show comments (Ctrl+O), edit (Ctrl+T), bookmark it (Ctrl+K) and open bookmarks with its GUID ready to add (Ctrl+B). Two problems need solving. **Which row is selected?** `MainScreen` records the table and row whenever the user @@ -737,7 +737,7 @@ qualified name. Some tables, such as Shop for Data's glossary table (built from Because `get_row_guid` takes the table itself, not a table name on one particular screen, any screen can use it, and each key binding is a few lines: -- **Ctrl+K** on the main screen, or **k** on Shop for Data, calls +- **Ctrl+K**, on the main screen or Shop for Data, calls `self.app.bookmark_table_row(table, row_key)`, which bookmarks the row straight away. - **Ctrl+B** calls `self.app.show_my_bookmarks(self.app.get_row_guid(table, row_key))`. The bookmark screen receives the GUID as `target_guid` and pre-fills its input with it, just as diff --git a/docs/my_profile_app_manual.md b/docs/my_profile_app_manual.md index 1065387c..b7033bce 100644 --- a/docs/my_profile_app_manual.md +++ b/docs/my_profile_app_manual.md @@ -134,18 +134,18 @@ The header shows your full name, user ID and **Karma Points** (your contribution | **User Identity** | Display Name, User ID, Distinguished Name, GUID | User identities linked to your profile | | **Projects** | Status or Type, Name, Description, GUID | Projects you are involved in | -Click or move into a table to select it; the highlighted row is the "selected row" used by the shortcuts below. Press `r` to reload everything from Egeria. The app also reloads automatically when you close an Add screen. +Click or move into a table to select it; the highlighted row is the "selected row" used by the shortcuts below. Press `ctrl+r` to reload everything from Egeria. The app also reloads automatically when you close an Add screen. Main screen shortcuts: | Key | Action | | :--- | :--- | | `ctrl+t` | **Edit Selected Table** – opens the edit screen for the selected table, where you add and delete rows | -| `ctrl+a` | **Add Comment** to the selected row | -| `ctrl+s` | **Show Comments** for the selected row | -| `ctrl+b` | **Manage Bookmarks** | +| `ctrl+o` | **Show Comments** for the selected row | +| `ctrl+n` | **Add Comment** to the selected row | | `ctrl+k` | **Bookmark** the element in the selected row | -| `r` | Refresh all data from Egeria | +| `ctrl+b` | **Manage Bookmarks** | +| `ctrl+r` | Refresh all data from Egeria | | `ctrl+f` | Leave feedback (works on every screen) | | `q` | Quit the app | @@ -155,8 +155,8 @@ Adding and deleting both go through the table's **edit screen**: 1. On the main screen, select a table and press `ctrl+t`. 2. The edit screen shows a copy of that table. Then: - - `a` – **Add Row**: opens the Add screen for that kind of element. - - `d` – **Delete Row**: asks you to confirm, then deletes the highlighted element from Egeria. The row is removed only if Egeria accepts the delete. You can delete one row at a time. + - `ctrl+n` – **Add Row**: opens the Add screen for that kind of element. + - `ctrl+d` – **Delete Row**: asks you to confirm, then deletes the highlighted element from Egeria. The row is removed only if Egeria accepts the delete. You can delete one row at a time. - `Escape` – back to the main screen. Edit screens add and delete; they do not edit a row's values in place. To change your own details use **Edit Profile** from the Other Functions menu. @@ -165,10 +165,10 @@ Edit screens add and delete; they do not edit a row's values in place. To change Every Add screen works the same way: - Fill in the fields. Those marked \* are required. -- Press **Add** (or `ctrl+a`). +- Press **Add** (or `ctrl+s`). - Some screens have a switch that links the new element to you. It is on by default. -If a required field is missing or a value is invalid, the screen tells you which one. After a successful add the form is cleared, so you can add another; after a failed add your input is kept so you can correct it. Press **Quit** (or `q` outside a text field) to close the screen. You go back to the main screen and the data is reloaded. +If a required field is missing or a value is invalid, the screen tells you which one. After a successful add the form is cleared, so you can add another; after a failed add your input is kept so you can correct it. Press **Quit** (or `Escape`) to close the screen. You go back to the main screen and the data is reloaded. | Table | Fields | Link switch | Egeria result | | :--- | :--- | :--- | :--- | @@ -187,15 +187,15 @@ If a required field is missing or a value is invalid, the screen tells you which Comments can be attached to any element in a main-screen table, and responses can be attached to comments to any depth. -**Add a comment from the main screen:** select a row, press `ctrl+a`, enter the comment and a comment type (`Question`, `Answer`, `Suggestion` or `Requirement`), then press **Submit**. +**Add a comment from the main screen:** select a row, press `ctrl+n`, enter the comment and a comment type (`Question`, `Answer`, `Suggestion` or `Requirement`), then press **Submit** (`ctrl+s`). -**View and reply:** select a row and press `ctrl+s`. The comments screen lists the comments on that element. +**View and reply:** select a row and press `ctrl+o`. The comments screen lists the comments on that element. -- `ctrl+a` adds a new comment to the element being shown. -- `ctrl+r` on a highlighted comment re-opens the screen focused on *that comment*, showing its responses. Here `ctrl+a` adds a response. Repeat to go deeper into a thread. -- `q` closes the screen. +- `ctrl+n` adds a new comment to the element being shown. +- `ctrl+o` on a highlighted comment re-opens the screen focused on *that comment*, showing its responses. Here `ctrl+n` adds a response. Repeat to go deeper into a thread. +- `Escape` or `q` closes the screen. -If an element has no comments yet, the screen tells you to use `ctrl+a`. +If an element has no comments yet, the screen tells you to use `ctrl+n`. ## Leaving Feedback @@ -214,21 +214,21 @@ A read-only table of your user identities: display name, category, description, Opens a screen with five tables, each loading in the background: **Glossaries**, **Digital Product Catalog**, **Data Dictionaries**, **Business Domains** and **Root Collections**. - **Enter** (or click) on a row opens an **overview** screen: a tree on the left and details on the right. Selecting a tree node shows its details – glossary terms, digital products (including sample data), dictionaries, business capabilities or collections. -- `s` – show sample data for the highlighted row. -- `u` or `ctrl+s` – subscribe to the highlighted item. -- `t` – search glossary terms. Back from the search (`g`) returns to Shop for Data. -- `k` – bookmark the highlighted item; `ctrl+b` – open bookmarks with its GUID ready to add. -- `b` or `Escape` – back to the main screen; `q` – quit the screen. +- `ctrl+l` – show sample data for the highlighted row. +- `ctrl+u` – subscribe to the highlighted item. +- `ctrl+g` – search glossary terms. `Escape` from the search returns to Shop for Data. +- `ctrl+k` – bookmark the highlighted item; `ctrl+b` – open bookmarks with its GUID ready to add. +- `Escape` or `q` – back to the main screen. -On the overview screen, `s` subscribes to the selected node, `b` goes back and `q` returns to the main screen. On a sample-data view, `s` subscribes, and `b`, `Escape` or `Enter` go back. +On the overview screen, `ctrl+u` subscribes to the selected node, `Escape` goes back and `q` returns to the main screen. On a sample-data view, `ctrl+u` subscribes, `Escape` goes back to Shop for Data and `q` returns to the main screen. -**Subscribing**, from any of these screens, opens the **Create Subscription Request** form. It has Display Name, Description, Identifier and Status (`DRAFT`, `PROPOSED` or `ACTIVE`; an invalid value becomes `DRAFT`). Press `c` to create the digital subscription in Egeria. The subscription is linked to the item you chose and to you as its subscriber. If either link can't be made, a warning says so; the subscription itself is still created. +**Subscribing**, from any of these screens, opens the **Create Subscription Request** form. It has Display Name, Description, Identifier and Status (`DRAFT`, `PROPOSED` or `ACTIVE`; an invalid value becomes `DRAFT`). Press `ctrl+s` to create the digital subscription in Egeria (`Escape` cancels). The subscription is linked to the item you chose and to you as its subscriber. If either link can't be made, a warning says so; the subscription itself is still created. ### Edit Profile -A form pre-filled with your profile details (courtesy title, job title, given names, surname, preferred name, pronouns, description, time zone, employee ID, preferred language, resident country). Press **Edit Profile** to save. The screen also has shortcuts that open the Communities (`ctrl+c`), Identities (`ctrl+i`), Roles (`ctrl+r`) and Teams (`ctrl+t`) edit screens. Press `q` to return. +A form pre-filled with your profile details (courtesy title, job title, given names, surname, preferred name, pronouns, description, time zone, employee ID, preferred language, resident country). Press **Edit Profile** (or `ctrl+s`) to save. The screen also has shortcuts that open the Communities (`ctrl+g`), Identities (`ctrl+l`), Roles (`ctrl+r`) and Teams (`ctrl+t`) edit screens. Press `Escape` to return. ### Subscriptions -A table of the digital subscriptions you created: name, status, description and GUID. Press `q` to return. +A table of the digital subscriptions you created: name, status, description and GUID. Press `Escape` or `q` to return. ### Technology Types Browse the technology type hierarchy as a tree. Selecting a type shows its **catalog templates** and **governance action processes**: @@ -236,27 +236,27 @@ Browse the technology type hierarchy as a tree. Selecting a type shows its **cat - **Templates:** pick one and press **Select Template**. Fill in its placeholder values and submit. Egeria creates a new element from the template, and a **status screen** shows the result. - **Processes:** pick one and press **Select Process**, then fill in its request parameters and submit. Only the parameters you fill in are sent. Egeria starts the governance action process, and a **status screen** shows its GUID. -On the status screen, `c` copies the GUID shown to your clipboard, and `Enter`/`q` closes it and returns you to the main screen. +On the status screen, `ctrl+y` copies the GUID shown to your clipboard, and `Enter`, `Escape` or `q` closes it and returns you to the main screen. ### User Bookmarks Opens the bookmarks screen (also `ctrl+b` on the main screen and on Shop for Data). It lists your bookmarks (name, type, GUID). -The easiest way to add a bookmark is from a table: select a row and press `ctrl+k` on the main screen, or highlight a row and press `k` on Shop for Data. Any row that has a GUID can be bookmarked, and so can a glossary row, which is identified by its qualified name. Bookmarking something that is already bookmarked just tells you so. +The easiest way to add a bookmark is from a table: select a row and press `ctrl+k` on the main screen, or highlight a row and press `ctrl+k` on Shop for Data. Any row that has a GUID can be bookmarked, and so can a glossary row, which is identified by its qualified name. Bookmarking something that is already bookmarked just tells you so. On the bookmarks screen: -- `ctrl+n` shows a field for the GUID of an element to bookmark. If you opened the screen with `ctrl+b` while a row was highlighted, that row's GUID is already filled in; otherwise copy a GUID from any table, or with `c` on a status screen. -- `d` removes the highlighted bookmark (the element itself is not deleted). -- `q` returns. +- `ctrl+n` shows a field for the GUID of an element to bookmark. If you opened the screen with `ctrl+b` while a row was highlighted, that row's GUID is already filled in; otherwise copy a GUID from any table, or with `ctrl+y` on a status screen. +- `ctrl+d` removes the highlighted bookmark (the element itself is not deleted). +- `Escape` or `q` returns. Your bookmarks are kept in Egeria as the members of your private **My Bookmarks** collection (qualified name `Bookmarks::`), and only that collection. It is created the first time you bookmark something, anchored to your profile and linked from it with a `ResourceList` relationship whose resource use is "Bookmarks". ### My Network Your network is the people linked to your profile in Egeria. The screen lists them (qualified name, display name, full name, pronouns, GUID and the description you gave the link). If your profile has not finished loading, the app tells you to wait and does not open the screen. -- **View details:** highlight a person and press `v` (or **Person Details**). A panel shows their **contact methods** and the **communities** they belong to. A message tells you if either is empty. +- **View details:** highlight a person and press `ctrl+o` (or **Person Details**). A panel shows their **contact methods** and the **communities** they belong to. A message tells you if either is empty. - **Add someone:** type all or part of a name in the search field and press `Enter`. Up to 10 matching people are listed, leaving out you and anyone already in your network. Select a person, optionally enter a **nickname** and a **description** of your relationship, then press **Add to Network**. **Cancel** clears the selection. -- **Remove someone:** highlight a person and press `d` (or **Remove from Network**), then confirm with **Remove**. **Cancel** or `Escape` keeps them. Only the link between you is removed; their profile is not changed. -- `q` returns to the main screen. +- **Remove someone:** highlight a person and press `ctrl+d` (or **Remove from Network**), then confirm with **Remove**. **Cancel** or `Escape` keeps them. Only the link between you is removed; their profile is not changed. +- `Escape` or `q` returns to the main screen. Each person in your network is linked to your profile with a `Peer` relationship. The nickname and description are stored on that relationship. @@ -265,31 +265,35 @@ See [Leaving Feedback](#leaving-feedback). ## Keyboard Reference -| Key | Where | Action | +Every screen follows the same rules: + +- `Escape` goes back one level, on every screen except the main screen. +- `q` returns to the main screen, or quits the app when you are on the main screen. Screens that are mostly text fields (Add screens, Add Comment, Create Subscription, Create/Edit Profile, Search for Term, Feedback, Template and Process forms) have no `q`, because it would be typed into the field; use `Escape`. +- Every other action is a `ctrl` key, and a key means the same thing wherever it appears. `ctrl` keys work even while a text field has focus. + +| Key | Action | Where | | :--- | :--- | :--- | -| `q` | Main screen | Quit the app | -| `r` | Main screen | Refresh all data from Egeria | -| `ctrl+f` | Any screen | Leave feedback | -| `F3` | Any screen (feedback owner only) | Show the feedback log | -| `ctrl+t` | Main screen | Edit the selected table | -| `ctrl+a` | Main screen | Comment on the selected row | -| `ctrl+s` | Main screen | Show comments for the selected row | -| `ctrl+b` | Main screen, Shop for Data | Bookmarks, with the highlighted row's GUID ready to add | -| `ctrl+k` | Main screen | Bookmark the selected row | -| `ctrl+n` / `d` / `q` | Bookmarks | Add by GUID / remove highlighted / close | -| `v` / `d` / `q` | My Network | Show highlighted person's details / remove highlighted person / close | -| `Enter` | My Network search field | Search for people to add | -| `a` / `d` / `Escape` | Edit screens | Add row / delete row / back | -| `ctrl+a` / `q` | Add screens | Add / close (outside a text field) | -| `ctrl+a` / `ctrl+r` / `q` | Comments screen | Add comment / show responses to highlighted comment / close | -| `s` / `u` or `ctrl+s` / `t` / `k` / `b` or `Escape` / `q` | Shop for Data | Sample / subscribe / search glossary terms / bookmark / back / close | -| `s` / `b` / `q` | Overview | Subscribe / back / back to main | -| `c` / `q` | Create Subscription | Create / cancel | -| `c` / `Enter` / `q` | Status screen | Copy GUID / close / close | -| `Escape` | Splash screen | Continue to the app | -| `ctrl+s` / `Escape` | Feedback | Submit / cancel | -| `Escape` | Confirm Delete | Cancel | -| `ctrl+c` | Anywhere | Force quit (terminal) | +| `Escape` | Back | Every screen except Main; continue on the splash screen; cancel on confirm dialogs | +| `q` | Main menu / quit the app | Every list screen / Main screen | +| `Enter` | Select the highlighted row; continue | Tables and trees; Status screen | +| `ctrl+s` | Save / submit the form | Add screens, Add Comment, Create Subscription, Create/Edit Profile, Feedback | +| `ctrl+n` | Add a new item | Edit screens (row), Bookmarks, Comments, Main screen (comment) | +| `ctrl+d` | Delete / remove the highlighted item | Edit screens, Bookmarks, My Network | +| `ctrl+o` | Open the highlighted item | Main screen (comments), Comments (responses), My Network (person details) | +| `ctrl+t` | Edit the selected table | Main screen | +| `ctrl+k` | Bookmark the highlighted row | Main screen, Shop for Data | +| `ctrl+b` | Bookmarks, with the highlighted row's GUID ready to add | Main screen, Shop for Data | +| `ctrl+u` | Subscribe | Shop for Data, Overview, sample-data view | +| `ctrl+l` | Show sample data | Shop for Data | +| `ctrl+g` | Search glossary terms | Shop for Data | +| `ctrl+e` | Expand / collapse the whole tree | Technology Types | +| `ctrl+y` | Copy the GUID shown | Status screen | +| `ctrl+x` | Close, flagging a bad result | Status screen | +| `ctrl+g` / `ctrl+l` / `ctrl+r` / `ctrl+t` | Edit Communities / Identities / Roles / Teams | Edit Profile | +| `ctrl+r` | Refresh all data from Egeria | Main screen | +| `ctrl+f` | Leave feedback | Any screen | +| `F3` | Show the feedback log | Any screen (feedback owner only) | +| `ctrl+c` | Force quit (terminal) | Anywhere | ## Exit and Return Codes @@ -322,7 +326,7 @@ The full list is in `RETURN_CODES.md` in the app folder. - the view server named by `EGERIA_VIEW_SERVER` is running; - `EGERIA_USER` and `EGERIA_USER_PASSWORD` are correct, and the user is allowed to use the view server. -**Something I added doesn't appear.** Press `r` on the main screen. Communities you create are not linked to you yet (see Known Limitations), so they don't appear in User Associations. +**Something I added doesn't appear.** Press `ctrl+r` on the main screen. Communities you create are not linked to you yet (see Known Limitations), so they don't appear in User Associations. **The app quit unexpectedly.** The app only exits by itself in a few cases: a startup problem (codes `402`, `403`, `430`), a failure fetching technology types (`416`), or an error looking up a team (`440`). diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/AddCommentScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/AddCommentScreen.py index 085f6529..4b57e75a 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/AddCommentScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/AddCommentScreen.py @@ -18,9 +18,8 @@ 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") + ("escape", "cancel", "Back"), + ("ctrl+s", "add_comment", "Add Comment"), ] CSS_PATH = "my_profile.tcss" diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/AddToElementsScreens.py b/my_egeria/my_egeria/DemoCode/My_Profile/AddToElementsScreens.py index f013534e..2da63d28 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/AddToElementsScreens.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/AddToElementsScreens.py @@ -30,8 +30,8 @@ class BaseAddScreen(ModalScreen): """ BINDINGS = [ - ("q", "quit", "Quit"), - ("ctrl+a", "add_element", "Add"), + ("escape", "quit", "Back"), + ("ctrl+s", "add_element", "Add"), ] CSS_PATH = "my_profile.tcss" @@ -202,7 +202,8 @@ class AddAssociationScreen(ModalScreen): """ BINDINGS = [ - ("q", "quit", "Quit"), + ("escape", "quit", "Back"), + ("q", "quit", "Main Menu"), ] CSS_PATH = "my_profile.tcss" diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/CreateProfileScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/CreateProfileScreen.py index 7408c676..673e68f4 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/CreateProfileScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/CreateProfileScreen.py @@ -22,7 +22,8 @@ class CreateProfileScreen(ModalScreen[int]): 4xx on failure """ - BINDINGS = [("q", "dismiss(200)", "Quit")] + BINDINGS = [("escape", "quit", "Back"), + ("ctrl+s", "create_profile", "Create Profile")] CSS_PATH = "my_profile.tcss" def __init__(self, user: str | None = None, password: str | None = None, view_server: str | None = None, platform_url: str | None = None, *args, **kwargs): @@ -66,6 +67,10 @@ def compose(self) -> ComposeResult: yield Static() yield Footer() + def action_create_profile(self) -> None: + """ ctrl+s does the same as pressing the Create Profile button """ + self.create_profile() + @on(Button.Pressed, "#create_profile_btn") def create_profile(self) -> int: """Create a new profile in Egeria from data provided in Input fields.""" diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/CreateSubscriptionRequestScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/CreateSubscriptionRequestScreen.py index 65304332..4f3ae925 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/CreateSubscriptionRequestScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/CreateSubscriptionRequestScreen.py @@ -16,8 +16,8 @@ class CreateSubscriptionRequestScreen(ModalScreen): BINDINGS = [ - ("q", "quit", "Quit"), - ("c", "create_subscription", "Create Subscription") + ("escape", "quit", "Back"), + ("ctrl+s", "create_subscription", "Create Subscription"), ] CSS_PATH = "my_profile.tcss" diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/EditElementsScreens.py b/my_egeria/my_egeria/DemoCode/My_Profile/EditElementsScreens.py index d38c7701..7e65ece1 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/EditElementsScreens.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/EditElementsScreens.py @@ -96,9 +96,10 @@ class BaseEditScreen(ModalScreen): SOURCE_TABLE: str = "" BINDINGS = [ - ("escape", "exit_screen", "Exit"), - ("a", "add_row", "Add Row"), - ("d", "delete_row", "Delete Row"), + ("escape", "exit_screen", "Back"), + ("q", "exit_screen", "Main Menu"), + ("ctrl+n", "add_row", "Add Row"), + ("ctrl+d", "delete_row", "Delete Row"), ] CSS_PATH = "my_profile.tcss" @@ -120,7 +121,7 @@ def compose(self) -> ComposeResult: yield Header(show_clock=True) yield Static(self.TITLE_TEXT, classes="span-3", id=self.TITLE_ID) yield Static( - " Press 'a' to add a row, 'd' to delete the selected row, Esc to exit." + " Press Ctrl+N to add a row, Ctrl+D to delete the selected row, Esc to go back." " Please note you may only delete one row at a time!", classes="span-3", ) @@ -351,11 +352,14 @@ class EditProfileScreen(ModalScreen[Any]): 4xx on failure """ - BINDINGS = [("q", "dismiss(200)", "Quit"), - ("ctrl+c", "Edit_communities", "Edit communities"), - ("ctrl+i", "Edit_identities", "Edit identities"), - ("ctrl+r", "Edit_roles", "Edit roles"), - ("ctrl+t", "Edit_teams", "Edit teams"), + # ctrl+c and ctrl+i can't be used here: ctrl+c is Textual's copy/quit key and a + # terminal sends ctrl+i as Tab. Input swallows ctrl+a/d/e/k/u, so these avoid those too. + BINDINGS = [("escape", "quit", "Back"), + ("ctrl+s", "create_profile", "Save Profile"), + ("ctrl+g", "Edit_communities", "Edit Communities"), + ("ctrl+l", "Edit_identities", "Edit Identities"), + ("ctrl+r", "Edit_roles", "Edit Roles"), + ("ctrl+t", "Edit_teams", "Edit Teams"), ] CSS_PATH = "my_profile.tcss" @@ -470,6 +474,10 @@ def compose(self) -> ComposeResult: yield Static() yield Footer() + def action_create_profile(self) -> None: + """ ctrl+s does the same as pressing the Edit Profile button """ + self.create_profile() + @on(Button.Pressed, "#edit_profile_btn") def create_profile(self) -> Any: """Update profile in Egeria from data provided in Input fields.""" diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/FeedbackScreens.py b/my_egeria/my_egeria/DemoCode/My_Profile/FeedbackScreens.py index 01479a62..1c12adb8 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/FeedbackScreens.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/FeedbackScreens.py @@ -22,8 +22,8 @@ class FeedbackScreen(ModalScreen): """ Collect feedback about the screen the user was on when they pressed the feedback key """ BINDINGS = [ + ("escape", "cancel", "Back"), ("ctrl+s", "submit", "Submit Feedback"), - ("escape", "cancel", "Cancel"), ] CSS_PATH = "my_profile.tcss" @@ -98,8 +98,8 @@ class FeedbackLogScreen(ModalScreen): """ Show the accumulated entries in the private feedback note log """ BINDINGS = [ - ("q", "quit", "Quit"), ("escape", "quit", "Back"), + ("q", "quit", "Main Menu"), ] CSS_PATH = "my_profile.tcss" diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/GenericDataViewScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/GenericDataViewScreen.py index 95599ff2..453220c4 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/GenericDataViewScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/GenericDataViewScreen.py @@ -23,11 +23,9 @@ class GenericDataViewScreen(ModalScreen): """ BINDINGS = [ - ("q", "quit", "Quit"), - ("b", "back", "Go back"), ("escape", "back", "Back"), - ("return", "back", "Back"), - ("s", "subscribe", "Subscribe to Data Source"), + ("q", "quit", "Main Menu"), + ("ctrl+u", "subscribe", "Subscribe"), ] CSS_PATH = "my_profile.tcss" diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py index 9b183932..7615def5 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py @@ -13,17 +13,19 @@ from textual.widgets import DataTable, OptionList, Header, Static, Footer from textual.widgets._option_list import Option +from pyegeria import load_app_config, settings + class MainScreen(Screen): """Main Screen for My Profile App.""" BINDINGS = [ ("q", "app.quit", "Quit"), - ("ctrl+a", "add_comment","add comment to selected row"), - ("ctrl+b", "bookmarks", "Manage Bookmarks"), - ("ctrl+k", "bookmark_row", "Bookmark Selected Row"), - ("ctrl+s", "show_comments", "Show Comments for Selected Row"), ("ctrl+t", "edit_table", "Edit Selected Table"), + ("ctrl+o", "show_comments", "Show Comments"), + ("ctrl+n", "add_comment", "Add Comment"), + ("ctrl+k", "bookmark_row", "Bookmark Row"), + ("ctrl+b", "bookmarks", "Bookmarks"), ] CSS_PATH = "my_profile.tcss" @@ -31,10 +33,18 @@ class MainScreen(Screen): def __init__(self, *args, **kwargs): super().__init__(id="main_screen", *args, **kwargs) self.title = "Egeria" - self.sub_title = "My Profile" self.other_function_list: OptionList = OptionList(id="other_function_list") self.selected_table = None self.selected_row = None + load_app_config() + app_user = settings.User_Profile + self.user_name = app_user.user_name or "garygeeke" + self.sub_title = f"User: {self.user_name}" + + def on_mount(self) -> None: + # self.log needs a running app, so it can't be called from __init__ + self.log(f"Application Config: {settings.Environment}") + self.log(f"User Profile: {settings.User_Profile}") def compose(self) -> ComposeResult: # place widgets into grid on screen, note sequence determines position! diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/MyBookMarksScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/MyBookMarksScreen.py index 8d3a16f2..c29ea38d 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/MyBookMarksScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/MyBookMarksScreen.py @@ -20,9 +20,10 @@ class MyBookMarksScreen(ModalScreen): """ BINDINGS = [ - ("q", "quit", "Quit"), - ("ctrl+n", "new_bookmark", "Add New Bookmark"), - ("d", "remove_bookmark", "Remove Highlighted Bookmark"), + ("escape", "quit", "Back"), + ("q", "quit", "Main Menu"), + ("ctrl+n", "new_bookmark", "Add Bookmark"), + ("ctrl+d", "remove_bookmark", "Remove Bookmark"), ] CSS_PATH = "my_profile.tcss" @@ -39,7 +40,7 @@ def __init__(self, my_bookmarks, *args, target_guid: str | None = None, **kwargs def compose(self) -> ComposeResult: yield Header(show_clock=True) yield ScrollableContainer( - Static("Your bookmarks. Ctrl+N adds one by GUID; 'd' removes the highlighted one."), + Static("Your bookmarks. Ctrl+N adds one by GUID; Ctrl+D removes the highlighted one."), DataTable(id="my_bookmark_table", zebra_stripes=True, cursor_type="row"), id="bookmarks_table_container", ) diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/MyNetworkScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/MyNetworkScreen.py index b2f42a50..cf6c02eb 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/MyNetworkScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/MyNetworkScreen.py @@ -64,9 +64,10 @@ class MyNetworkScreen(ModalScreen): """ BINDINGS = [ - ("q", "quit", "Quit"), - ("v", "view_details", "View Highlighted Person"), - ("d", "remove_peer", "Remove Highlighted Person"), + ("escape", "quit", "Back"), + ("q", "quit", "Main Menu"), + ("ctrl+o", "view_details", "Open Person"), + ("ctrl+d", "remove_peer", "Remove Person"), ] CSS_PATH = "my_profile.tcss" diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/MyTeamScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/MyTeamScreen.py index 6a01f8d4..8ac9674d 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/MyTeamScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/MyTeamScreen.py @@ -18,8 +18,8 @@ class MyTeam(ModalScreen): """ Display a list of team Members for a Team Leader """ BINDINGS = [ - ("q", "quit", "Quit"), - ("b", "back", "Go back") + ("escape", "back", "Back"), + ("q", "quit", "Main Menu"), ] CSS_PATH = "my_profile.tcss" diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/SearchForTermScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/SearchForTermScreen.py index 9404b0bc..af2ef78a 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/SearchForTermScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/SearchForTermScreen.py @@ -17,8 +17,7 @@ class SearchForTermScreen(ModalScreen): - BINDINGS = [("q", "quit", "Quit"), - ("g", "go_back", "Go back"), + BINDINGS = [("escape", "go_back", "Back"), ] CSS_PATH = "my_profile.tcss" diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/SelectionOverviewScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/SelectionOverviewScreen.py index 41ee4a3f..1ef46122 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/SelectionOverviewScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/SelectionOverviewScreen.py @@ -21,9 +21,9 @@ class SelectionOverviewScreen(ModalScreen): """Screen to display the selection of the user's data sources.""" - BINDINGS = [("q", "quit", "Quit"), - ("s", "subscribe", "Subscribe to Data Source"), - ("b", "back", "Go back")] + BINDINGS = [("escape", "back", "Back"), + ("q", "quit", "Main Menu"), + ("ctrl+u", "subscribe", "Subscribe")] CSS_PATH = "my_profile.tcss" diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/ShopForDataScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/ShopForDataScreen.py index b99c2937..35cd4e08 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/ShopForDataScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/ShopForDataScreen.py @@ -11,7 +11,6 @@ from pyegeria import load_app_config, settings from textual import on from textual.app import ComposeResult -from textual.binding import Binding from textual.containers import ScrollableContainer from textual.events import Action from textual.screen import Screen @@ -20,14 +19,13 @@ class ShopForDataScreen(Screen): """ Screen to Present a choice of different data sources to the user.""" - BINDINGS = [("q", "dismiss(200)", "Quit"), - ("b", "back", "Go back"), - Binding("escape", "back", "Go back", show=False), - ("s", "sample_data_source", "Sample data source"), - ("u", "subscribe_to_data_source", "Subscribe"), - ("ctrl+s", "subscribe_to_data_source", "Subscribe"), - ("t", "search_for_term", "Search Glossary Terms"), - ("k", "bookmark_row", "Bookmark"), + BINDINGS = [("escape", "back", "Back"), + # Opened straight from the main screen, so back and main menu are the same place + ("q", "back", "Main Menu"), + ("ctrl+l", "sample_data_source", "Sample Data"), + ("ctrl+u", "subscribe_to_data_source", "Subscribe"), + ("ctrl+g", "search_for_term", "Search Glossary Terms"), + ("ctrl+k", "bookmark_row", "Bookmark Row"), ("ctrl+b", "bookmarks", "Bookmarks")] CSS_PATH = "my_profile.tcss" @@ -89,7 +87,7 @@ def compose(self) -> ComposeResult: """ Compose the UI components for the ShopForDataScreen screen.""" yield Header(show_clock=True) # A Static, not a TextArea: a focused TextArea swallows the screen's letter key bindings (b, q, s, ...) - yield Static("\nPlease click on a line to select a data source, or press b or Escape to go back:") + yield Static("\nPlease click on a line to select a data source, or press Escape to go back:") yield ScrollableContainer( Static("Glossary"), self.glossary_table) diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/ShowCommentsScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/ShowCommentsScreen.py index c27df8d7..2a124655 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/ShowCommentsScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/ShowCommentsScreen.py @@ -21,9 +21,10 @@ class ShowCommentsScreen(ModalScreen): """Show any comments for an element Screen for My Profile App.""" BINDINGS = [ - ("q", "quit", "Quit"), - ("ctrl+a", "add_comment", "Add Comment"), - ("ctrl+r", "add_response", "Add Response"), + ("escape", "quit", "Back"), + ("q", "quit", "Main Menu"), + ("ctrl+n", "add_comment", "Add Comment"), + ("ctrl+o", "add_response", "Open Responses"), ] CSS_PATH = "my_profile.tcss" @@ -40,7 +41,7 @@ def __init__( 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) + # The comment currently highlighted in the DataTable (target for Ctrl+O) self.highlighted_comment_guid: str | None = None app_config = settings.Environment app_user = settings.User_Profile @@ -116,15 +117,15 @@ def load_comments(self, element_guid: str) -> None: 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) + self.notify("This comment has no responses yet - use Ctrl+N to add one", timeout=10) else: - self.notify("Selected element does not have any comments - use Ctrl+A to add one", timeout=10) + self.notify("Selected element does not have any comments - use Ctrl+N 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")) + container.mount(Static("Ctrl+O on a highlighted comment to view and add responses to it")) def extract_backend_identifier(self, table_name, row_key): try: diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/StatusScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/StatusScreen.py index 90ca4695..f7c6a000 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/StatusScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/StatusScreen.py @@ -18,10 +18,11 @@ class StatusScreen(ModalScreen): """Modal screen to display the status of the application.""" - BINDINGS = [("q", "quit", "Quit"), - ("return", "successful", "Continue"), - ("b", "unsuccessful", "Bad result"), - ("c", "copy_guid_to_clipboard", "Copy GUID to clipboard"), + BINDINGS = [("enter", "successful", "Continue"), + ("escape", "successful", "Back"), + ("q", "quit", "Main Menu"), + ("ctrl+y", "copy_guid_to_clipboard", "Copy GUID"), + ("ctrl+x", "unsuccessful", "Bad Result"), ] CSS_PATH = "my_profile.tcss" diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/TechnologyTypeScreens.py b/my_egeria/my_egeria/DemoCode/My_Profile/TechnologyTypeScreens.py index fd5c4309..9a53c545 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/TechnologyTypeScreens.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/TechnologyTypeScreens.py @@ -21,9 +21,8 @@ class TechnologyTypeOptionsScreen(ModalScreen): """ Modal screen to display a technology type's templates and processes.""" - BINDINGS = [("q", "dismiss", "Quit"), - ("b", "go_back", "Go_Back"), - # ("ctl+s", "Select", "tech_type_option_select") + BINDINGS = [("escape", "go_back", "Back"), + ("q", "quit", "Main Menu"), ] CSS_PATH = "my_profile.tcss" @@ -239,7 +238,7 @@ def action_go_back(self) -> None: """ Handle the back key press.""" self.dismiss("back") - def action_Quit(self) -> None: + def action_quit(self) -> None: """ Handle the quit key press""" self.dismiss(200) @@ -261,8 +260,7 @@ def handle_process_selected(self, event: Button.Pressed) -> None: class TechnologyTypeProcessesScreen(ModalScreen[Any]): """Modal screen to display technology type processes in Egeria.""" - BINDINGS = [("q", "quit", "Quit"), - ("b", "quit", "Go back")] + BINDINGS = [("escape", "quit", "Back")] CSS_PATH = "my_profile.tcss" @@ -406,9 +404,9 @@ def handle_input_changed(self, event: Input.Changed) -> None: class TechnologyTypesScreen(ModalScreen): """Modal screen to display technology types in Egeria.""" - BINDINGS = [("q", "dismiss(200)", "Quit"), - ("g", "go_back", "Back"), - ("ctrl+e", "expand", "Toggle Twisties")] + BINDINGS = [("escape", "go_back", "Back"), + ("q", "quit", "Main Menu"), + ("ctrl+e", "expand", "Expand/Collapse All")] CSS_PATH = "my_profile.tcss" @@ -543,8 +541,7 @@ def render_tech_type_hierarchy_to_tree(self, class TechnologyTypeTemplatesScreen(ModalScreen[Any]): """Modal screen to display technology type templates in Egeria.""" - BINDINGS = [("q", "dismiss(200)", "Quit"), - ("b", "quit", "Go back")] + BINDINGS = [("escape", "quit", "Back")] CSS_PATH = "my_profile.tcss" diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/UserIdentitiesScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/UserIdentitiesScreen.py index 4ff72ede..7888e651 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/UserIdentitiesScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/UserIdentitiesScreen.py @@ -19,8 +19,8 @@ class UserIdentitiesScreen(ModalScreen): """ Modal Screen to display the user identities as stored in Egeria """ BINDINGS = [ - ("escape", "app.pop_screen", "Exit Screen"), - ("q", "app.pop_screen", "Exit Screen"), + ("escape", "app.pop_screen", "Back"), + ("q", "app.pop_screen", "Main Menu"), ] CSS_PATH = "my_profile.tcss" 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 index 4109a9c7..d84ec809 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/VIDEO_SCRIPT_DRAFT.md +++ b/my_egeria/my_egeria/DemoCode/My_Profile/VIDEO_SCRIPT_DRAFT.md @@ -49,17 +49,17 @@ > 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. +**[ON-SCREEN CALLOUT]** Point out the keyboard shortcuts in the footer: `Ctrl+T` Edit, `Ctrl+O` 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. +**[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+O` 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. +> **Ctrl+O** 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. @@ -67,7 +67,7 @@ ## 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. +**[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 `Ctrl+L` to **sample** it, then `Ctrl+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. @@ -110,7 +110,7 @@ - **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. +- **Captions:** burn in the keyboard shortcuts (`Ctrl+T`, `Ctrl+O`, `Ctrl+B`, `Ctrl+L`, `Ctrl+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/ViewSubscriptionsScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/ViewSubscriptionsScreen.py index 0b12122a..ed8784f1 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/ViewSubscriptionsScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/ViewSubscriptionsScreen.py @@ -19,7 +19,8 @@ class ViewSubscriptionsScreen(ModalScreen): """View the digital subscriptions created by the current user.""" BINDINGS = [ - ("q", "quit", "Quit"), + ("escape", "quit", "Back"), + ("q", "quit", "Main Menu"), ] CSS_PATH = "my_profile.tcss" @@ -37,7 +38,7 @@ def __init__(self, *args, **kwargs): def compose(self) -> ComposeResult: yield Header(show_clock=True) yield ScrollableContainer( - Static("Your digital subscriptions. Press q to return.", id="view_subsciptions_static"), + Static("Your digital subscriptions. Press Escape to go back.", id="view_subsciptions_static"), DataTable(id="subscriptions_table", zebra_stripes=True, cursor_type="row"), id="view-subscriptions_container", ) 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 336e1dbf..281e6cb0 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 @@ -242,7 +242,7 @@ def show_comments(self, table_name: str | None = None, row_k: Any = 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. + comment (its responses) and Ctrl+N 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( 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 2b3674bf..79710c50 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 @@ -104,7 +104,7 @@ class MyProfileApp(App, TechTypesMixin, ShopForDataMixin, TeamRolesMixin, Elemen # means ctrl+f wins over the Input/TextArea "delete word right" binding on the same key. BINDINGS = [ ("q", "quit", "Quit"), - ("r", "refresh", "Refresh Data"), + ("ctrl+r", "refresh", "Refresh Data"), Binding("ctrl+f", "feedback", "Feedback", priority=True), Binding("f3", "view_feedback_log", "Feedback Log", priority=True), ] diff --git a/tests/micro-tests/my_profile/test_key_bindings.py b/tests/micro-tests/my_profile/test_key_bindings.py new file mode 100644 index 00000000..d71692e2 --- /dev/null +++ b/tests/micro-tests/my_profile/test_key_bindings.py @@ -0,0 +1,130 @@ +""" + PDX-License-Identifier: Apache-2.0 + Copyright Contributors to the ODPi Egeria project. + + Guards the app-wide key binding convention so every My Profile screen behaves the same: + escape back one level (every screen except Main) + q the only plain-letter key: Main Menu on sub-screens, Quit on Main. + Left off text-entry forms, where it would be typed into the Input instead. + ctrl+* every other action, one meaning per key (see docs/my_profile_app_manual.md) +""" + +import inspect + +import pytest +from textual.app import App +from textual.binding import Binding +from textual.screen import Screen +from textual.widgets import Input + +import AddCommentScreen +import AddToElementsScreens +import CreateProfileScreen +import CreateSubscriptionRequestScreen +import EditElementsScreens +import FeedbackScreens +import GenericDataViewScreen +import MainScreen +import MyBookMarksScreen +import MyNetworkScreen +import MyTeamScreen +import SearchForTermScreen +import SelectionOverviewScreen +import ShopForDataScreen +import ShowCommentsScreen +import SplashScreen +import StatusScreen +import TechnologyTypeScreens +import UserIdentitiesScreen +import ViewSubscriptionsScreen +from my_profile_app import MyProfileApp + +MODULES = [ + AddCommentScreen, AddToElementsScreens, CreateProfileScreen, CreateSubscriptionRequestScreen, + EditElementsScreens, FeedbackScreens, GenericDataViewScreen, MainScreen, MyBookMarksScreen, + MyNetworkScreen, MyTeamScreen, SearchForTermScreen, SelectionOverviewScreen, ShopForDataScreen, + ShowCommentsScreen, SplashScreen, StatusScreen, TechnologyTypeScreens, UserIdentitiesScreen, + ViewSubscriptionsScreen, +] + +SCREENS = sorted( + { + cls + for module in MODULES + for _, cls in inspect.getmembers(module, inspect.isclass) + if issubclass(cls, Screen) and cls.__module__ == module.__name__ + }, + key=lambda c: c.__name__, +) + +# ctrl+c is Textual's copy/quit, a terminal sends ctrl+i as Tab, ctrl+m/ctrl+j as Enter and +# ctrl+h as Backspace, and ctrl+p opens the command palette. "return" is not a Textual key name. +UNUSABLE_KEYS = {"ctrl+c", "ctrl+i", "ctrl+m", "ctrl+j", "ctrl+h", "ctrl+p", "return"} + + +def _bindings(cls): + for b in cls.BINDINGS: + key, action = (b.key, b.action) if isinstance(b, Binding) else (b[0], b[1]) + for k in key.split(","): + yield k.strip(), action + + +@pytest.mark.parametrize("screen_cls", SCREENS, ids=lambda c: c.__name__) +def test_only_q_is_a_plain_letter(screen_cls): + letters = [k for k, _ in _bindings(screen_cls) if len(k) == 1 and k != "q"] + assert not letters, f"{screen_cls.__name__} binds plain letter(s) {letters}; use a ctrl key" + + +@pytest.mark.parametrize("screen_cls", [s for s in SCREENS if s is not MainScreen.MainScreen], + ids=lambda c: c.__name__) +def test_escape_goes_back(screen_cls): + assert "escape" in dict(_bindings(screen_cls)), f"{screen_cls.__name__} has no escape binding" + + +@pytest.mark.parametrize("screen_cls", SCREENS + [MyProfileApp], ids=lambda c: c.__name__) +def test_no_unusable_keys(screen_cls): + bad = [k for k, _ in _bindings(screen_cls) if k in UNUSABLE_KEYS] + assert not bad, f"{screen_cls.__name__} binds {bad}, which never reach the app" + + +@pytest.mark.parametrize("screen_cls", SCREENS, ids=lambda c: c.__name__) +def test_every_binding_has_an_action(screen_cls): + for key, action in _bindings(screen_cls): + if action.startswith("app."): + continue + name = action.split("(")[0] + assert hasattr(screen_cls, f"action_{name}"), f"{screen_cls.__name__}: {key} -> missing action_{name}" + + +class _Host(App): + """Pushes one screen and records what it dismisses with.""" + + def __init__(self, screen): + super().__init__() + self._screen = screen + self.dismissed = "not dismissed" + + def on_mount(self): + self.push_screen(self._screen, callback=lambda r: setattr(self, "dismissed", r)) + + +@pytest.mark.parametrize("key, expected", [("ctrl+g", "community"), ("ctrl+l", "identity"), + ("ctrl+r", "role"), ("ctrl+t", "team"), ("escape", 200)]) +async def test_edit_profile_keys_work_while_typing(key, expected): + app = _Host(EditElementsScreens.EditProfileScreen(user_profile={"displayName": "Gary"}, user_GUID="g-1")) + async with app.run_test() as pilot: + await pilot.pause() + app.screen.query_one("#user_job_title", Input).focus() + await pilot.press(key) + await pilot.pause() + assert app.dismissed == expected + + +@pytest.mark.parametrize("key", ["enter", "escape", "q"]) +async def test_status_screen_close_keys(key): + app = _Host(StatusScreen.StatusScreen("Created element 'guid-1'")) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press(key) + await pilot.pause() + assert app.dismissed == 200 diff --git a/tests/micro-tests/my_profile/test_screens.py b/tests/micro-tests/my_profile/test_screens.py index b2994cd5..6caad2aa 100644 --- a/tests/micro-tests/my_profile/test_screens.py +++ b/tests/micro-tests/my_profile/test_screens.py @@ -764,7 +764,7 @@ async def test_shop_for_data_screen_bookmark_highlighted_row(self): target.focus() target.move_cursor(row=0) await pilot.pause() - await pilot.press("k") + await pilot.press("ctrl+k") await pilot.pause() app.bookmark_table_row.assert_called_once_with(target, row_key) @@ -776,7 +776,7 @@ async def test_shop_for_data_screen_default_config(self): await pilot.pause() assert app.dismissed_result == [210] - @pytest.mark.parametrize("key", ["b", "escape"]) + @pytest.mark.parametrize("key", ["escape", "q"]) @pytest.mark.asyncio async def test_shop_for_data_screen_go_back_key(self, key): # Pressed as soon as the screen opens, without focusing a table first: nothing on the From 0682b9cfebf1cdcd775a2067d2143f8d0d136326 Mon Sep 17 00:00:00 2001 From: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:28:04 -0500 Subject: [PATCH 14/14] feat(my_profile): make the Roles -> My Team view discoverable, changes to User Identities Signed-off-by: Peter Coldicott <86156876+pcoldico@users.noreply.github.com> --- docs/my_profile_app_manual.md | 8 ++++-- .../DemoCode/My_Profile/MainScreen.py | 4 +-- .../DemoCode/My_Profile/RETURN_CODES.md | 4 +-- .../DemoCode/My_Profile/team_roles_handler.py | 11 +++++++- .../my_profile/test_team_roles_handler.py | 28 +++++++++++++++++-- 5 files changed, 45 insertions(+), 10 deletions(-) diff --git a/docs/my_profile_app_manual.md b/docs/my_profile_app_manual.md index b7033bce..a5f2187e 100644 --- a/docs/my_profile_app_manual.md +++ b/docs/my_profile_app_manual.md @@ -126,7 +126,7 @@ The header shows your full name, user ID and **Karma Points** (your contribution | **User Associations** | Status or Type, Name, Description, GUID | The communities you belong to | | **My Collections** | Collection Name, Collection Description, Collection GUID | Collections you created (your bookmarks collection is not listed here) | | **Other Functions** | – | A menu of further features ([below](#other-functions-menu)) | -| **Roles** | Role Name, Role Type, Description, GUID | Roles you have been appointed to | +| **Roles** | Role Name, Role Type, Description, GUID | Roles you have been appointed to. Press `Enter` on a TeamLeader or TeamMember role (or click it a second time) to see the other people in that team | | **Teams** | Assignment Type, Team Name, Description, GUID | Teams you are a member of | | **Blogs** | Blog Title, Date, Text, GUID | Your blog entries | | **Journal** | Journal Entry, Date, Text, GUID | Your journal entries | @@ -136,6 +136,8 @@ The header shows your full name, user ID and **Karma Points** (your contribution Click or move into a table to select it; the highlighted row is the "selected row" used by the shortcuts below. Press `ctrl+r` to reload everything from Egeria. The app also reloads automatically when you close an Add screen. +**Seeing your team.** Selecting a TeamLeader or TeamMember role in the Roles table (for example `TeamLeader::Department::0010`) opens the **My Team** screen, which lists the team's members and their assignment types; press `Escape` to return. Selecting any other role shows a warning that it is not a team role. If Egeria returns no members for the team, or the lookup fails, a message says so. + Main screen shortcuts: | Key | Action | @@ -313,7 +315,7 @@ When the app exits it returns a code; screens also use codes to talk to the app. | `412` / `413` | Error / empty result reloading a newly created profile | | `416` | Technology types could not be fetched | | `430` | Incompatible Python or pydantic version | -| `440` | Team, role or glossary lookup error | +| `440` | Glossary term lookup error | The full list is in `RETURN_CODES.md` in the app folder. @@ -328,7 +330,7 @@ The full list is in `RETURN_CODES.md` in the app folder. **Something I added doesn't appear.** Press `ctrl+r` on the main screen. Communities you create are not linked to you yet (see Known Limitations), so they don't appear in User Associations. -**The app quit unexpectedly.** The app only exits by itself in a few cases: a startup problem (codes `402`, `403`, `430`), a failure fetching technology types (`416`), or an error looking up a team (`440`). +**The app quit unexpectedly.** The app only exits by itself in a few cases: a startup problem (codes `402`, `403`, `430`), a failure fetching technology types (`416`), or an error looking up a glossary term (`440`). A failed team lookup is reported as a message instead. ## Known Limitations diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py index 7615def5..aff78958 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py @@ -64,7 +64,7 @@ def compose(self) -> ComposeResult: Static(f"Other Functions"), Static(f"[b]Select a function[/b]"), OptionList( - Option("[] User Identities"), + # Option("[] User Identities"), Option("[] Catalogs/Shop for Data"), Option("[] Edit Profile"), Option("[] Subscriptions"), @@ -78,7 +78,7 @@ def compose(self) -> ComposeResult: id="other_function_container" ) yield ScrollableContainer( - Static("Roles"), + Static("Roles [dim](Enter on a TeamLeader/TeamMember role: show team)[/dim]"), DataTable(id="roles_table"), id="main_roles_container" ) 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 de3e376e..080c92e5 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/RETURN_CODES.md +++ b/my_egeria/my_egeria/DemoCode/My_Profile/RETURN_CODES.md @@ -48,7 +48,7 @@ In accordance with application conventions: | **`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` | +| **`440`** | `TEAM_ROLES_OR_GLOSSARY_ERROR` | Error communicating with Egeria or retrieving glossary term details. (A failed team members lookup is now shown as an error notification and does not exit the app.) | `team_roles_handler.py` | --- @@ -59,4 +59,4 @@ In accordance with application conventions: - **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. +- **440**: Glossary term query communication errors. diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/team_roles_handler.py b/my_egeria/my_egeria/DemoCode/My_Profile/team_roles_handler.py index 0b050795..e9d3fdc3 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/team_roles_handler.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/team_roles_handler.py @@ -70,8 +70,17 @@ def handle_roles_table_row_selection(self, event: DataTable.RowSelected) -> Any: if not isinstance(result, (tuple, list)) or len(result) != 5: return team_members_list, team_display_name, team_qualified_name, team_category, team_description = result + if team_display_name is None: + # find_team_members has already told the user why there is no team to show + return else: self.log(f"Selected role is not a TeamLeader or TeamMember: {selected_role_name}") + self.notify( + f"'{selected_role_name}' is not a team role, so there are no team members to show. " + "Select a TeamLeader or TeamMember role.", + timeout=5, + severity="warning", + ) return 201 self.log(f"team_members_list: {team_members_list}") @@ -131,7 +140,7 @@ def find_team_members(self, role_name: str) -> list[Any]: except PyegeriaException as e: print_basic_exception(e) self.log(f"Error retrieving team members: {e!s}") - self.exit(440) + self.notify(f"Could not retrieve the team members from Egeria: {e!s}", timeout=20, severity="error") return [[], None, None, None, None] self.log(f"team_member_data: {team_member_data}") diff --git a/tests/micro-tests/my_profile/test_team_roles_handler.py b/tests/micro-tests/my_profile/test_team_roles_handler.py index 35221cd5..04cfcb76 100644 --- a/tests/micro-tests/my_profile/test_team_roles_handler.py +++ b/tests/micro-tests/my_profile/test_team_roles_handler.py @@ -24,6 +24,7 @@ class DummyTeamRolesApp(TeamRolesMixin): def __init__(self, backend=None): self.pushed_screens = [] + self.notifications = [] self.exit_code = None self.shown_main_screen = False self.log_messages = [] @@ -49,6 +50,9 @@ def push_screen(self, screen, callback=None): def exit(self, code): self.exit_code = code + def notify(self, message, **kwargs): + self.notifications.append((message, kwargs.get("severity"))) + def status_callback(self, *args, **kwargs): pass @@ -76,6 +80,22 @@ def test_handle_roles_table_row_selection_non_team(self): res = app.handle_roles_table_row_selection(mock_event) assert res == 201 assert len(app.pushed_screens) == 0 + # The user is told why nothing opened + assert len(app.notifications) == 1 + message, severity = app.notifications[0] + assert "DataSteward" in message and "not a team role" in message + assert severity == "warning" + + def test_handle_roles_table_row_selection_failed_lookup_opens_no_team_screen(self, backend): + # Always faked: an 'empty' result. Only the StatusScreen explaining it is shown, not MyTeam too. + app = DummyTeamRolesApp(backend) + backend.always_fake("team_roles_handler.exec_report_spec", returns={"kind": "empty"}) + mock_event = MagicMock() + mock_event.data_table.get_row.return_value = ["TeamLeader::Department::101", "PersonRole", "Desc", "g1"] + + app.handle_roles_table_row_selection(mock_event) + + assert [type(screen) for screen, _ in app.pushed_screens] == [StatusScreen] @pytest.mark.live_capable def test_handle_roles_table_row_selection_team_leader( @@ -138,8 +158,12 @@ def test_find_team_members_pyegeria_exception(self, backend): backend.always_fake("team_roles_handler.exec_report_spec", side_effect=PyegeriaException("API Error")) members, dname, qname, cat, desc = app.find_team_members("Department::101::TeamLeader") - assert members == [] - assert app.exit_code == 440 + assert members == [] and dname is None + # Reported to the user; the app keeps running + assert app.exit_code is None + assert len(app.notifications) == 1 + message, severity = app.notifications[0] + assert "API Error" in message and severity == "error" def test_find_team_members_empty_kind(self, backend): # Always faked: an 'empty' result is not something a live server can be asked for.