diff --git a/.claude/rules/commit-signing.md b/.claude/rules/commit-signing.md new file mode 100644 index 00000000..7af1cd60 --- /dev/null +++ b/.claude/rules/commit-signing.md @@ -0,0 +1,9 @@ +--- +description: Enforce signed-off commits using -s flag +--- + +# Commit Signing Rule + +- Always use `git commit -s` when committing changes. +- This appends the `Signed-off-by` line required by the repository's DCO (Developer Certificate of Origin). +- Never use `git commit` without the `-s` flag. diff --git a/CLAUDE.md b/CLAUDE.md index 5f2aedc1..0ec1bb38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,6 +185,7 @@ calls, so verb and body shape stay correct by construction. ## Commits - Always use `git commit -s` to sign off commits. This appends `Signed-off-by: Dan Wolfson ` — DCO is enforced on this repo and unsigned commits will be rejected. +- This rule is strictly enforced for all automated and manual commits. - Do **not** add `Co-Authored-By:` lines to commit messages. --- diff --git a/docs/My-Egeria-Doc.md b/docs/My-Egeria-Doc.md index c9d2220f..f90359bd 100644 --- a/docs/My-Egeria-Doc.md +++ b/docs/My-Egeria-Doc.md @@ -1,908 +1,564 @@ -# Egeria User Guide: My Profile Application + + -The **My Profile Application** (`my_profile_app.py`) is an interactive terminal-based user interface (TUI) powered by Egeria. It is designed for business users, data citizens, data analysts, team leaders, and governance professionals to manage personal governance profiles, track daily activities, collaborate on governance artifacts, and discover organizational data assets. +# My Profile Application: Reference Guide ---- - -## Target Audience & User Personas - -This application is tailored for business roles within an organization that uses Egeria as its open metadata and governance platform: +This guide explains how the **My Profile Application** (`my_profile_app.py`) is put together: its screens, the handler mixins behind them, the Egeria calls each flow makes, and the codes screens use to talk to the app. To learn how to *use* the app (configuration, starting it, keys), see the [My Profile App User Manual](my_profile_app_manual.md). -- **Business Analysts & Data Citizens**: Search glossaries, explore business terms, browse digital data catalogs, and subscribe to data assets. -- **Team Leaders & Project Managers**: View team membership, oversee project assignments, manage governance roles, and track team activities. -- **Governance Officers & Stewards**: Add and manage governance to-dos, document insights via blogs and journals, comment on metadata elements, and trigger automated catalog templates. - -> **Note:** Modification screens (`EditElementsScreens.py`) are currently under construction and are not covered in this guide. +All source files named here are in `my_egeria/my_egeria/DemoCode/My_Profile/`. --- -## Getting Started & Profile Initialization - -### Application Startup & Authentication +## Source Layout + +| File | Contents | +| :--- | :--- | +| `my_profile_app.py` | `MyProfileApp`: startup, profile loading, table population, menu dispatch, comments-from-main | +| `MainScreen.py` | The dashboard: tables, Other Functions menu, row-selection tracking | +| `SplashScreen.py` | Welcome card and Change User | +| `CreateProfileScreen.py` | First-time profile creation | +| `elements_crud_handler.py` | `ElementsCrudMixin`: add/edit/delete routing, comments screen routing | +| `AddToElementsScreens.py` | `BaseAddScreen` and every Add screen | +| `EditElementsScreens.py` | `BaseEditScreen`, `ConfirmDeleteScreen`, per-table edit screens, `EditProfileScreen` | +| `AddCommentScreen.py`, `ShowCommentsScreen.py` | Commenting on a row; viewing and replying to comment threads | +| `feedback_handler.py`, `FeedbackScreens.py` | `FeedbackMixin`, `FeedbackScreen`, `FeedbackLogScreen` | +| `shop_for_data_handler.py` | `ShopForDataMixin`: catalog loading, overview trees, sampling, subscriptions | +| `ShopForDataScreen.py`, `SelectionOverviewScreen.py`, `GenericDataViewScreen.py`, `CreateSubscriptionRequestScreen.py`, `SearchForTermScreen.py` | Shop for Data screens | +| `ViewSubscriptionsScreen.py` | Subscriptions menu entry | +| `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 | +| `UserIdentitiesScreen.py` | Read-only user identity table | +| `StatusScreen.py` | Result message with copy-GUID | +| `profile_utils.py` | Environment check and data clean-up helpers | +| `my_profile.tcss` | Layout and theme | +| `RETURN_CODES.md`, `catalog_api_calls.md` | Return-code list and Shop for Data API notes | -When the application launches, it automatically loads configuration settings and establishes a secure connection to the Egeria View Server: - -1. **Configuration & Credentials**: Loads application environment settings and user credentials (`user_name`, `user_password`, `egeria_view_server`, `egeria_platform_url`). -2. **Profile Retrieval**: Calls Egeria via `MyProfile._async_get_my_profile()` using the `My-User-MD` specification. -3. **Karma Points & Reputation**: Calculates and displays the user's active **Karma Points**, recognizing contributions to the metadata ecosystem. -4. **Table Population**: Asynchronously populates the main dashboard tables (Associations, Roles, Teams, Blogs, Journal, To-Dos, User Identity, and Collections). +--- -### First-Time User: Profile Creation +## Application Structure -If no existing profile is found for your account in Egeria (`my_profile_data == []`), the application automatically launches the **Create Profile** modal dialog (`CreateProfileScreen.py`). +```mermaid +classDiagram + class App + class MyProfileApp { + SCREENS + BINDINGS: q, r, ctrl+f, f3 + on_mount() + mainline(splash_return) + _load_profile_and_populate() + _process_profile_data() + _populate_tables() + handle_option_selected() + add_comment() / add_comment_callback() + _load_my_collections() + check_action() + } + class TechTypesMixin + class ShopForDataMixin + class TeamRolesMixin + class ElementsCrudMixin + class FeedbackMixin + class BookmarksMixin { + show_my_bookmarks() + list_my_bookmarks() + add_my_bookmark() / delete_my_bookmark() + } + App <|-- MyProfileApp + TechTypesMixin <|-- MyProfileApp + ShopForDataMixin <|-- MyProfileApp + TeamRolesMixin <|-- MyProfileApp + ElementsCrudMixin <|-- MyProfileApp + FeedbackMixin <|-- MyProfileApp + BookmarksMixin <|-- MyProfileApp +``` -| Field | Description | Example / Format | -| :--- | :--- | :--- | -| `Courtesy Title` | Preferred formal title | `Dr.`, `Ms.`, `Mr.` | -| `Given Names` | First / given names | `Gary` | -| `Family Name` | Surname / last name | `Geeke` | -| `Preferred Name` | Preferred display name | `Gary Geeke` | -| `Pronouns` | Personal pronouns | `he/him`, `they/them` | -| `Job Title` | Professional role in organization | `Lead Data Architect` | -| `Description` | Brief summary of role and responsibilities | `Responsible for enterprise data modeling` | -| `Employee ID` | Organization employee identifier | `EMP-10492` | -| `Preferred Language` | Language for system interactions | `en-US` | -| `Resident Country` | Country of employment / residence | `United Kingdom` | -| `Time Zone` | Working time zone | `Europe/London` | - -Click **Create Profile** or submit the form. Egeria creates your `Person` entity and anchors your user profile. If you cancel or encounter an issue, press `q` to dismiss. +- **App bindings:** + - `q` quits with `200`. + - `r` re-runs the profile load and table population. + - `ctrl+f` (feedback) and `f3` (feedback log) are *priority* bindings. Textual ignores ordinary App bindings while a modal screen is active, and priority bindings get round that. +- **`check_action`** hides and disables `view_feedback_log` unless the current user is the feedback owner (`garygeeke`). +- **Screen registration:** most screens are registered by name in `SCREENS`. `EditCollectionsScreen`, `AddCollectionScreen`, `AddUserIdentityScreen`, `FeedbackScreen`, `FeedbackLogScreen` and `ConfirmDeleteScreen` are pushed as instances instead. +- **Mixins and `@on`:** the mixins are plain Python classes, not Textual message pumps. Textual only collects `@on(...)`-decorated handlers from classes built with its own metaclass, so an `@on` handler defined on a mixin is never dispatched. Put any `@on` handler on `MyProfileApp` itself and have it delegate to the mixin, as `_on_roles_row_selected` does. Handlers found by name (`on_worker_state_changed`, `action_*`) and callbacks passed to `push_screen` work on mixins. +- **Entry point:** `main()` runs the app. It is the target of the `my_profile` console script. --- -### Startup & Profile Architecture Flows - -#### Component & Flow Map +## Startup and Profile Loading ```mermaid flowchart TD - %% App Startup and Profile Loading - subgraph ClientApp ["Textual Application (my_profile_app.py)"] - START(["Application Launch"]) - ONMOUNT["on_mount()
• push_screen('main')"] - LOAD["_load_or_create_profile()
• Create Bearer Token
• _async_get_my_profile('My-User-MD')"] - CHECK{"Profile
Found?"} - POPULATE["_populate_tables()
• Populate 8 Tables
• Compute Karma Points"] - MAIN["Main Dashboard Active"] - end - - subgraph Modals ["Profile Creation Modal"] - CPS["CreateProfileScreen
(CreateProfileScreen.py)
• Input Personal & Contact Details"] - SUBMIT["Submit Profile Action
• action_create_profile()"] - end - - subgraph EgeriaOMVS ["Egeria Pyegeria Client"] - MP["MyProfile Client
(pyegeria.omvs.my_profile)"] - GET_P["get_my_profile(report_spec='My-User-MD')"] - CREATE_P["create_actor_profile(body)"] - end - - START --> ONMOUNT --> LOAD - LOAD --> MP --> GET_P - GET_P -- "Profile data returned" --> CHECK - CHECK -- "Yes (Data Present)" --> POPULATE --> MAIN - CHECK -- "No (Empty List)" --> CPS - CPS --> SUBMIT -- "dismiss(200 / payload)" --> CREATE_P - CREATE_P --> POPULATE + START(["Launch"]) --> CHECK_ENV{"pydantic >= 2.12.3?"} + CHECK_ENV -- "No" --> EXIT430(["exit(430)"]) + CHECK_ENV -- "Yes" --> PUSH_MAIN["push_screen('main')"] + PUSH_MAIN --> TASK["start task: _load_profile_and_populate()"] + PUSH_MAIN --> SPLASH["push_screen('splash', callback=mainline)"] + SPLASH -- "Continue to App: dismiss()" --> MAINLINE{"mainline(result)"} + SPLASH -- "Change User: dismiss([user, pwd])" --> MAINLINE + MAINLINE -- "list (user changed)" --> RELOAD["cancel task, set user,
refresh_bindings(),
_load_or_create_profile()"] + MAINLINE -- "None" --> AWAIT["await task"] + TASK --> GET["MyProfile._async_get_my_profile('My-User-MD', DICT)"] + GET -- "PyegeriaException" --> EXIT402(["exit(402)"]) + GET --> PROC["_process_profile_data()
• get_my_profile('User-Identities')
• get_my_to_dos('My-User-ToDos')
• resolve user GUID (fallback: 'Actor-Profiles')"] + PROC --> POP["_populate_tables()"] + AWAIT --> FOUND{"Profile found?"} + RELOAD --> FOUND + FOUND -- "Yes" --> DASH["Main dashboard"] + FOUND -- "No" --> CPS["CreateProfileScreen
callback=new_profile_return"] + CPS -- "200" --> REREAD["re-read 'My-User-MD', populate"] --> DASH + CPS -- "not 200" --> EXIT403(["exit(403)"]) ``` -#### Sequence & Data Exchange +- **Parallel loading.** The profile load starts as soon as the app mounts, while the splash screen is still showing. If the user switches account, the background task is cancelled and the load is repeated for the new user. +- **Escape on the splash screen** dismisses it, the same as **Continue to App**. It must dismiss rather than `pop_screen`, because `pop_screen` discards the pushed screen's callback, so `mainline` (and the missing-profile check) would never run. +- **Change User** also writes the new user and password into pyegeria's cached `settings.User_Profile`. Screens read the user from there, so every screen opened afterwards runs its Egeria calls as the new user. +- **Change User check.** The splash screen builds an `Egeria` client from the new credentials and closes it again, but it does not request a token. Bad credentials are therefore only found when the profile load fails. +- **What `_process_profile_data` extracts** from the first `My-User-MD` record: + - Contribution Record, which supplies the Karma Points; + - Projects, Teams, Communities and Roles; + - Note Logs, split into blogs and journal by their `class`. -```mermaid -sequenceDiagram - autonumber - actor User - participant App as MyProfileApp
(my_profile_app.py) - participant Modal as CreateProfileScreen
(CreateProfileScreen.py) - participant MP as MyProfile
(pyegeria.omvs) - - User->>App: Launch Application - App->>App: load_app_config() (Settings & Credentials) - App->>App: push_screen("main") - App->>MP: create_egeria_bearer_token(user, password) - App->>MP: _async_get_my_profile(report_spec="My-User-MD", output_format="DICT") - - alt Profile Exists - MP-->>App: Return User Profile Dict & Contribution Records - App->>App: Calculate Karma Points & extract profile entities - App->>App: _populate_tables() (Associations, Roles, Teams, Blogs, etc.) - App-->>User: Display Populated Main Dashboard - else Profile Missing (First-Time User) - MP-->>App: Return empty dataset [] - App->>Modal: push_screen(CreateProfileScreen(), callback=new_profile_return) - Modal-->>User: Display Profile Creation Form - User->>Modal: Enter personal details & submit - Modal->>MP: create_actor_profile(body) / create_person_profile() - MP-->>Modal: Profile Created (200 OK) - Modal->>App: dismiss(200) - App->>App: _populate_tables() - App-->>User: Display Populated Main Dashboard - end -``` +### Create Profile ---- +`CreateProfileScreen` builds a `NewElementRequestBody` from its fields and calls `MyProfile.add_my_profile(body)`: +- the qualified name is made from "Person", the employee ID, the country and the given and family names; +- `initials` is set to `"PAT"` and `employeeType` to `"Full-Time"`. -## Main Dashboard Layout & Workspace Structure +It returns `200` on success and `401` on failure. -The main dashboard (`MainScreen.py`) organizes all user-relevant metadata into clean, scrollable containers: +--- -```mermaid -flowchart TB - subgraph Dashboard ["Main Dashboard (MainScreen.py)"] - HEADER["Header: Egeria - My Profile | User: <user_name> (Karma Points: <points>)"] - - subgraph TopRow ["Top Grid Container"] - ASSOC["User Associations Container
• Community & project ties
• #associations_table"] - COLL["My Collections Container
• Personal asset collections
• #my_collections_table"] - end - - subgraph MidRow ["Middle Grid Container"] - OTHER["Other Functions Menu
• [1] User Identities
• [2] Catalogs/Shop for Data
• [3] Technology Types"] - ROLES["Assigned Roles Container
• Governance & business roles
• #roles_table (Drill-down to Teams)"] - end - - subgraph LowerRow ["Lower Grid Container"] - TEAMS["Teams Container
• Departments & Squads
• #teams_table"] - ACTIVITIES["Activities & Note Logs
• Blogs: #blogs_table
• Journal: #journal_table
• To-Dos: #todos_table"] - end - - subgraph BottomRow ["Identity Container"] - IDENTITY["User Identity Container
• Distinguished names across repositories
• #user_identity_table"] - end - - FOOTER["Footer: Global Keyboard Shortcuts & Status Indicators"] - - HEADER --- TopRow - TopRow --- MidRow - MidRow --- LowerRow - LowerRow --- BottomRow - BottomRow --- FOOTER - end -``` +## Main Screen Data Sources -### Dashboard Data Sections +| Table id | Source | Notes | +| :--- | :--- | :--- | +| `associations_table` | Communities from `My-User-MD` | | +| `my_collections_table` | `Egeria.find_collections("*", JSON)`, keeping those whose `elementHeader.versions.createdBy` is the current user, minus the bookmarks collection (`_load_my_collections`) | | +| `roles_table` | Roles from `My-User-MD` | | +| `teams_table` | Teams from `My-User-MD` | | +| `blogs_table`, `journal_table` | Note Logs from `My-User-MD` | | +| `todos_table` | `MyProfile.get_my_to_dos(report_spec="My-User-ToDos")` | | +| `user_identity_table` | `MyProfile.get_my_profile(report_spec="User-Identities")` | | -- **User Associations**: Displays communities, initiatives, and working groups the user is associated with. -- **My Collections**: Lists personal asset collections and pinned resource groupings. -- **Roles**: Shows formal roles assigned to the user (e.g., `TeamLeader`, `DataSteward`, `BusinessAnalyst`). -- **Teams**: Displays departments, squads, and operational units where the user is a member. -- **Activities (Blogs, Journal, To-Dos)**: - - **Blogs**: Published articles and knowledge-sharing entries. - - **Journal**: Private or internal work log entries with timestamps. - - **To-Dos**: Assigned action items, tasks, and governance workflows with activity statuses. -- **User Identity**: Identity mappings linking user accounts to directory services and repository platforms. -- **Other Functions**: Quick-access navigation menu to specialized workspaces. +| `projects_table` | Projects from `My-User-MD` | | ---- +`communities_table` is not on the main screen (communities show in User Associations); `_populate_tables_sync` skips any table that isn't composed. -## Keyboard Navigation & Global Shortcuts +`MainScreen` tracks the selected table and row through focus, highlight, select and cell events. It uses them for `ctrl+t` (edit table), `ctrl+a` (add comment) and `ctrl+s` (show comments). -The My Profile application provides rapid keyboard-driven navigation: +### Other Functions menu dispatch (`handle_option_selected`) -| Key Binding | Action | Scope | Description | -| :--- | :--- | :--- | :--- | -| `q` | **Quit** | Global | Exits the application or dismisses the current modal window. | -| `r` | **Refresh Data** | Global | Reloads profile data, activities, and tables from Egeria. | -| `ctrl+s` | **Show Comments** | Main Screen | Opens the comment thread for the currently highlighted table row. | -| `ctrl+t` | **Add To-Do** | Main Screen | Opens the quick-creation dialog for a new To-Do item. | -| `ctrl+b` | **Add Blog** | Main Screen | Opens the creation dialog for a new Blog post. | -| `ctrl+j` | **Add Journal** | Main Screen | Opens the creation dialog for a new Journal entry. | -| `ctrl+c` | **Add Association** | Main Screen | Links the profile to a Community or Project. | -| `ctrl+r` | **Add Role** | Main Screen | Creates and assigns a new governance role. | -| `ctrl+g` | **Add Team** | Main Screen | Creates a new Team structure. | -| `ctrl+m` | **Add Collection**| Main Screen | Creates a new asset collection. | -| `ctrl+e` | **Toggle Twisties**| Trees | Expands or collapses all nodes in hierarchical trees. | -| `Escape` / `b` | **Back / Exit** | Modals | Closes the current modal dialog and returns to the previous screen. | +| Option | Action | +| :--- | :--- | +| User Identities | `UserIdentitiesScreen(karma_points, user_identities)` | +| Catalogs/Shop for Data | `handle_shop_for_data_option()` | +| Edit Profile | `EditProfileScreen(karma_points, user_profile, user_GUID)` | +| Subscriptions | `ViewSubscriptionsScreen()` | +| Technology Types | `handle_technology_types_option()` | +| User Bookmarks | `show_my_bookmarks()` | +| Leave Feedback | `action_feedback()` | +| Feedback Log | `action_view_feedback_log()` (owner only) | --- -## Managing User Activities & Adding Elements - -The application includes dedicated quick-entry dialogs (`AddToElementsScreens.py` managed via `elements_crud_handler.py`) to create new items without needing complex scripts: - -### 1. Adding a To-Do Item (`ctrl+t`) -- **Fields**: - - `Name of Todo`: Clear summary of the required task. - - `Description of Todo`: Detailed instructions or requirements. - - `Priority of Todo`: Priority indicator (e.g., `High`, `Medium`, `Low`). - - `Link Todo to your profile?`: Toggle switch (defaults to `True`) to anchor the task to your active profile. -- **Behavior**: New To-Dos are automatically created with status `REQUESTED` and linked directly to your Egeria profile. - -### 2. Adding a Blog Entry (`ctrl+b`) -- **Fields**: - - `Blog Title`: Title for the knowledge post. - - `Qualified Name`: Unique system identifier for the blog entry. - - `Blog Entry Text`: Detailed markdown or plain-text body of the post. - - `Link to Blog?`: Switch to associate the entry with an existing blog channel. - -### 3. Adding a Journal Entry (`ctrl+j`) -- **Fields**: - - `Journal Name`: Title or identifier for the journal log. - - `Journal Entry Text`: Detailed notes or work summary. - - `Link to Profile`: Automatically anchors the entry with the current timestamp. - -### 4. Adding a Community (`AddCommunityScreen`) -- **Fields**: - - `Community Name`: Display name for the community of practice. - - `Community Description`: Mission and purpose of the community. - - `Community Category`: Functional group or business area. - - `Assignment Type`: User membership level (e.g., `Leader`, `Member`, `Contributor`). - - `Community Mission`: Strategic objectives. - -### 5. Adding a Project (`AddProjectScreen`) -- **Fields**: - - `Project Name`: Name of the project or initiative. - - `Project Description`: Goals and deliverables. - - `Project Category`: Type of project (e.g., `Governance`, `Data Migration`, `Analytics`). - - `Project Status`: Initial project state (e.g., `PROPOSED`, `ACTIVE`). - - `Start Date` / `End Date`: Planned execution timeline. - -### 6. Adding a Role (`ctrl+r`) -- **Fields**: - - `Role Name`: Functional title (e.g., `Data Steward - Finance`). - - `Role Type`: Egeria role type specification. - - `Role Description`: Responsibilities and delegation authority. - - `Scope`: Department, business domain, or organizational unit. - - `Appointed Actor`: Target user or profile appointed to the role. +## Element Management (Add, Edit, Delete) ---- +Three routing tables in `elements_crud_handler.py` drive everything, all keyed by the main-screen table id: -### Element Management & CRUD Architecture Flows - -#### Component & Flow Map +| Table id | Edit screen | Add screen | Delete call | +| :--- | :--- | :--- | :--- | +| `associations_table` | `EditAssociationsScreen` | `AddAssociationScreen` | `delete_community` | +| `blogs_table` | `EditBlogsScreen` | `AddBlogEntryScreen` | `delete_metadata_element` | +| `communities_table` | `EditCommunitiesScreen` | `AddCommunityScreen` | `delete_community` | +| `journal_table` | `EditJournalScreen` | `AddJournalEntryScreen` | `delete_metadata_element` | +| `my_collections_table` | `EditCollectionsScreen` | `AddCollectionScreen` | `delete_collection` | +| `projects_table` | `EditProjectsScreen` | `AddProjectScreen` | `delete_project` | +| `roles_table` | `EditRolesScreen` | `AddRoleScreen` | `delete_actor_role` | +| `teams_table` | `EditTeamsScreen` | `AddTeamScreen` | `delete_actor_profile` | +| `todos_table` | `EditTodosScreen` | `AddTodoScreen` | `delete_metadata_element` | +| `user_identity_table` | `EditIdentitiesScreen` | `AddUserIdentityScreen` | `delete_user_identity` | + +These are `EDIT_TABLE_ROUTES`, `ADD_TABLE_ROUTES` and `DELETE_METHODS`. Blogs, journal entries and to-dos have no bespoke SDK delete, so they use the generic `delete_metadata_element`. It takes only a GUID, so it cannot get the element's type wrong. + +### Edit screens (`BaseEditScreen`) + +- **On mount:** each subclass names its `SOURCE_TABLE`, and the screen copies that table's columns and rows from the main screen. +- **`a` (add row):** calls `app.add_to_tables(SOURCE_TABLE, row)`, which pushes the Add screen from `ADD_TABLE_ROUTES`. +- **`d` (delete row):** takes the GUID from the row's last column and pushes `ConfirmDeleteScreen`. If the user confirms, it calls `app.delete_element(SOURCE_TABLE, guid)`, which looks up `DELETE_METHODS` and calls Egeria. The row is removed only if that returns `True`. +- **`Escape`:** dismisses with `[(row_key, row_values), ...]`. The table's edit callback then writes those rows back to the main table through `_write_back_rows()`. + +### Add screens + +The newer Add screens subclass **`BaseAddScreen`**. A subclass declares its form and implements only the Egeria calls: + +```python +class AddCollectionScreen(BaseAddScreen): + SCREEN_ID = "add_collection_screen" + ELEMENT_NAME = "Collection" + FIELDS = [("collection_name", "Name of Collection", True), ...] # (input id, label, required) + LINK_LABEL = None # set to show a "link to my profile" switch + + def create_element(self, client, values) -> str: ... # returns the new GUID + def link_element(self, client, guid) -> None: ... # only needed if LINK_LABEL is set +``` -```mermaid -flowchart TD - subgraph Trigger ["User Action on MainScreen"] - KEY["Shortcut Keys:
• ctrl+t (Todo)
• ctrl+b (Blog)
• ctrl+j (Journal)
• ctrl+c (Association)
• ctrl+r (Role)
• ctrl+g (Team)
• ctrl+m (Collection)"] - end +`BaseAddScreen` provides: +- connection settings; +- the form layout; +- required-field validation, which names the missing fields; +- the `Egeria` client lifecycle, including bearer token, session close and error notices; +- the optional link to the user's profile, skipped with a warning if the profile GUID is unknown; +- clearing the form after a successful add (a failed add keeps the input so it can be corrected); +- the Add, Quit and `ctrl+a` controls. - subgraph Handler ["Elements CRUD Handler (elements_crud_handler.py)"] - ADD_ROUTER["add_to_tables(selected_table)
• Matches ADD_TABLE_ROUTES"] - CALLBACKS["Add Callbacks:
• add_todo_callback()
• add_blog_entry_callback()
• add_journal_entry_callback()
• add_role_callback()
• add_team_callback()"] - DEL["delete_element(source_table, guid)
• Matches DELETE_METHODS"] - end +The screen stays open for repeated adds and dismisses with `200`. - subgraph Screens ["Add / Edit Dialogs (AddToElementsScreens.py)"] - MODALS["Modal Screen Instances:
• AddTodoScreen
• AddBlogEntryScreen
• AddJournalEntryScreen
• AddRoleScreen
• AddTeamScreen
• AddProjectScreen
• AddCommunityScreen"] - end +| Add screen | Base | Egeria calls | +| :--- | :--- | :--- | +| `AddCollectionScreen` | `BaseAddScreen` | `create_collection(display_name, description, category)` | +| `AddUserIdentityScreen` | `BaseAddScreen` | `create_user_identity(body)` with `UserIdentityProperties`; `link_identity_to_profile(identity_guid, profile_guid)` | +| `AddTodoScreen` | `BaseAddScreen` | `create_my_todo(todo_name, description, priority, activity_status="REQUESTED")` | +| `AddBlogEntryScreen` | `BaseAddScreen` | `blog_my_activity(body)` with `BlogEntryProperties` (attached to the user's blog) | +| `AddJournalEntryScreen` | `BaseAddScreen` | `journal_my_activity(body)` with `JournalEntryProperties` (attached to the user's journal) | +| `AddProjectScreen` | `BaseAddScreen` | `create_project(...)`; link: `add_to_project_team(project_guid, actor_guid=profile GUID)` | +| `AddCommunityScreen` | `BaseAddScreen` | `create_community(body)` with `CommunityProperties` | +| `AddRoleScreen` | `BaseAddScreen` | `create_actor_role(body)` with `PersonRoleProperties`; link: `link_person_role_to_profile(role_guid, profile_guid)` | +| `AddTeamScreen` | `BaseAddScreen` | `create_actor_profile(body)` with `TeamProperties`; join: `create_actor_role` (a `TeamMember` role), `link_person_role_to_profile(role, profile)`, `link_assignment_scope(team, role)` | +| `AddAssociationScreen` | `ModalScreen` | None: a chooser that dismisses with `"project"` or `"community"`. `add_association_callback` then pushes `AddProjectScreen` or `AddCommunityScreen`. | - subgraph EgeriaOMVS ["Egeria OMVS Engine"] - OMVS["Egeria Core & MyProfile Services
• create_actor_role()
• create_community()
• create_project()
• create_collection()
• delete_metadata_element()"] - end +`BaseAddScreen.validate(values)` lets a subclass reject values before any Egeria call. To-Do checks that its priority is a whole number; Project checks its kind and its `YYYY-MM-DD` dates. - KEY --> ADD_ROUTER - ADD_ROUTER -- "push_screen(screen_cls(table, guid))" --> MODALS - MODALS -- "User submits form
dismiss(result_dict)" --> CALLBACKS - CALLBACKS -- "Persist Changes" --> OMVS - OMVS -- "Switch Screen" --> ADD_ROUTER - DEL -- "delete_actor_role() / delete_metadata_element()" --> OMVS -``` +Team membership in Egeria is a `TeamMember` role (a PersonRole subtype) appointed to the person and scoped to the team by an `AssignmentScope` relationship. This is the same model Dr.Egeria's `Link Team Membership` uses. `link_element()` can read the form values through `self.last_values`. Communities have no "link to me" step, because pyegeria has no community-membership call. -#### Sequence & Data Exchange +When any Add screen closes, its `add_*_callback` calls `_after_add()`. That pops back to the main screen, which drops the edit screen's stale rows, and schedules `action_refresh()` so the new element appears. ```mermaid sequenceDiagram autonumber actor User - participant Main as MainScreen
(MainScreen.py) - participant Handler as elements_crud_handler.py
(ElementsCrudMixin) - participant Modal as AddToElementsScreens.py
(e.g., AddTodoScreen) - participant OMVS as Egeria Backend
(pyegeria client) - - User->>Main: Press Shortcut (e.g., 'ctrl+t' for To-Do) - Main->>Handler: add_to_tables("todos_table", selected_row) - Handler->>Handler: Resolve route via ADD_TABLE_ROUTES["todos_table"] - Handler->>Modal: push_screen(AddTodoScreen("todos_table", user_GUID), callback=add_todo_callback) - Modal-->>User: Display Entry Form (Name, Description, Priority, Link Switch) - User->>Modal: Fill form and press Add Button - Modal->>OMVS: Create entity / anchor to user profile - OMVS-->>Modal: Return created element response - Modal->>Handler: dismiss(result_dict) - Handler->>Handler: add_todo_callback(result) - Handler->>Main: switch_screen("main") - Main-->>User: Updated Main Dashboard with New Row + participant Main as MainScreen + participant Crud as ElementsCrudMixin + participant Edit as Edit screen (BaseEditScreen) + participant Add as Add screen + participant Egeria as pyegeria Egeria client + + User->>Main: select table, ctrl+t + Main->>Crud: edit_tables(table) + Crud->>Edit: push_screen(EDIT_TABLE_ROUTES[table]) + alt add a row ('a') + Edit->>Crud: add_to_tables(table, row) + Crud->>Add: push_screen(ADD_TABLE_ROUTES[table](table, user_GUID)) + User->>Add: fill form, Add + Add->>Egeria: create (and optionally link to profile) + Egeria-->>Add: new GUID + User->>Add: Quit + Add->>Crud: dismiss(200) → add_*_callback → _after_add() + Crud->>Main: pop to main, action_refresh() + else delete a row ('d') + Edit->>User: ConfirmDeleteScreen + User->>Edit: Delete + Edit->>Crud: delete_element(table, guid) + Crud->>Egeria: DELETE_METHODS[table](guid) + Egeria-->>Crud: ok / PyegeriaException + Crud-->>Edit: True / False (row removed only on True) + end + User->>Edit: Escape + Edit->>Crud: dismiss(rows) → _write_back_rows(table, rows) + Crud->>Main: main screen ``` ---- +### Edit Profile -## Collaboration & Element Comments +`EditProfileScreen` pre-fills the person properties and calls `Egeria.update_actor_profile(user_GUID, UpdateElementRequestBody{PersonProperties})`. It returns `200` on success and `401` on failure. -Egeria supports collaborative feedback on any metadata element across the dashboard via `ShowCommentsScreen.py`: +Its `ctrl+c`, `ctrl+i`, `ctrl+r` and `ctrl+t` keys return `"community"`, `"identity"`, `"role"` or `"team"`. `edit_profile_callback` then opens the matching edit screen. -```mermaid -flowchart TB - subgraph CommentUI ["Show Comments Screen Layout (ShowCommentsScreen.py)"] - HEADER["Header: Show Comments for Selected Element (Table: roles_table)"] - - subgraph ThreadPane ["Discussion Thread Viewer"] - C1["[Comment 1] Question: Should this role oversee raw feeds? (Author: Peter)"] - C2["[Comment 2] Answer: Yes, raw feeds fall under ingestion. (Author: Gary)"] - end - - subgraph EntryPane ["Add Comment Container"] - INPUT["Comment Input Field: [ Enter comment text... ]"] - SELECT["Comment Type Selector: [ Question | Answer | Suggestion | Requirement ]"] - SUBMIT_BTN["[ Add Comment (ctrl+a) ]"] - end - - FOOTER["Footer: [ctrl+a] Add Comment | [b] Back | [q] Quit"] - - HEADER --- ThreadPane - ThreadPane --- EntryPane - EntryPane --- FOOTER - end -``` +--- -### Viewing Comments -1. Highlight any row in any table (e.g., Roles, Projects, Teams, Collections). -2. Press `ctrl+s`. -3. The system extracts the element's unique `GUID` or `Qualified Name` and retrieves all attached discussion threads using Egeria's `Comment-by-Element` service. - -### Adding a Comment -1. In the comments view, press `ctrl+a` or navigate to the input container. -2. Enter your comment text. -3. Specify a valid **Comment Type**: - - `Question`: Ask for clarification or metadata details. - - `Answer`: Provide a resolution to an open question. - - `Suggestion`: Propose enhancements or governance adjustments. - - `Requirement`: Specify mandatory compliance or data quality conditions. -4. Click **Add**. The comment is immediately attached to the element in Egeria. +## Comments and Threaded Responses ---- +There are two ways to comment. -### Collaboration & Comments Architecture Flows +**1. From the main screen (`ctrl+a`).** +1. `app.add_comment(table, row)` finds the row's GUID column, falling back to a "Qualified Name" column. +2. It pushes `AddCommentScreen`, which dismisses with `[comment, type, guid]`. +3. `add_comment_callback` calls `Egeria.add_comment_to_element(guid, comment, TYPE)`. -#### Component & Flow Map +**2. From the comments screen (`ctrl+s`).** `ShowCommentsScreen` handles both viewing comments and replying to them: ```mermaid flowchart TD - subgraph Main ["Main Dashboard Table Selection"] - SELECT_ROW["User highlights row on table
(Roles, Teams, Collections, etc.)"] - TRIGGER["Press 'ctrl+s' (Show Comments)"] - end + MAIN["MainScreen: select row, ctrl+s"] --> SHOW["show_comments(table, row)"] + SHOW --> SCS["ShowCommentsScreen(table, row,
selected_comment_guid=None)"] + SCS --> LOAD["exec_report_spec('Comment-by-Element',
element_guid = row GUID or comment GUID)"] + LOAD --> LIST["Comment table: Comment, GUID, Qualified Name, Name"] + LIST -- "ctrl+a" --> ADD["inline comment + type inputs →
EgeriaTech.add_comment_to_element()"] + ADD -- "dismiss(200)" --> CB + LIST -- "ctrl+r on a comment" --> RESP["dismiss([250, comment_guid])"] + RESP --> CB["show_comments_callback"] + CB -- "[250, guid]" --> SHOW2["show_comments(selected_comment_guid=guid)"] + SHOW2 --> SCS + CB -- "200 / None" --> MAIN +``` - subgraph Handler ["Elements Handler (elements_crud_handler.py)"] - SHOW["show_comments(table_name, row_k)
• Extracts row GUID / Qualified Name"] - CALLBACK["show_comments_callback(return_c)
• Restores Main Screen on exit"] - end +A response is a comment whose anchor is another comment. Each `ctrl+r` re-opens the screen with the selected comment as its target, so threads can nest to any depth. `q` on the comments screen calls `app.pop_screen`. - subgraph Screen ["Comments Modal Screen (ShowCommentsScreen.py)"] - INIT["on_mount()
• Fetch existing comments from Egeria"] - VIEW["Display Discussion Thread Viewer"] - ADD["action_add_comment()
• Captures text & comment type
• Calls Egeria add_comment()"] - end - - subgraph Egeria ["Egeria OMVS Client"] - FETCH_C["MyProfile / OpenMetadataStore
• get_attached_comments(element_guid)"] - POST_C["create_comment_and_link(body)"] - end +--- - SELECT_ROW --> TRIGGER --> SHOW - SHOW -- "push_screen(ShowCommentsScreen)" --> INIT - INIT --> FETCH_C --> VIEW - VIEW -- "User enters comment + 'ctrl+a'" --> ADD - ADD --> POST_C -- "Comment attached" --> INIT - VIEW -- "Press 'b' / 'q' (dismiss)" --> CALLBACK --> Main -``` +## Feedback Mechanism -#### Sequence & Data Exchange +`FeedbackMixin` lets any user leave feedback from any screen. ```mermaid sequenceDiagram autonumber actor User - participant Main as MainScreen
(MainScreen.py) - participant Handler as elements_crud_handler.py
(ElementsCrudMixin) - participant Comments as ShowCommentsScreen
(ShowCommentsScreen.py) - participant OMVS as Egeria OMVS Engine - - User->>Main: Highlight row and press 'ctrl+s' - Main->>Handler: show_comments(table_name, row_key) - Handler->>Comments: push_screen(ShowCommentsScreen(table_name, row_key), callback=show_comments_callback) - Comments->>OMVS: Retrieve attached comments for element GUID - OMVS-->>Comments: Return comment list [{text, type, author, timestamp}] - Comments-->>User: Render existing comment threads - User->>Comments: Type new comment, select type ('Question'), press 'ctrl+a' - Comments->>OMVS: create_comment_and_link(element_guid, comment_body) - OMVS-->>Comments: Confirmation (Comment Attached) - Comments->>Comments: Refresh comment list in thread viewer - User->>Comments: Press 'b' (Back) - Comments->>Handler: dismiss(None) - Handler->>Main: _show_main_screen() + participant App as MyProfileApp (FeedbackMixin) + participant FS as FeedbackScreen + participant Egeria as Egeria (note logs) + + User->>App: ctrl+f (any screen) or "Leave Feedback" + App->>App: record current screen class and sub-title + App->>FS: push_screen(FeedbackScreen(screen, title)) + User->>FS: category + text, Submit (ctrl+s) + FS-->>App: {screen, screen_title, category, text} + App->>Egeria: _async_get_note_logs_by_name("NoteLog::garygeeke::My-Profile-App-Feedback") + alt log missing + App->>Egeria: _async_create_note_log(...) + end + App->>Egeria: _async_create_note(log_guid, note body) ``` ---- - -## Team & Role Exploration +Each note records: +- `qualifiedName`: `Note::{log_guid}::{user}::{timestamp}` +- `displayName`: `"{category} - {screen}"` +- `description`: the feedback text +- `additionalProperties`: `category`, `screen`, `screenTitle`, `submittedBy` and `submittedAt` -The **My Team** view (`MyTeamScreen.py` & `team_roles_handler.py`) enables managers and team leads to explore their organizational structure directly from the dashboard: +**Viewing the log.** `F3` or "Feedback Log" reads the notes with `_async_get_notes_for_note_log`, sorts them newest first and shows them in `FeedbackLogScreen`. -1. **Role Selection**: In the **Roles** table on the main screen, highlight and select any role marked with `TeamLeader` or `TeamMember`. -2. **Dynamic Member Resolution**: The system queries Egeria for the associated department or team structure. -3. **Team Roster Display**: - - **Header**: Shows Team Display Name, Qualified Name, Category, and Description. - - **Roster Table**: Lists all team members, their individual assigned roles, and their personal `GUID`s. -4. **Navigation**: Press `b` to return to the main dashboard or `q` to dismiss. +**Privacy.** Only the owner (`FEEDBACK_LOG_OWNER = "garygeeke"`) can open the log. The app enforces this, not Egeria. --- -### Team & Role Exploration Architecture Flows - -#### Component & Flow Map - -```mermaid -flowchart TD - subgraph Main ["Main Dashboard Roles Table"] - SELECT_ROLE["User selects row in #roles_table"] - end +## Team and Role Exploration - subgraph Handler ["Team & Roles Handler (team_roles_handler.py)"] - EVAL{"Role contains
'TeamLeader' or
'TeamMember'?"} - FIND["find_team_members(role_name)
• Extracts Department identifier
• Executes TeamMembers report spec"] - PARSE["Parse team properties &
member list (Individual, Role, GUID)"] - CALLBACK["my_team_callback(result)
• Returns to Main Screen"] - end +When a row in the Roles table is selected, `MyProfileApp._on_roles_row_selected` calls `TeamRolesMixin.handle_roles_table_row_selection`: +1. For a role whose name or type contains `TeamLeader` or `TeamMember`, it calls `find_team_members()`. +2. `find_team_members()` takes the text after the first `::` in the role name and runs `exec_report_spec("Team-Members")`. +3. The handler then pushes `MyTeam(members, properties)`. - subgraph Screen ["Team Roster View (MyTeamScreen.py)"] - DISPLAY["Display Team Header Details &
Roster DataTable (#team_members_table)"] - end +`MyTeam` shows the team name, qualified name, category and description, and a Name / Role / GUID table. `q` returns `"200"` and `b` returns `"201"`. - subgraph Egeria ["Egeria OMVS Client"] - SPEC["exec_report_spec('TeamMembers')
or ActorProfile Queries"] - end - - SELECT_ROLE --> EVAL - EVAL -- "No" --> EXIT_NOOP["Return 201 (No-op)"] - EVAL -- "Yes" --> FIND - FIND --> SPEC --> PARSE - PARSE -- "push_screen(MyTeam(team_members, properties))" --> DISPLAY - DISPLAY -- "Press 'b' / 'q' (dismiss)" --> CALLBACK -``` +--- -#### Sequence & Data Exchange +## Shop for Data and Catalog Exploration ```mermaid -sequenceDiagram - autonumber - actor User - participant Main as MainScreen
(MainScreen.py) - participant Handler as team_roles_handler.py
(TeamRolesMixin) - participant TeamScreen as MyTeamScreen
(MyTeamScreen.py) - participant Egeria as Egeria View Server - - User->>Main: Select a TeamLeader / TeamMember role in #roles_table - Main->>Handler: handle_roles_table_row_selection(event) - Handler->>Handler: Validate role type & extract department key - Handler->>Egeria: exec_report_spec("TeamMembers", params={dept_key}) - Egeria-->>Handler: Return team metadata & members list - Handler->>Handler: Assemble team_properties & team_members roster - Handler->>TeamScreen: push_screen(MyTeam(members, properties, leader), callback=my_team_callback) - TeamScreen-->>User: Display Team Header, Description & Member Table - User->>TeamScreen: Inspect members and press 'b' (Back) - TeamScreen->>Handler: dismiss(result) - Handler->>Main: Return focus to MainScreen +flowchart TD + MENU["Other Functions → Catalogs/Shop for Data"] --> HSO["handle_shop_for_data_option()"] + HSO --> SFDS["push ShopForDataScreen
(5 tables, loading spinners)"] + HSO --> W["5 thread workers → exec_report_spec:
• Glossaries
• Digital-Product-Catalog-MyE
• Data-Dictionaries
• BusinessCapabilities
• BasicCollections ('RootCollection')"] + W --> WSC["on_worker_state_changed() fills tables"] + SFDS -- "Enter on row:
[category, qname, ...]" --> CB["shop_for_data_callback"] + SFDS -- "s: [212, ...]" --> CB + SFDS -- "u / ctrl+s: [211, ...]" --> CB + CB -- "category" --> BUILD["build_*_details() → tree"] + BUILD --> SOS["SelectionOverviewScreen"] + CB -- "212" --> SAMPLE["request_to_sample_data_source()"] --> GDV["GenericDataViewScreen"] + CB -- "211" --> SUB["request_to_subscribe_data_source()"] --> CSRS["CreateSubscriptionRequestScreen"] + SFDS -- "t: [201]" --> CB + CB -- "201" --> SFT["SearchForTermScreen"] + SOS -- "s: [211, node, parent]" --> OC["overview_callback"] --> CSRS + GDV -- "s: [211, name, qname]" --> GDVC["generic_data_view_callback"] --> CSRS + CSRS -- "dict" --> CSC["create_subscription_callback → _create_subscription()
• create_digital_subscription(body)
• link_agreement_item(sub, item)
• link_subscriber(profile, sub)"] ``` ---- +### Detail builders -## Data Shopping & Catalog Explorer +| Category | Data used | Tree | +| :--- | :--- | :--- | +| dictionary | `Data-Dictionaries` | Grouped by subject area | +| domain | `BusinessCapabilities` | Containing members / member of | +| catalog | `Digital-Product-Catalog` | Products. For each digital product it also calls `find_tabular_data_sets(page_size=1)` and `get_tabular_data_set(max_row_count=10, output_format="MD")` to get sample data. | +| glossary | Pre-fetched "Folders" | Folders | +| collection | Pre-fetched root collections | First collection only | -The **Catalogs / Shop for Data** workspace (`ShopForDataScreen.py`, `SelectionOverviewScreen.py`, `SearchForTermScreen.py`, `GenericDataViewScreen.py`, and `CreateSubscriptionRequestScreen.py`) is a discovery hub for data assets: +### Selection overview -```mermaid -flowchart TB - subgraph ShopScreenUI ["Shopping for Data Layout (ShopForDataScreen.py)"] - HEADER["Header: Shopping for Data | Select Category or Search Terms"] - - subgraph SplitView ["Catalog Navigation Pane"] - TREE["Data Hierarchy Tree
• Customer 360 Product Family
• Daily Customer Churn Score
• Verified Customer Profiles"] - DETAILS["Metadata & Specifications Pane
• Title & Display Name
• Owner & SLA Details
• Technical Attributes"] - end - - subgraph SamplePane ["Sample Data Preview Pane"] - SAMPLE_TABLE["Data Sample Table: | CustID | Status | Tier | Activity |"] - end - - FOOTER["Footer: [s] Subscribe to Data Source | [d] View Data Sample | [b] Back | [q] Quit"] - - HEADER --- SplitView - SplitView --- SamplePane - SamplePane --- FOOTER - end -``` +`SelectionOverviewScreen` shows details for the selected tree node: -### 1. Selecting a Data Source Category -Selecting **Catalogs/Shop for Data** from the *Other Functions* menu presents 5 data categories: -- **Glossaries**: Authoritative business terminology, definitions, and hierarchies. -- **Digital Product Catalogs**: Packaged data products and product families. -- **Data Dictionaries**: Structural data dictionaries and schema definitions. -- **Business Domains**: Enterprise business capabilities and domain mappings. -- **Root Collections**: Asset collections and curated resource groups. - -### 2. Interactive Selection & Hierarchical Tree -Selecting any item opens the **Selection Overview Screen**: -- **Navigation Tree**: Browse categories, nested products, and sub-assets. Press `ctrl+e` to expand or collapse all branches. -- **Metadata Details**: Displays rich Markdown-formatted descriptions, ownership metadata, and technical attributes. -- **Data Samples**: Displays sample records for digital products to verify fitness for use before requesting access. - -### 3. Searching for Glossary Terms (`SearchForTermScreen`) -- Access keyword-based term discovery. -- Enter search terms to find matching glossary definitions across the entire catalog. -- Displays comprehensive term definitions, status, examples, and relationships. +| Node type | Report spec | Output format | +| :--- | :--- | :--- | +| glossary | `Glossary-Terms` | MD | +| catalog | `Digital-Products-MyE` | MD, plus sample data | +| dictionary | `Data-Dictionaries` | MD | +| domain | `BusinessCapabilities` | DICT, shown in a text area | +| collection | `Collections` | MD | ---- +`q` returns `210`, `b` returns `200` and `s` returns `[211, node, parent]`. -### Catalog Discovery & Exploration Architecture Flows +### Sampling -#### Component & Flow Map +Only digital products have real sample data. For other rows the sample view shows the element's attributes as Key/Value pairs. `GenericDataViewScreen` can parse several formats: tabular data set reports, `{"data": [...]}`, `dataRecords`/`columnDescriptions`, dicts, lists and strings. -```mermaid -flowchart TD - subgraph Menu ["Main Dashboard Menu"] - SELECT_SHOP["Other Functions -> [2] Catalogs/Shop for Data"] - end +### Subscriptions - subgraph Handler ["Shop For Data Handler (shop_for_data_handler.py)"] - HANDLE_OPT["handle_shop_for_data_option()
• Launches 5 Background Workers"] - WORKERS["Textual Background Workers:
• glossary_group (get_glossary_data)
• product_group (get_digital_product_data)
• dictionary_group (get_data_dictionary_data)
• domain_group (get_business_domain_data)
• root_group (get_root_collection_data)"] - STATE_CHANGE["on_worker_state_changed()
• Populates 5 DataTables asynchronously"] - CALLBACK["shop_for_data_callback(result)
• Dispatches to Overview / Sample / Subscribe"] - end +- **Form result.** `CreateSubscriptionRequestScreen` returns `{externalSourceGUID, guid, GUID, displayName, description, identifier, Status}`. An invalid status becomes `DRAFT`. +- **Creating the subscription.** All three subscribe paths (shop table, overview, sample view) open `CreateSubscriptionRequestScreen`. Its result goes to `create_subscription_callback`, which calls `_create_subscription()`. That: + 1. calls `ProductManager.create_digital_subscription(body)` with `DigitalSubscriptionProperties`; `contentStatus` is the status from the form, and the qualified name includes the user and a timestamp, so names can repeat; + 2. calls `link_agreement_item(subscription, item)` to link the subscription to the chosen item; + 3. calls `link_subscriber(profile, subscription)` to link the user's profile as subscriber. + If a link fails, the subscription is kept and the user gets a warning naming what couldn't be linked. +- **Viewing subscriptions.** `ViewSubscriptionsScreen` calls `Egeria.find_collections(search_string="*", metadata_element_type_name="DigitalSubscription", JSON)`. It lists those the user created: name, status, description and GUID. - subgraph Screens ["Exploration Screens"] - SFDS["ShopForDataScreen
(ShopForDataScreen.py)"] - SEARCH["SearchForTermScreen
(SearchForTermScreen.py)"] - OVERVIEW["SelectionOverviewScreen
(SelectionOverviewScreen.py)"] - DATAVIEW["GenericDataViewScreen
(GenericDataViewScreen.py)"] - end +### Glossary term search - subgraph EgeriaOMVS ["Egeria Pyegeria Client"] - REPORTS["exec_report_spec():
• Glossaries / GlossaryTerms
• DigitalProductCatalog
• DataDictionaries
• BusinessCapabilities
• RootCollections"] - end +`SearchForTermScreen` runs `exec_report_spec("Glossary-Terms", output_format="MD", search_string=term)` and renders the Markdown. `t` on `ShopForDataScreen` returns `[201]`, which opens it. Going back from it (`g`, result `201`) re-runs `handle_shop_for_data_option()`, so the catalog tables are reloaded rather than shown empty. - SELECT_SHOP --> HANDLE_OPT - HANDLE_OPT --> WORKERS - WORKERS --> REPORTS - REPORTS -- "Worker Results" --> STATE_CHANGE - STATE_CHANGE --> SFDS - SFDS -- "Select Category Row" --> CALLBACK - CALLBACK -- "Code 200 (Category Selected)" --> OVERVIEW - CALLBACK -- "Code 201 (Search Terms)" --> SEARCH - CALLBACK -- "Code 212 (Sample Data)" --> DATAVIEW -``` +--- -#### Sequence & Data Exchange +## Technology Types ```mermaid sequenceDiagram autonumber actor User - participant Main as MainScreen
(MainScreen.py) - participant Handler as shop_for_data_handler.py
(ShopForDataMixin) - participant ShopScreen as ShopForDataScreen
(ShopForDataScreen.py) - participant OverviewScreen as SelectionOverviewScreen
(SelectionOverviewScreen.py) - participant Egeria as Egeria OMVS Engine - - User->>Main: Select "Catalogs/Shop for Data" from Other Functions - Main->>Handler: handle_shop_for_data_option() - Handler->>ShopScreen: push_screen(ShopForDataScreen(), callback=shop_for_data_callback) - Handler->>Handler: Launch 5 parallel background worker threads - par Background Workers Loading - Handler->>Egeria: Fetch Glossaries - Handler->>Egeria: Fetch Digital Product Catalogs - Handler->>Egeria: Fetch Data Dictionaries - Handler->>Egeria: Fetch Business Domains - Handler->>Egeria: Fetch Root Collections + participant App as TechTypesMixin + participant TTS as TechnologyTypesScreen + participant TTOS as TechnologyTypeOptionsScreen + participant Form as Templates / Processes screen + participant AC as AutomatedCuration + participant Status as StatusScreen + + User->>App: Other Functions → Technology Types + App->>AC: _async_get_tech_type_hierarchy(filter_string="*") + App->>TTS: push(tree of displayName / subTypes) + User->>TTS: select a type + TTS-->>App: label + App->>AC: get_tech_type_detail(filter_string=label, JSON) + App->>TTOS: push(templates, processes) + User->>TTOS: Select Template / Select Process + TTOS-->>App: ["template" | "process", name] + App->>Form: push(placeholders / request parameters) + User->>Form: fill values, Submit + Form-->>App: ["input", {field: value}, spec] + alt template + App->>AC: create_elem_from_template(TemplateRequestBody) + App->>Status: push(result message) + else process + App->>AC: initiate_gov_action_process(process qualifiedName, request_parameters) + App->>Status: push(process GUID) end - Egeria-->>Handler: Worker results returned asynchronously - Handler->>ShopScreen: on_worker_state_changed() -> populate tables & disable spinners - ShopScreen-->>User: Display category tables with loaded elements - User->>ShopScreen: Select a data product row (Enter) - ShopScreen->>Handler: dismiss([200, row_key, cursor_row, table_id, row_values]) - Handler->>OverviewScreen: push_screen(SelectionOverviewScreen(item_guid, tree_data)) - OverviewScreen-->>User: Display interactive hierarchy tree & metadata details ``` ---- - -### 4. Subscribing to Data Assets (`CreateSubscriptionRequestScreen`) - -Users can subscribe to data sources and products either directly from the **Shop For Data** table, through the **Selection Overview** tree, or after viewing a **Data Sample**. - -#### Subscription Interaction Paths: -1. **Direct Subscribe from Shop For Data Table**: Highlight a row in the Digital Product Catalog (or other data table) on `ShopForDataScreen` and press `u` or `ctrl+s`. -2. **Subscribe from Selection Overview**: While inspecting an item tree in `SelectionOverviewScreen`, press `s`. -3. **Subscribe from Sample Data View**: While viewing a data sample preview in `GenericDataViewScreen`, press `s`. - -#### Subscription Form & Submission: -1. The **Create Subscription Request** modal (`CreateSubscriptionRequestScreen.py`) opens pre-populated with the target item's GUID. -2. Enter a **Display Name**, optional **Description**, and **Identifier**. -3. Select an initial **Status** (`DRAFT`, `PROPOSED`, `ACTIVE`). -4. Submit the form (`c` / Submit). The handler constructs a `NewAgreementRequestBody` containing `DigitalSubscriptionProperties` and calls Egeria's `ProductManager.create_digital_subscription()`. +- **Template validity dates.** Template requests use the fixed dates `effectiveFrom: 2026-01-01` and `effectiveTo: 2030-12-31`. +- **After the status screen** closes, `status_callback` returns to the main screen. +- **Process parameters.** `TechnologyTypeProcessesScreen` records each input's real parameter name (`parameter_names`), so names containing spaces or underscores are sent unchanged. Only non-empty values are sent. A process with no `qualifiedName` can't be started; the user is told why. +- **No technology types.** If the hierarchy comes back empty, the user gets a notice and stays on the main screen. +- **Fetch failure.** If the hierarchy can't be fetched, the app exits with `416`. --- -### Subscription Architecture & Data Flows - -#### Component & Flow Map - -```mermaid -flowchart TD - %% Entry Screens - subgraph Screens ["User Interface Screens (my_profile / Textual)"] - SFDS["ShopForDataScreen
(ShopForDataScreen.py)"] - SOS["SelectionOverviewScreen
(SelectionOverviewScreen.py)"] - GDVS["GenericDataViewScreen
(GenericDataViewScreen.py)"] - CSRS["CreateSubscriptionRequestScreen
(CreateSubscriptionRequestScreen.py)"] - end - - subgraph Handler ["Shop For Data Handler (shop_for_data_handler.py)"] - SFDC["shop_for_data_callback(result)
• Evaluates selection_type"] - RTSDS["request_to_subscribe_data_source(...)
• Extracts row_values/GUID
• Sets self.selected_item = element_guid"] - OC["overview_callback(r_code)
• Handles r_code == 211
• Uses self.selected_item & self.selected_tree"] - DDSC["display_data_sample_callback(result)
• Handles result[0] == 211"] - CSC["create_subscription_callback(result)
• Builds NewAgreementRequestBody
• Calls Egeria ProductManager"] - end - - subgraph EgeriaOMVS ["Egeria Pyegeria Client"] - PM["ProductManager (pyegeria.omvs.product_manager)
create_digital_subscription(body)"] - end +## Bookmarks - %% Flow 1: Direct Subscribe from ShopForDataScreen - SFDS -- "Key: 'u' / 'ctrl+s'
action_subscribe_to_data_source()
dismiss([211, row_key, cursor_row, table_id, row_values])" --> SFDC - SFDC -- "if selection_type == 211
Passes row_values & table_id" --> RTSDS - RTSDS -- "Extracts: element_guid, element_name
push_screen(CreateSubscriptionRequestScreen(guid))" --> CSRS +pyegeria has no favourites API, so `BookmarksMixin` (`bookmarks_handler.py`) keeps a user's bookmarks as the members of a personal collection. Its qualified name is `Collection::::Bookmarks` and its display name is "My Bookmarks". - %% Flow 2: Subscribe from Overview Screen - SOS -- "Key: 's'
action_subscribe()
dismiss([211, node_GUID, tree_selected])" --> OC - OC -- "if r_code == 211
push_screen(CreateSubscriptionRequestScreen(self.selected_item))" --> CSRS +| Function | What it does | +| :--- | :--- | +| `list_my_bookmarks()` | `get_collections_by_name(qualified name)` → `get_collection_members(guid, JSON)` → `(name, type, GUID)` rows; `[]` if the collection doesn't exist yet, `None` if Egeria can't be reached | +| `show_my_bookmarks()` | Pushes `MyBookMarksScreen(rows)` | +| `add_my_bookmark(guid)` | Creates the collection on first use (`create_collection`), then `add_to_collection(collection, guid)` | +| `delete_my_bookmark(guid)` | `remove_from_collection(collection, guid)` | +| `bookmark_table_row(table, row_key)` | Reads the row's GUID column (labelled `GUID` or ending ` GUID`) with `profile_utils.row_identity()`. If there's no GUID, it resolves the row's `Qualified Name` with `get_guid_for_name`. Then it calls `add_my_bookmark`. Used by `ctrl+k` on `MainScreen` and `k` on `ShopForDataScreen`. | - %% Flow 3: Subscribe from Data Sample View Screen - GDVS -- "Key: 's'
action_subscribe()
dismiss([211, name, qualified_name])" --> DDSC - DDSC -- "if result[0] == 211
push_screen(CreateSubscriptionRequestScreen(self.selected_item))" --> CSRS - - %% Modal Submission & Egeria API Call - CSRS -- "Submit Button / Action
dismiss({
'displayName': str,
'description': str,
'identifier': str,
'Status': 'DRAFT',
'guid': str,
'externalSourceGUID': str
})" --> CSC - - CSC -- "POST NewAgreementRequestBody
{
'class': 'NewAgreementRequestBody',
'properties': 'DigitalSubscriptionProperties',
'externalSourceGUID': item_guid,
'externalSourceName': display_name
}" --> PM -``` - -#### Sequence & Data Exchange - -```mermaid -sequenceDiagram - autonumber - actor User - participant Screen as Active Screen
(ShopForData / Overview / DataView) - participant Handler as shop_for_data_handler.py
(ShopForDataMixin) - participant Modal as CreateSubscriptionRequestScreen.py - participant PM as ProductManager
(pyegeria.omvs) - - %% Trigger - User->>Screen: Press Subscribe ('u', 'ctrl+s', or 's') - Screen->>Handler: dismiss([211, ...row / GUID data...]) - - %% Handler processing - alt Direct from ShopForDataScreen - Handler->>Handler: request_to_subscribe_data_source(row_values) - Note over Handler: Extracts GUID from row column 3 or 2
Validates not a placeholder ('No ...')
Sets self.selected_item = element_guid - else From SelectionOverviewScreen - Handler->>Handler: overview_callback(r_code=211) - Note over Handler: Uses self.selected_item from tree selection - else From GenericDataViewScreen - Handler->>Handler: display_data_sample_callback(result) - Note over Handler: Uses self.selected_item from previous sample call - end +`MyBookMarksScreen` lists the rows: +- `ctrl+n` adds a bookmark by GUID; +- `d` removes the highlighted bookmark; +- `q` closes the screen. - %% Modal Display - Handler->>Modal: push_screen(CreateSubscriptionRequestScreen(self.selected_item)) - Modal->>User: Display Form (Display Name, Description, Identifier, Status) - User->>Modal: Enters details and presses Submit - Modal->>Handler: dismiss(result: dict) - Note over Modal,Handler: Result Dict: {displayName, description, identifier, Status, guid, externalSourceGUID} - - %% Egeria Request - Handler->>Handler: create_subscription_callback(result) - Note over Handler: Constructs NewAgreementRequestBody
with DigitalSubscriptionProperties - Handler->>PM: create_digital_subscription(body) - PM-->>Handler: Return subscription response - Handler-->>User: app.notify("Created digital subscription...") -``` +After each change it re-reads the list through the app. The lookup matches on the exact qualified name, so another user's "My Bookmarks" collection is never used. `profile_utils.element_summary()` flattens raw JSON elements for both bookmarks and My Collections, including member results wrapped in `relatedElement`. --- -## Technology Types & Governance Automation - -The **Technology Types** suite (`TechnologyTypeScreens.py` & `tech_types_handler.py`) allows business and technical users to explore supported technologies and execute automated governance actions: +## Status Screen -```mermaid -flowchart TB - subgraph TechUI ["Technology Types Workspace Layout"] - HEADER["Header: Technology Type Details: PostgreSQL Database"] - - subgraph OptionsSplit ["Option Selection Containers"] - TEMPLATES["Available Templates Container
• [1] Standard Relational DB Schema
• [2] Audited Secure DB Instance
• Button: [ Select Template ]"] - PROCESSES["Available Processes Container
• [1] Scan Schema & Profile Assets
• [2] Classify Confidential Data
• Button: [ Select Process ]"] - end - - subgraph ParamForm ["Dynamic Parameter Configuration Form"] - PARAMS["Host: [ db.prod.internal.net ] | Port: [ 5432 ] | Database: [ analytics_prod ]"] - SUBMIT_ACTION["Button: [ Submit Action ]"] - end - - FOOTER["Footer: [b] Back | [q] Quit"] - - HEADER --- OptionsSplit - OptionsSplit --- ParamForm - ParamForm --- FOOTER - end -``` +`StatusScreen(message)` shows a read-only message: +- `c` copies the first `'...'`-quoted substring (normally a GUID) to the clipboard with pyegeria's `copy_to_clipboard`; +- `q` and `Enter` return `200`; +- `b` returns `400`. -1. **Technology Types Hierarchy**: Navigate through technologies (e.g., Databases, Data Lakes, Kafka Topics, Cloud Storage). -2. **Templates & Processes Discovery**: - - **Catalog Templates**: Reusable asset templates that preconfigure connectors, classifications, and relationships. - - **Governance Action Processes**: Pre-built governance workflows (such as profiling, lineage extraction, and quality verification). -3. **Dynamic Parameter Forms**: - - Selecting a template or process dynamically builds an input form based on its required request parameters. - - Each parameter shows its **Name**, **Data Type**, **Description**, **Example Value**, and whether it is **Required**. -4. **Execution**: Fill in the parameter values and click **Submit Action** to trigger the automated governance pipeline in Egeria. +The app's `status_callback` then returns to the main screen. --- -### Technology Types & Automation Architecture Flows - -#### Component & Flow Map +## Report Specs and Clients + +| Report spec | Used by | +| :--- | :--- | +| `My-User-MD` | Profile load and reload | +| `User-Identities` | User identity table | +| `My-User-ToDos` | To-dos table | +| `Actor-Profiles` | User GUID fallback | +| `Comment-by-Element` | Comments screen | +| `Team-Members` | Team roster | +| `Glossaries`, `Digital-Product-Catalog-MyE`, `Data-Dictionaries`, `BusinessCapabilities`, `BasicCollections` | Shop for Data tables | +| `Digital-Product-Catalog` | Catalog tree | +| `Digital-Products-MyE`, `Collections`, `Glossary-Terms` | Overview details and term search | + +| 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` | +| `EgeriaTech` | Comments screen | +| `AutomatedCuration` | Technology types | +| `ProductManager` | Digital subscriptions | + +Report specs are run with `exec_report_spec(...)`. -```mermaid -flowchart TD - subgraph Menu ["Main Dashboard Menu"] - SELECT_TT["Other Functions -> [3] Technology Types"] - end +--- - subgraph Handler ["Tech Types Handler (tech_types_handler.py)"] - FETCH_TT["handle_technology_types_option()
• Fetches all technology types"] - CALLBACK_TT["tech_type_callback(result)
• AutomatedCuration.get_tech_type_detail()"] - OPT_CALLBACK["tech_type_options_callback(choice)
• Routes to Templates or Processes"] - ACTION_EXEC["execute_tech_type_action()
• Creates catalog template or
triggers governance action process"] - end +## Screen Return Codes - subgraph Screens ["Technology Screens (TechnologyTypeScreens.py)"] - TTS["TechnologyTypesScreen
• Interactive Technology Tree"] - TTOS["TechnologyTypeOptionsScreen
• Choose Templates vs Processes"] - TTTS["TechnologyTypeTemplatesScreen
• Dynamic Template Parameter Form"] - TTPS["TechnologyTypeProcessesScreen
• Dynamic Process Parameter Form"] - STATUS["StatusScreen
• Displays outcome & copy GUID"] - end +Screens tell the app what to do next through their `dismiss(...)` value: - subgraph Egeria ["Egeria AutomatedCuration Service"] - AC["AutomatedCuration Client
(pyegeria.omvs.automated_curation)"] - end +| Code | Meaning | +| :--- | :--- | +| `200` | Done / back | +| `201` | Alternative navigation | +| `210` | Quit to main | +| `211` | Subscribe | +| `212` | Sample data | +| `250` | Show responses to a comment | - SELECT_TT --> FETCH_TT --> TTS - TTS -- "Select Tech Node" --> CALLBACK_TT - CALLBACK_TT --> AC --> TTOS - TTOS -- "Select Templates" --> TTTS - TTOS -- "Select Processes" --> TTPS - TTTS -- "Submit Parameter Form" --> ACTION_EXEC - TTPS -- "Submit Parameter Form" --> ACTION_EXEC - ACTION_EXEC --> AC --> STATUS -``` - -#### Sequence & Data Exchange - -```mermaid -sequenceDiagram - autonumber - actor User - participant Main as MainScreen
(MainScreen.py) - participant Handler as tech_types_handler.py
(TechTypesMixin) - participant TTS as TechnologyTypesScreen - participant TTOS as TechnologyTypeOptionsScreen - participant Form as Parameter Form Screen
(Templates / Processes) - participant Status as StatusScreen
(StatusScreen.py) - participant AC as AutomatedCuration
(pyegeria.omvs) - - User->>Main: Select "Technology Types" from Other Functions - Main->>Handler: handle_technology_types_option() - Handler->>TTS: push_screen(TechnologyTypesScreen(tech_types_list)) - TTS-->>User: Display Technology Types Tree (Databases, Queues, Storage) - User->>TTS: Select specific technology (e.g. PostgreSQL) - TTS->>Handler: dismiss(selected_tech_node_guid) - Handler->>AC: get_tech_type_detail(filter_string=node_guid) - AC-->>Handler: Return templates & governance processes metadata - Handler->>TTOS: push_screen(TechnologyTypeOptionsScreen(templates, processes)) - TTOS-->>User: Display option to choose Template or Governance Process - User->>TTOS: Choose "Standard Relational DB Template" - TTOS->>Handler: dismiss("template") - Handler->>Form: push_screen(TechnologyTypeTemplatesScreen(template_spec)) - Form-->>User: Render dynamically generated parameter inputs (Host, Port, DB) - User->>Form: Enter values and press Submit Action - Form->>Handler: dismiss(parameter_values_dict) - Handler->>AC: create_catalog_template_instance(request_body) - AC-->>Handler: Action Completed with Element GUID - Handler->>Status: push_screen(StatusScreen(success_message, guid)) - Status-->>User: Display Confirmation & Copy GUID Option ('c') -``` +Failures use codes from the `4xx` range, and some of them exit the app. The [user manual](my_profile_app_manual.md#exit-and-return-codes) lists the common ones, and `RETURN_CODES.md` has the full list. --- -## User Identities & Account Mappings +## Testing -The **User Identities Screen** (`UserIdentitiesScreen.py`) gives users full visibility into their mapped security and directory credentials across the enterprise: +The suite in `tests/micro-tests/my_profile/` has one module per handler and screen group. -- Accessible via **Other Functions -> User Identities**. -- Displays a structured table containing: - - **Display Name**: Identity moniker. - - **User ID**: System account identifier. - - **Distinguished Name (DN)**: LDAP / Active Directory full path. - - **Category & Type Name**: Identity classification. - - **Metadata Collection Name & ID**: Home repository where the identity originates. - - **GUID**: Unique identifier in Egeria. +**Fake mode (default).** Tests run against in-memory fakes. An autouse fixture blocks real HTTP, so any un-mocked Egeria call fails the test. Mark a test `@pytest.mark.allow_network` to opt out. ---- - -## Status Reporting & Clipboard Integration +**Live mode (`PYEG_LIVE_EGERIA=1`).** +- `egeria_backend.py` swaps the fakes for mocks that wrap the real SDK. Call assertions therefore hold in both modes; only assertions about returned data differ. +- Connection details come from `PYEG_PLATFORM_URL`, `PYEG_SERVER_NAME`, `PYEG_USER_ID` and `PYEG_USER_PWD`. +- Live mode writes to the server: it creates subscriptions and template elements, and may create a profile. -All operations that create or modify metadata communicate outcomes through the **Status Screen** (`StatusScreen.py`): +**Running the whole app in a test.** Tests that run `MyProfileApp.run_test()` stub the profile client with `stub_profile_client()`. -- **Status Message Area**: Displays structured feedback, success confirmation, or backend diagnostic information. -- **Copy GUID to Clipboard (`c` key)**: When an element is created or retrieved, pressing `c` extracts its `GUID` and copies it directly to your system clipboard for use in other tools, CLI commands, or documentation. -- **Completion Actions**: Press `Enter` to continue, `b` to report a bad result, or `q` to dismiss. +**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. --- -## Summary of Business User Workflows +## Known Gaps -The following master workflow diagram provides a complete architectural overview of all navigation paths, operational modes, and subsystem integrations available within the My Profile application: +The user-visible limitations are listed in the [user manual](my_profile_app_manual.md#known-limitations). Other internal gaps: -```mermaid -flowchart TD - %% Master User Workflow - LAUNCH(["Launch My Profile Application"]) - - subgraph InitPhase ["Application Initialization & Authentication"] - CONFIG["Load Config & Connect View Server"] - CHECK_PROF{"User Profile
Found in Egeria?"} - CREATE_MODAL["CreateProfileScreen
• Enter Personal & Contact Info"] - POP_TABLES["Populate Dashboard & Compute Karma Points"] - end +- `get_data_product_catalog_table`, `get_guid_for_qualified_name`, `display_glossary_term_details`, `tech_type_processes_details`, `unpack_egeria_data` and `display_selected_data_specification` are defined but never called. +- Bookmarks, My Collections, team joining, subscription links and starting processes have only been tested against fakes; they haven't yet been run against a live Egeria server. - subgraph CoreHub ["Main Dashboard Command Center (MainScreen.py)"] - MAIN_SCREEN["Main Dashboard View
• Associations, Collections, Roles, Teams
• Activities: Blogs, Journal, To-Dos
• User Identities & Karma Points"] - end - - subgraph Subsystems ["Functional Workspaces & Handlers"] - CRUD["Activities & Elements Management
(elements_crud_handler.py)
• Add/Edit To-Dos (ctrl+t)
• Add/Edit Blogs (ctrl+b)
• Add/Edit Journal (ctrl+j)
• Add Roles & Communities"] - COMMENTS["Collaboration Threads
(ShowCommentsScreen.py)
• Highlight Row + ctrl+s
• Add Question/Answer (ctrl+a)"] - TEAM_VIEW["Team & Organization Explorer
(team_roles_handler.py)
• Select TeamLeader / TeamMember
• View Roster & Member GUIDs"] - SHOP["Catalogs / Shop for Data
(shop_for_data_handler.py)
• Browse 5 Category Catalogs
• Search Terms & Sample Data
• Subscribe to Data Source (ctrl+s/u)"] - TECH["Technology Types & Automation
(tech_types_handler.py)
• Browse Tech Hierarchy
• Trigger Catalog Templates
• Run Governance Action Processes"] - IDENTITIES["User Identities Viewer
• Directory Mappings & DNs"] - end - - subgraph Outcome ["Outcome & Status Integration"] - STATUS_SCREEN["Status Screen (StatusScreen.py)
• Operational Feedback & Error Diagnostics
• Copy Result GUID to Clipboard ('c')"] - end - - LAUNCH --> CONFIG --> CHECK_PROF - CHECK_PROF -- "No" --> CREATE_MODAL --> POP_TABLES - CHECK_PROF -- "Yes" --> POP_TABLES - POP_TABLES --> MAIN_SCREEN - - MAIN_SCREEN -- "Shortcuts (ctrl+t, ctrl+b, ctrl+j...)" --> CRUD - MAIN_SCREEN -- "Shortcut (ctrl+s on row)" --> COMMENTS - MAIN_SCREEN -- "Select Role Row" --> TEAM_VIEW - MAIN_SCREEN -- "Other Functions -> [2] Shop for Data" --> SHOP - MAIN_SCREEN -- "Other Functions -> [3] Tech Types" --> TECH - MAIN_SCREEN -- "Other Functions -> [1] User Identities" --> IDENTITIES - - CRUD --> STATUS_SCREEN - COMMENTS --> MAIN_SCREEN - TEAM_VIEW --> MAIN_SCREEN - SHOP --> STATUS_SCREEN - TECH --> STATUS_SCREEN - IDENTITIES --> MAIN_SCREEN - STATUS_SCREEN --> MAIN_SCREEN -``` - -The **My Profile Application** provides a unified, keyboard-friendly command center for all your daily Egeria data stewardship and governance responsibilities. Use it to keep your activities current, discover organizational data, collaborate with team members, and drive automated governance across your enterprise. +--- +License: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), +Copyright Contributors to the ODPi Egeria project. diff --git a/docs/README.md b/docs/README.md index 69a3ec9e..34b57147 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,8 +11,8 @@ contributors who need more depth than the top-level `README.md`/`CLAUDE.md`/ |---|---| | `developer_guide_pyegeria_textual.md` | Developer Guide for Python programmers new to Egeria and Textual — step-by-step examples (`examples/developer_guide/`), then My Profile, Dr.Egeria and hey_egeria as case studies. Screenshots in `images/developer-guide/`. | | `dr_egeria_manual.md` | The Dr.Egeria user manual — command reference, markdown authoring conventions, attribute styles. | -| `my_profile_app_manual.md` | The My Profile App user manual — configuration, dashboard overview, keyboard shortcuts. | -| `My-Egeria-Doc.md` | In-depth architecture, workflow flows, and sequence diagrams for My Profile / MyEgeria. | +| `my_profile_app_manual.md` | The My Profile App user manual — configuration, starting the app, change user, every screen and key, comments, feedback, return codes, known limitations, running the tests. | +| `My-Egeria-Doc.md` | My Profile App reference guide — source layout, mixin structure, routing tables, Egeria calls and report specs per flow, flow and sequence diagrams. | | `output-formats-and-report-specs.md` | How `generate_output()`/report specs work: `FormatSet`/`Format`/`Column`/`ActionParameter` models, analytic functions, chart output formats. | | `reference-data-and-valid-metadata-mechanisms.md` | Reference data (`ReferenceDataManager`) vs. valid metadata values — what each mechanism is for and when to use which. | | `parameter_cleanup_plan.md` | Working notes from an in-progress parameter-naming/consistency audit across OMVS clients. | diff --git a/docs/developer_guide_pyegeria_textual.md b/docs/developer_guide_pyegeria_textual.md index 3b6b486e..76f991f0 100644 --- a/docs/developer_guide_pyegeria_textual.md +++ b/docs/developer_guide_pyegeria_textual.md @@ -526,11 +526,11 @@ All files are in | File(s) | Role | |---|---| -| `my_profile_app.py` | The `App` subclass, `MyProfileApp`: startup, loading the profile, filling the main tables, and the shared actions (comments, bookmarks). | +| `my_profile_app.py` | The `App` subclass, `MyProfileApp`: startup, loading the profile, filling the main tables, and shared actions such as comments. | | `MainScreen.py` | The main dashboard screen: its layout, and keeping track of which table and row are selected. | | `*Screen.py`, `*Screens.py` | One class per screen or dialog: `SplashScreen`, `ShopForDataScreen`, `MyBookMarksScreen`, `AddCommentScreen`, the `Edit*`/`Add*` screens, and so on. | -| `*_handler.py` | **Mixins**, each holding one feature area's logic: `shop_for_data_handler.py`, `tech_types_handler.py`, `team_roles_handler.py`, `elements_crud_handler.py`, `feedback_handler.py`. | -| `profile_utils.py` | Plain functions with no UI code: data clean-up and the environment check. | +| `*_handler.py` | **Mixins**, each holding one feature area's logic: `shop_for_data_handler.py`, `tech_types_handler.py`, `team_roles_handler.py`, `elements_crud_handler.py`, `feedback_handler.py`, `bookmarks_handler.py`. | +| `profile_utils.py` | Plain functions with no UI code: data clean-up, flattening Egeria elements (`element_summary`), reading a row's GUID and qualified name (`row_identity`), and the environment check. | | `my_profile.tcss` | All the Textual CSS for the app. | | `RETURN_CODES.md` | The catalogue of numeric codes that screens pass back through `dismiss()`. | @@ -539,7 +539,8 @@ Two design choices keep `my_profile_app.py` manageable. **Feature logic lives in mixins.** The app class is assembled from them: ```python -class MyProfileApp(App, TechTypesMixin, ShopForDataMixin, TeamRolesMixin, ElementsCrudMixin, FeedbackMixin): +class MyProfileApp(App, TechTypesMixin, ShopForDataMixin, TeamRolesMixin, ElementsCrudMixin, FeedbackMixin, + BookmarksMixin): ``` Each mixin adds the methods for one feature. For example, `ShopForDataMixin` adds @@ -654,8 +655,8 @@ 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) and bookmark (Ctrl+B). Two problems need -solving. +(Ctrl+A), show comments (Ctrl+S), 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 highlights, selects or focuses one, by handling `DataTable.RowHighlighted`, @@ -680,48 +681,47 @@ recorded selection: ``` **What is that row's GUID?** The tables don't all use the GUID as the row key, so the app -finds it by column heading instead. Any heading that ends in "GUID" counts, which also covers -"Collection GUID". Some tables, such as Shop for Data's glossary table (built from the -`Glossaries` report spec, which has no GUID column), have no GUID column at all. For those, the -app looks the GUID up from the row's qualified name with `get_element_guid_by_unique_name`: +finds it by column heading instead. `profile_utils.row_identity(table, row_key)` returns the +row's GUID (from a column headed "GUID", or ending " GUID" such as "Collection GUID") and its +qualified name. Some tables, such as Shop for Data's glossary table (built from the +`Glossaries` report spec, which has no GUID column), only have the qualified name. For those, +`BookmarksMixin.get_row_guid` asks Egeria for the GUID: ```python - def get_row_guid(self, table: DataTable | None, row_key) -> str | None: + def get_row_guid(self, table: Any, row_key: Any) -> str | None: if table is None or row_key is None: return None + guid, qualified_name = row_identity(table, row_key) + if guid or not qualified_name: + return guid or None + client = None try: - columns = {column.label.plain: key for key, column in table.columns.items()} - for heading, key in columns.items(): - if heading.endswith("GUID"): - guid = str(table.get_cell(row_key, key) or "").strip() - return guid or None - if "Qualified Name" not in columns: - return None - qualified_name = str(table.get_cell(row_key, columns["Qualified Name"]) or "").strip() - except Exception as e: - ... - return None + client = self._bookmarks_client() + resolved = client.get_guid_for_name(qualified_name) + # "No elements found" is returned, rather than an exception, when nothing matches + return resolved if isinstance(resolved, str) and resolved and "No " not in resolved else None ... - eclient = Egeria(self.view_server, self.platform_url, self.user_name, self.user_password) - eclient.create_egeria_bearer_token(self.user_name, self.user_password) - guid = eclient.get_element_guid_by_unique_name(qualified_name) ``` Because `get_row_guid` takes the table itself, not a table name on one particular screen, any -screen can use it. Both `MainScreen.action_bookmarks` and `ShopForDataScreen.action_bookmarks` -reduce to: find the table and row, then -`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 Step 3 did: +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 + `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 + Step 3 did: ![Bookmark screen, opened with Ctrl+B from the Shop for Data glossary table, with the GUID filled in](images/developer-guide/my_profile_bookmark_prefilled.svg) -**The lesson:** put "how do I identify the selected element" in one app-level helper, and make +**The lesson:** put "how do I identify the selected element" in one shared helper, and make each screen's key binding a few lines that call it. **Where the bookmarks are kept.** Egeria has no special "bookmark" type, so My Profile uses ordinary building blocks. Each user has a private collection with the qualified name `Bookmarks::`, and each bookmark is a member of that collection. The collection is -created the first time the user adds a bookmark. A single `create_collection` call also +created the first time the user adds a bookmark, by `BookmarksMixin` in `bookmarks_handler.py`. +A single `create_collection` call also *anchors* it to the user's profile and links it there with a `ResourceList` relationship: ```python @@ -734,24 +734,24 @@ created the first time the user adds a bookmark. A single `create_collection` ca "parentAtEnd1": True, "parentRelationshipProperties": { "class": "ResourceListProperties", - "resourceUse": self.BOOKMARKS_RESOURCE_USE, + "resourceUse": BOOKMARKS_RESOURCE_USE, "resourceUseDescription": f"Private bookmarks for {self.user_name}", }, "properties": { "class": "CollectionProperties", - "qualifiedName": qualified_name, - "displayName": f"Bookmarks for {self.user_name}", + "qualifiedName": bookmarks_qualified_name(self.user_name), + "displayName": BOOKMARKS_DISPLAY_NAME, ... }, } - guid = eclient.create_collection(body=body) + guid = client.create_collection(body=body) ``` After that, bookmarking is plain collection membership: `add_to_collection(collection_guid, element_guid)`, `get_collection_members(collection_guid)` and `remove_from_collection(collection_guid, element_guid)`. Because the qualified name is predictable, the app finds the collection again with -`get_element_guid_by_unique_name("Bookmarks::")`, and +`get_collections_by_name("Bookmarks::")`, and `get_attached_collections(profile_guid)` finds it from the profile side. The same approach suits any per-user list (reading lists, watched items): a well-known qualified name, anchored to and linked from the profile. diff --git a/docs/images/developer-guide/my_profile_bookmark_prefilled.svg b/docs/images/developer-guide/my_profile_bookmark_prefilled.svg index 6d2ac661..23e8d3c3 100644 --- a/docs/images/developer-guide/my_profile_bookmark_prefilled.svg +++ b/docs/images/developer-guide/my_profile_bookmark_prefilled.svg @@ -19,217 +19,217 @@ font-weight: 700; } - .terminal-2064465179-matrix { + .terminal-1174236164-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2064465179-title { + .terminal-1174236164-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2064465179-r1 { fill: #c5c8c6 } -.terminal-2064465179-r2 { fill: #e0e0e0 } -.terminal-2064465179-r3 { fill: #a0a3a6 } -.terminal-2064465179-r4 { fill: #c5c6c7 } -.terminal-2064465179-r5 { fill: #141414 } -.terminal-2064465179-r6 { fill: #ffffff } -.terminal-2064465179-r7 { fill: #646464 } -.terminal-2064465179-r8 { fill: #121212 } -.terminal-2064465179-r9 { fill: #7fff00 } -.terminal-2064465179-r10 { fill: #ffffff;font-weight: bold } -.terminal-2064465179-r11 { fill: #707070;font-weight: bold } -.terminal-2064465179-r12 { fill: #000000;font-weight: bold } -.terminal-2064465179-r13 { fill: #0a0a0a;font-weight: bold } -.terminal-2064465179-r14 { fill: #0a0a0a } -.terminal-2064465179-r15 { fill: #ffa62b;font-weight: bold } + .terminal-1174236164-r1 { fill: #c5c8c6 } +.terminal-1174236164-r2 { fill: #e0e0e0 } +.terminal-1174236164-r3 { fill: #a0a3a6 } +.terminal-1174236164-r4 { fill: #c5c6c7 } +.terminal-1174236164-r5 { fill: #141414 } +.terminal-1174236164-r6 { fill: #ffffff } +.terminal-1174236164-r7 { fill: #646464 } +.terminal-1174236164-r8 { fill: #121212 } +.terminal-1174236164-r9 { fill: #7fff00 } +.terminal-1174236164-r10 { fill: #707070;font-weight: bold } +.terminal-1174236164-r11 { fill: #ffffff;font-weight: bold } +.terminal-1174236164-r12 { fill: #0a0a0a;font-weight: bold } +.terminal-1174236164-r13 { fill: #000000;font-weight: bold } +.terminal-1174236164-r14 { fill: #0a0a0a } +.terminal-1174236164-r15 { fill: #ffa62b;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MyProfileApp + MyProfileApp - - - - ⭘Egeria — My Bookmarks09:52:40 -Existing  Bookmarks, (if any)▎┌───────────────────────────────────────────────┐Digital Product Catalog│ - Name                    Type       GUID ▎│077ce748-8d32-40eb-9d0a-d152724748cf│ Digital Product Catalog Name           Descript│ - No Bookmarks found for  garygeeke ▎└───────────────────────────────────────────────┘ Open Metadata Digital Product Catalog  Extracts│ -▎┌──────────────────┐┌──────────────┐│ -▎│ Add New Bookmark ││ Quit ││ -▎└──────────────────┘└──────────────┘│ -▎│ -▎│ -▎│ -▎│ -▎│ -▎│ -▎│ -▎│ -▎│ -▎│ -▎│ -▎│ -▎▇│ -│ -Root Collection│ - Root Collection Name                           │ - RootCollection::Coco::Egeria Design Library    │ - RootCollection::Coco::IT Systems Inventory     │ - RootCollection::Coco::Strategic Data Hubs      │ - RootCollection::Coco::Subject Areas            │ - RootCollection::Coco::Strategic Solutions      │ - RootCollection::Coco::Governance Folios        │ - RootCollection::Coco::Corporate Regulation Libr│ - RootCollection::Coco Pharmaceuticals Business A│ -│ -│ -│ -│ -│ -│ -│ -│ -│ -▇▇│ - q Quit  ^n Add New Bookmark  ^r Delete Selected Bookmark  + + + + ⭘Egeria — My Bookmarks11:36:08 +Your bookmarks. Ctrl+N adds one by GUID; 'd'▎┌───────────────────────────────────────────────┐Digital Product Catalog│ +removes the highlighted one.▎│077ce748-8d32-40eb-9d0a-d152724748cf│ Digital Product Catalog Name           Descript│ + Name  Type  GUID ▎└───────────────────────────────────────────────┘ Open Metadata Digital Product Catalog  Extracts│ +▎┌──────────────────┐┌──────────────┐│ +▎│ Add New Bookmark ││ Cancel ││ +▎└──────────────────┘└──────────────┘│ +▎│ +▎│ +▎│ +▎│ +▎│ +▎│ +▎│ +▎│ +▎│ +▎│ +▎│ +▎│ +▎▇│ +│ +Root Collection│ + Root Collection Name                           │ + RootCollection::Coco::Egeria Design Library    │ + RootCollection::Coco::IT Systems Inventory     │ + RootCollection::Coco::Strategic Data Hubs      │ + RootCollection::Coco::Subject Areas            │ + RootCollection::Coco::Strategic Solutions      │ + RootCollection::Coco::Governance Folios        │ + RootCollection::Coco::Corporate Regulation Libr│ + RootCollection::Coco Pharmaceuticals Business A│ +│ +│ +│ +│ +│ +│ +│ +│ +│ +▇▇│ + ^n Add New Bookmark  diff --git a/docs/images/developer-guide/my_profile_main.svg b/docs/images/developer-guide/my_profile_main.svg index 530d4616..64492bda 100644 --- a/docs/images/developer-guide/my_profile_main.svg +++ b/docs/images/developer-guide/my_profile_main.svg @@ -19,216 +19,216 @@ font-weight: 700; } - .terminal-4281562742-matrix { + .terminal-4145111206-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4281562742-title { + .terminal-4145111206-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4281562742-r1 { fill: #c5c8c6 } -.terminal-4281562742-r2 { fill: #e0e0e0 } -.terminal-4281562742-r3 { fill: #a0a3a6 } -.terminal-4281562742-r4 { fill: #c5c6c7 } -.terminal-4281562742-r5 { fill: #121212 } -.terminal-4281562742-r6 { fill: #ffffff;font-weight: bold } -.terminal-4281562742-r7 { fill: #e0e0e0;font-weight: bold } -.terminal-4281562742-r8 { fill: #000000;font-weight: bold } -.terminal-4281562742-r9 { fill: #003054;font-weight: bold } -.terminal-4281562742-r10 { fill: #191919 } -.terminal-4281562742-r11 { fill: #ffffff } -.terminal-4281562742-r12 { fill: #000000 } -.terminal-4281562742-r13 { fill: #ffa62b;font-weight: bold } -.terminal-4281562742-r14 { fill: #495259 } + .terminal-4145111206-r1 { fill: #c5c8c6 } +.terminal-4145111206-r2 { fill: #e0e0e0 } +.terminal-4145111206-r3 { fill: #a0a3a6 } +.terminal-4145111206-r4 { fill: #c5c6c7 } +.terminal-4145111206-r5 { fill: #121212 } +.terminal-4145111206-r6 { fill: #ffffff;font-weight: bold } +.terminal-4145111206-r7 { fill: #000000;font-weight: bold } +.terminal-4145111206-r8 { fill: #e0e0e0;font-weight: bold } +.terminal-4145111206-r9 { fill: #191919 } +.terminal-4145111206-r10 { fill: #003054;font-weight: bold } +.terminal-4145111206-r11 { fill: #ffffff } +.terminal-4145111206-r12 { fill: #000000 } +.terminal-4145111206-r13 { fill: #ffa62b;font-weight: bold } +.terminal-4145111206-r14 { fill: #495259 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MyProfileApp + MyProfileApp - - - - Egeria — My Profile -⭘09:51:38 - -User AssociationsMy CollectionsOther Functions - Status or Type  Name                      Descri Collection Name  Collection Description  CollectSelect a function - Contributor     Sustainability Community  Commun▌▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎ -▍▊[] User Identities▎ -▊[] Catalogs/Shop for Data▎ -▊[] Edit Profile▎ -▊[] Subscriptions▎ -▊[] Technology Types▎ -▊[] User Bookmarks▎ -▊[] Leave Feedback▎ -▊[] Feedback Log▎▅▅ -▊▎ - -RolesTeamsBlogs - Role Name                                    Assignment Type  Team Name  Description        Blog Title                             Date     - IT Sustainability Champion                   Leader           Team managing shar - Governance Officer for IT Infrastructure     Leader           Team managing IT r▏ - TeamLeader::Department::3067                 Contributor      Team managing sharJournal - Creating an IT Systems Inventory project man Leader           Team managing IT r Journal Entry  Date  Text  GUID  - Define the security strategy for Coco Pharma - Develop cyber attack response project team   - Add cyber monitoring and response foundationTo-Dos - New York Data Center Leader                 ▇▇ To-Do Name  Activity Status  Description  GUID  - TeamLeader::Department::0013                 - Review user audit project team              ▇▇▇▇ - -User Identity - Display Name  User ID    Distinguished Name  GUID                                  - Gary Geeke    garygeeke  None                d1f4e085-355d-4c0a-9740-6a0873de2de8  - - - - - - - - -▇▇ - q Quit  ^a add comment to selected row  ^b Manage Bookmarks  ^s Show Comments for Selected Row  ^t Edit Selected Table  r Refresh Data  ^▏^p palette + + + + Egeria — My Profile +⭘11:35:05 + +User AssociationsMy CollectionsOther Functions + Status or Type  Name                      Descri Collection Name          Collection DescriptSelect a function + Contributor     Sustainability Community  Commun probe-9004c31c           probe              ▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎ +▍ My Sub 616a2dc0          Sub Desc           ▊[] User Identities▎ + Test Sub b1d51f33        Test Desc          ▄▄▊[] Catalogs/Shop for Data▎ + Sub-Result-123 53cbc92b ▊[] Edit Profile▎ + My Sub 6680a229          Sub Desc           ▊[] Subscriptions▎ + Test Sub e2c5d0c4        Test Desc          ▊[] Technology Types▎ + Sub-Result-123 9c1a38d0 ▊[] User Bookmarks▎ + My Sub 1e23b2ed          Sub Desc           ▊[] Leave Feedback▎ + Test Sub be05771e        Test Desc          ▊[] Feedback Log▎▅▅ + Sub-Result-123 bfb71a95 ▇▇▊▎ + +RolesTeamsBlogs + Role Name                                    Assignment Type  Team Name  Description        Blog Title                             Date     + IT Sustainability Champion                   Leader           Team managing shar + Governance Officer for IT Infrastructure     Leader           Team managing IT r▏ + TeamLeader::Department::3067                 Contributor      Team managing sharJournal + Creating an IT Systems Inventory project man Leader           Team managing IT r Journal Entry  Date  Text  GUID  + Define the security strategy for Coco Pharma + Develop cyber attack response project team   + Add cyber monitoring and response foundationTo-Dos + New York Data Center Leader                 ▇▇ To-Do Name  Activity Status  Description  GUID  + TeamLeader::Department::0013                 + Review user audit project team              ▇▇▇▇ + +User IdentityProjects + Display Name  User ID    Distinguished Name  GUID                                  Status or Type  Name                            + Gary Geeke    garygeeke  None                d1f4e085-355d-4c0a-9740-6a0873de2de8  Completed       Creating an IT Systems Inventor + + + + + + + + +▇▇▇▇ + q Quit  ^a add comment to selected row  ^b Manage Bookmarks  ^k Bookmark Selected Row  ^s Show Comments for Selected Row  ^t Edit Selecte▏^p palette diff --git a/docs/images/developer-guide/my_profile_shop_for_data.svg b/docs/images/developer-guide/my_profile_shop_for_data.svg index 89543ced..f8ac03fc 100644 --- a/docs/images/developer-guide/my_profile_shop_for_data.svg +++ b/docs/images/developer-guide/my_profile_shop_for_data.svg @@ -19,214 +19,214 @@ font-weight: 700; } - .terminal-4176617891-matrix { + .terminal-3262979528-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4176617891-title { + .terminal-3262979528-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4176617891-r1 { fill: #c5c8c6 } -.terminal-4176617891-r2 { fill: #e0e0e0 } -.terminal-4176617891-r3 { fill: #a0a3a6 } -.terminal-4176617891-r4 { fill: #c5c6c7 } -.terminal-4176617891-r5 { fill: #121212 } -.terminal-4176617891-r6 { fill: #0178d4 } -.terminal-4176617891-r7 { fill: #ffffff } -.terminal-4176617891-r8 { fill: #ffffff;font-weight: bold } -.terminal-4176617891-r9 { fill: #000000;font-weight: bold } -.terminal-4176617891-r10 { fill: #000000 } -.terminal-4176617891-r11 { fill: #ffa62b;font-weight: bold } -.terminal-4176617891-r12 { fill: #495259 } + .terminal-3262979528-r1 { fill: #c5c8c6 } +.terminal-3262979528-r2 { fill: #e0e0e0 } +.terminal-3262979528-r3 { fill: #a0a3a6 } +.terminal-3262979528-r4 { fill: #c5c6c7 } +.terminal-3262979528-r5 { fill: #121212 } +.terminal-3262979528-r6 { fill: #0178d4 } +.terminal-3262979528-r7 { fill: #ffffff } +.terminal-3262979528-r8 { fill: #ffffff;font-weight: bold } +.terminal-3262979528-r9 { fill: #000000;font-weight: bold } +.terminal-3262979528-r10 { fill: #000000 } +.terminal-3262979528-r11 { fill: #ffa62b;font-weight: bold } +.terminal-3262979528-r12 { fill: #495259 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MyProfileApp + MyProfileApp - - - - ⭘MyProfileApp — Mr Gary Geeke (garygeeke, Karma Points: 138)09:52:34 -▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎GlossaryDigital Product Catalog -▊▎ Glossary Name                                  Digital Product Catalog Name           Descript -▊▎ Glossary for the Patterns of Information Manag Open Metadata Digital Product Catalog  Extracts -▊▎ Sales Forecasting Glossary                     -▊Please click on a line to select a data ▎ Data Field Naming                              -▊source: ▎ Glossary for the Terrorism (Protection of Prem -▊▎ Employee Glossary                              -▊▎ Data Quality Glossary                          -▊▎ Open Metadata Digital Product Glossary         -▊▎ Coco Pharmaceuticals Clinical Trial Terminolog -▊▎ Sustainability Glossary                        -▊▎ Simple Catalog Glossary                        -▊▎ -▊▎ -▊▎ -▊▎ -▊▎ -▊▎ -▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎▇▇▇▇ - -Data DictionaryBusiness DomainRoot Collection - Data Dictionary Name                           Business Area Name   Type Name           GUID  Root Collection Name                            - Open Metadata Digital Product Data Dictionary  BusinessArea::FIN    BusinessCapability  3983c RootCollection::Coco::Egeria Design Library     - BusinessArea::IT     BusinessCapability  8dc3f RootCollection::Coco::IT Systems Inventory      - BusinessArea::C-TRI  BusinessCapability  8fdd6 RootCollection::Coco::Strategic Data Hubs       - BusinessArea::HR     BusinessCapability  953ab RootCollection::Coco::Subject Areas             - BusinessArea::RES    BusinessCapability  c3ab6 RootCollection::Coco::Strategic Solutions       - BusinessArea::DIST   BusinessCapability  ccdcf RootCollection::Coco::Governance Folios         - BusinessArea::SALES  BusinessCapability  d0956 RootCollection::Coco::Corporate Regulation Libr - BusinessArea::MFG    BusinessCapability  e06d0 RootCollection::Coco Pharmaceuticals Business A - BusinessArea::GOV    BusinessCapability  fda86 - - - - - - - - -▇▇▇▇▇▇ - ^s Subscribe  ^b Bookmark Selected Row  ^f Feedback  f3 Feedback Log ▏^p palette + + + + ⭘MyProfileApp — Mr Gary Geeke (garygeeke, Karma Points: 138)11:36:02 +▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎GlossaryDigital Product Catalog +▊▎ Glossary Name                                  Digital Product Catalog Name           Descript +▊▎ Glossary for the Patterns of Information Manag Open Metadata Digital Product Catalog  Extracts +▊▎ Sales Forecasting Glossary                     +▊Please click on a line to select a data ▎ Data Field Naming                              +▊source: ▎ Glossary for the Terrorism (Protection of Prem +▊▎ Employee Glossary                              +▊▎ Data Quality Glossary                          +▊▎ Open Metadata Digital Product Glossary         +▊▎ Coco Pharmaceuticals Clinical Trial Terminolog +▊▎ Sustainability Glossary                        +▊▎ Simple Catalog Glossary                        +▊▎ +▊▎ +▊▎ +▊▎ +▊▎ +▊▎ +▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎▇▇▇▇ + +Data DictionaryBusiness DomainRoot Collection + Data Dictionary Name                           Business Area Name   Type Name           GUID  Root Collection Name                            + Open Metadata Digital Product Data Dictionary  BusinessArea::FIN    BusinessCapability  3983c RootCollection::Coco::Egeria Design Library     + BusinessArea::IT     BusinessCapability  8dc3f RootCollection::Coco::IT Systems Inventory      + BusinessArea::C-TRI  BusinessCapability  8fdd6 RootCollection::Coco::Strategic Data Hubs       + BusinessArea::HR     BusinessCapability  953ab RootCollection::Coco::Subject Areas             + BusinessArea::RES    BusinessCapability  c3ab6 RootCollection::Coco::Strategic Solutions       + BusinessArea::DIST   BusinessCapability  ccdcf RootCollection::Coco::Governance Folios         + BusinessArea::SALES  BusinessCapability  d0956 RootCollection::Coco::Corporate Regulation Libr + BusinessArea::MFG    BusinessCapability  e06d0 RootCollection::Coco Pharmaceuticals Business A + BusinessArea::GOV    BusinessCapability  fda86 + + + + + + + + +▇▇▇▇▇▇ + ^s Subscribe  ^b Bookmarks  ^f Feedback  f3 Feedback Log ▏^p palette diff --git a/docs/images/developer-guide/my_profile_splash.svg b/docs/images/developer-guide/my_profile_splash.svg index 23e7c7f9..48361d83 100644 --- a/docs/images/developer-guide/my_profile_splash.svg +++ b/docs/images/developer-guide/my_profile_splash.svg @@ -19,219 +19,219 @@ font-weight: 700; } - .terminal-200887873-matrix { + .terminal-2610717306-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-200887873-title { + .terminal-2610717306-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-200887873-r1 { fill: #c5c8c6 } -.terminal-200887873-r2 { fill: #e0e0e0 } -.terminal-200887873-r3 { fill: #c5c6c7 } -.terminal-200887873-r4 { fill: #fea62b } -.terminal-200887873-r5 { fill: #595a5a } -.terminal-200887873-r6 { fill: #7fff00 } -.terminal-200887873-r7 { fill: #646464 } -.terminal-200887873-r8 { fill: #121212 } -.terminal-200887873-r9 { fill: #646464;font-weight: bold } -.terminal-200887873-r10 { fill: #141414 } -.terminal-200887873-r11 { fill: #707070 } -.terminal-200887873-r12 { fill: #0a0a0a } -.terminal-200887873-r13 { fill: #707070;font-weight: bold } -.terminal-200887873-r14 { fill: #ffffff } -.terminal-200887873-r15 { fill: #55c076;font-weight: bold } -.terminal-200887873-r16 { fill: #000000;font-weight: bold } -.terminal-200887873-r17 { fill: #ffa62b;font-weight: bold } + .terminal-2610717306-r1 { fill: #c5c8c6 } +.terminal-2610717306-r2 { fill: #e0e0e0 } +.terminal-2610717306-r3 { fill: #c5c6c7 } +.terminal-2610717306-r4 { fill: #fea62b } +.terminal-2610717306-r5 { fill: #595a5a } +.terminal-2610717306-r6 { fill: #7fff00 } +.terminal-2610717306-r7 { fill: #646464 } +.terminal-2610717306-r8 { fill: #121212 } +.terminal-2610717306-r9 { fill: #646464;font-weight: bold } +.terminal-2610717306-r10 { fill: #141414 } +.terminal-2610717306-r11 { fill: #707070 } +.terminal-2610717306-r12 { fill: #0a0a0a } +.terminal-2610717306-r13 { fill: #707070;font-weight: bold } +.terminal-2610717306-r14 { fill: #ffffff } +.terminal-2610717306-r15 { fill: #55c076;font-weight: bold } +.terminal-2610717306-r16 { fill: #000000;font-weight: bold } +.terminal-2610717306-r17 { fill: #ffa62b;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MyProfileApp + MyProfileApp - - - - ⭘Egeria09:51:28 -█▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀█09:51:28│ -│██│ -│ser Associations██Other Functions│ -│█Welcome to My_Profile for Egeria Users,█Select a function│ -│█None!█▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎│ -│█---------------------------------------█▊[] User Identities▎│ -│█-█▊[] Catalogs/Shop for Data▎│ -│█Initializing secure packages...█▊[] Edit Profile▎│ -│██▊[] Subscriptions▎│ -│█© PDX-License-Identifier: Apache-2.0█▊[] Technology Types▎│ -│█Copyright Contributors to the ODPi█▊[] User Bookmarks▎│ -│█Egeria project.█▊[] Leave Feedback▎│ -│██▊[] Feedback Log▎▅│ -│█This file provides a set of profile█▊▎│ -│█related functions for my_egeria.█│ -│oles██Blogs│ -│██│ -│█┌─────────────────┐┌──────────────┐█│ -│█│ Continue to App ││ Change User │█│ -│█└─────────────────┘└──────────────┘█Journal│ -│██│ -│██│ -│██│ -│█▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄█To-Dos│ -││ -││ -│▇▇▇▇│ -││ -│ser Identity│ -││ -││ -││ -││ -││ -││ -││ -││ -││ -││ -│▇│ - esc Exit  + + + + ⭘Egeria11:34:41 +█▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀█11:34:41│ +│██│ +│ser Associations██Other Functions│ +│█Welcome to My_Profile for Egeria Users,█Select a function│ +│█None!█▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎│ +│█---------------------------------------█▊[] User Identities▎│ +│█-█▊[] Catalogs/Shop for Data▎│ +│█Initializing secure packages...█▊[] Edit Profile▎│ +│██▊[] Subscriptions▎│ +│█© PDX-License-Identifier: Apache-2.0█▊[] Technology Types▎│ +│█Copyright Contributors to the ODPi█▊[] User Bookmarks▎│ +│█Egeria project.█▊[] Leave Feedback▎│ +│██▊[] Feedback Log▎▅│ +│█This file provides a set of profile█▊▎│ +│█related functions for my_egeria.█│ +│oles██Blogs│ +│██│ +│█┌─────────────────┐┌──────────────┐█│ +│█│ Continue to App ││ Change User │█│ +│█└─────────────────┘└──────────────┘█Journal│ +│██│ +│██│ +│██│ +│█▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄█To-Dos│ +││ +││ +│▇▇▇▇│ +││ +│ser IdentityProjects│ +││ +││ +││ +││ +││ +││ +││ +││ +││ +││ +│▇▇▇│ + esc Continue  diff --git a/docs/images/developer-guide/step2_glossary_browser.svg b/docs/images/developer-guide/step2_glossary_browser.svg index 0742af5b..158c0e1a 100644 --- a/docs/images/developer-guide/step2_glossary_browser.svg +++ b/docs/images/developer-guide/step2_glossary_browser.svg @@ -19,212 +19,212 @@ font-weight: 700; } - .terminal-3847477862-matrix { + .terminal-197253732-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3847477862-title { + .terminal-197253732-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3847477862-r1 { fill: #c5c8c6 } -.terminal-3847477862-r2 { fill: #e0e0e0 } -.terminal-3847477862-r3 { fill: #a0a3a6 } -.terminal-3847477862-r4 { fill: #c5c6c7 } -.terminal-3847477862-r5 { fill: #e0e0e0;font-weight: bold } -.terminal-3847477862-r6 { fill: #ddedf9;font-weight: bold } -.terminal-3847477862-r7 { fill: #272727 } -.terminal-3847477862-r8 { fill: #003054 } -.terminal-3847477862-r9 { fill: #ffa62b;font-weight: bold } -.terminal-3847477862-r10 { fill: #495259 } + .terminal-197253732-r1 { fill: #c5c8c6 } +.terminal-197253732-r2 { fill: #e0e0e0 } +.terminal-197253732-r3 { fill: #a0a3a6 } +.terminal-197253732-r4 { fill: #c5c6c7 } +.terminal-197253732-r5 { fill: #e0e0e0;font-weight: bold } +.terminal-197253732-r6 { fill: #ddedf9;font-weight: bold } +.terminal-197253732-r7 { fill: #272727 } +.terminal-197253732-r8 { fill: #003054 } +.terminal-197253732-r9 { fill: #ffa62b;font-weight: bold } +.terminal-197253732-r10 { fill: #495259 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Egeria + Egeria - + - - ⭘Egeria — Glossary Browser09:51:21 - -Glossaries visible to garygeeke - - Display Name                                                       Qualified Name                                              GUID                   - Glossary for the Patterns of Information Management                Glossary::PatternsOfInformationManagement                   83210e2c-ceae-4ef7-9e2 - Sales Forecasting Glossary                                         Glossary::SalesForecasting                                  077ce748-8d32-40eb-9d0 - Data Field Naming                                                  Glossary::DataFieldNaming                                   e1407d22-4a33-4bb7-8eb - Glossary for the Terrorism (Protection of Premises) Act 2025 (UK)  Glossary::MartynsLawUK                                      e4d5b15f-7eac-423a-a39 - Employee Glossary                                                  Glossary::EmployeeGlossary                                  6c8f3996-73d3-490b-84c - Data Quality Glossary                                              Glossary::DataQualityGlossary                               d0fd4f4b-bc37-400f-b90 - Open Metadata Digital Product Glossary                             Glossary::Jacquard::Open Metadata Digital Product Glossary  bd901f7c-9f37-4f7b-a97 - Coco Pharmaceuticals Clinical Trial Terminology                    Glossary::Coco::ClinicalTrialTerminology                    0f2c1abc-2aa2-4db8-9fb - Sustainability Glossary                                            Glossary::Sustainability                                    2f48981a-d752-4b96-b67 - Simple Catalog Glossary                                            Glossary::SimpleCatalogGlossary                             08c8e87f-6818-4753-892 - - - - - - - - - - - - - - - - - - - - - - - - - -▍ - q Quit  r Refresh ▏^p palette + + ⭘Egeria — Glossary Browser11:34:34 + +Glossaries visible to garygeeke + + Display Name                                                       Qualified Name                                              GUID                   + Glossary for the Patterns of Information Management                Glossary::PatternsOfInformationManagement                   83210e2c-ceae-4ef7-9e2 + Sales Forecasting Glossary                                         Glossary::SalesForecasting                                  077ce748-8d32-40eb-9d0 + Data Field Naming                                                  Glossary::DataFieldNaming                                   e1407d22-4a33-4bb7-8eb + Glossary for the Terrorism (Protection of Premises) Act 2025 (UK)  Glossary::MartynsLawUK                                      e4d5b15f-7eac-423a-a39 + Employee Glossary                                                  Glossary::EmployeeGlossary                                  6c8f3996-73d3-490b-84c + Data Quality Glossary                                              Glossary::DataQualityGlossary                               d0fd4f4b-bc37-400f-b90 + Open Metadata Digital Product Glossary                             Glossary::Jacquard::Open Metadata Digital Product Glossary  bd901f7c-9f37-4f7b-a97 + Coco Pharmaceuticals Clinical Trial Terminology                    Glossary::Coco::ClinicalTrialTerminology                    0f2c1abc-2aa2-4db8-9fb + Sustainability Glossary                                            Glossary::Sustainability                                    2f48981a-d752-4b96-b67 + Simple Catalog Glossary                                            Glossary::SimpleCatalogGlossary                             08c8e87f-6818-4753-892 + + + + + + + + + + + + + + + + + + + + + + + + + +▍ + q Quit  r Refresh ▏^p palette diff --git a/docs/images/developer-guide/step3_add_comment.svg b/docs/images/developer-guide/step3_add_comment.svg index bf435d3d..ff8d83ea 100644 --- a/docs/images/developer-guide/step3_add_comment.svg +++ b/docs/images/developer-guide/step3_add_comment.svg @@ -19,224 +19,224 @@ font-weight: 700; } - .terminal-1215773723-matrix { + .terminal-2984066073-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1215773723-title { + .terminal-2984066073-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1215773723-r1 { fill: #e0e0e0 } -.terminal-1215773723-r2 { fill: #646464 } -.terminal-1215773723-r3 { fill: #4a4c4d } -.terminal-1215773723-r4 { fill: #595a5a } -.terminal-1215773723-r5 { fill: #c5c8c6 } -.terminal-1215773723-r6 { fill: #646464;font-weight: bold } -.terminal-1215773723-r7 { fill: #63696e;font-weight: bold } -.terminal-1215773723-r8 { fill: #0178d4 } -.terminal-1215773723-r9 { fill: #e0e0e0;font-weight: bold } -.terminal-1215773723-r10 { fill: #1e1e1e } -.terminal-1215773723-r11 { fill: #191919 } -.terminal-1215773723-r12 { fill: #121212 } -.terminal-1215773723-r13 { fill: #7f7f7f } -.terminal-1215773723-r14 { fill: #6db2ff } -.terminal-1215773723-r15 { fill: #2d2d2d } -.terminal-1215773723-r16 { fill: #ddedf9;font-weight: bold } -.terminal-1215773723-r17 { fill: #004295 } -.terminal-1215773723-r18 { fill: #0d0d0d } -.terminal-1215773723-r19 { fill: #1a1a1a } -.terminal-1215773723-r20 { fill: #0a1e2c } -.terminal-1215773723-r21 { fill: #704d1c;font-weight: bold } -.terminal-1215773723-r22 { fill: #282b2e } + .terminal-2984066073-r1 { fill: #e0e0e0 } +.terminal-2984066073-r2 { fill: #646464 } +.terminal-2984066073-r3 { fill: #4a4c4d } +.terminal-2984066073-r4 { fill: #595a5a } +.terminal-2984066073-r5 { fill: #c5c8c6 } +.terminal-2984066073-r6 { fill: #646464;font-weight: bold } +.terminal-2984066073-r7 { fill: #63696e;font-weight: bold } +.terminal-2984066073-r8 { fill: #0178d4 } +.terminal-2984066073-r9 { fill: #e0e0e0;font-weight: bold } +.terminal-2984066073-r10 { fill: #1e1e1e } +.terminal-2984066073-r11 { fill: #191919 } +.terminal-2984066073-r12 { fill: #121212 } +.terminal-2984066073-r13 { fill: #7f7f7f } +.terminal-2984066073-r14 { fill: #6db2ff } +.terminal-2984066073-r15 { fill: #2d2d2d } +.terminal-2984066073-r16 { fill: #ddedf9;font-weight: bold } +.terminal-2984066073-r17 { fill: #004295 } +.terminal-2984066073-r18 { fill: #0d0d0d } +.terminal-2984066073-r19 { fill: #1a1a1a } +.terminal-2984066073-r20 { fill: #0a1e2c } +.terminal-2984066073-r21 { fill: #704d1c;font-weight: bold } +.terminal-2984066073-r22 { fill: #282b2e } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Egeria + Egeria - + - - ⭘Egeria — Glossary Comments09:51:26 - -Glossaries visible to garygeeke - - Display Name                                                       Qualified Name                                              GUID                   - Glossary for the Patterns of Information Management                Glossary::PatternsOfInformationManagement                   83210e2c-ceae-4ef7-9e2 - Sales Forecasting Glossary                                         Glossary::SalesForecasting                                  077ce748-8d32-40eb-9d0 - Data Field Naming                                                  Glossary::DataFieldNaming                                   e1407d22-4a33-4bb7-8eb - Glossary for the Terrorism (Protection of Premises) Act 2025 (UK)  Glossary::MartynsLawUK                                      e4d5b15f-7eac-423a-a39 - Employee Glossary                                                  Glossary::EmployeeGlossary                                  6c8f3996-73d3-490b-84c - Data Quality Glossary                                              Glossary::DataQualityGlossary                               d0fd4f4b-bc37-400f-b90 - Open Metadata Digital Product█▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀█ossary  bd901f7c-9f37-4f7b-a97 - Coco Pharmaceuticals Clinical██ 0f2c1abc-2aa2-4db8-9fb - Sustainability Glossary      █Add a comment to Data Field Naming█ 2f48981a-d752-4b96-b67 - Simple Catalog Glossary      █▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎█ 08c8e87f-6818-4753-892 -█▊e1407d22-4a33-4bb7-8eb6-41e53d88e937▎█ -█▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎█ -██ -█▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎█ -█▊Is this glossary still maintained?▎█ -█▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎█ -██ -█▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎█ -█▊Standard Comment▼▎█ -█▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎█ -██ -█▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔█ -█ Add Comment  Cancel █ -█▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█ -██ -█▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄█ - - - - - - - - - -▍ - q Quit  r Refresh  ^a Comment on Selected Row ▏^p palette + + ⭘Egeria — Glossary Comments11:34:39 + +Glossaries visible to garygeeke + + Display Name                                                       Qualified Name                                              GUID                   + Glossary for the Patterns of Information Management                Glossary::PatternsOfInformationManagement                   83210e2c-ceae-4ef7-9e2 + Sales Forecasting Glossary                                         Glossary::SalesForecasting                                  077ce748-8d32-40eb-9d0 + Data Field Naming                                                  Glossary::DataFieldNaming                                   e1407d22-4a33-4bb7-8eb + Glossary for the Terrorism (Protection of Premises) Act 2025 (UK)  Glossary::MartynsLawUK                                      e4d5b15f-7eac-423a-a39 + Employee Glossary                                                  Glossary::EmployeeGlossary                                  6c8f3996-73d3-490b-84c + Data Quality Glossary                                              Glossary::DataQualityGlossary                               d0fd4f4b-bc37-400f-b90 + Open Metadata Digital Product█▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀█ossary  bd901f7c-9f37-4f7b-a97 + Coco Pharmaceuticals Clinical██ 0f2c1abc-2aa2-4db8-9fb + Sustainability Glossary      █Add a comment to Data Field Naming█ 2f48981a-d752-4b96-b67 + Simple Catalog Glossary      █▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎█ 08c8e87f-6818-4753-892 +█▊e1407d22-4a33-4bb7-8eb6-41e53d88e937▎█ +█▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎█ +██ +█▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎█ +█▊Is this glossary still maintained?▎█ +█▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎█ +██ +█▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎█ +█▊Standard Comment▼▎█ +█▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎█ +██ +█▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔█ +█ Add Comment  Cancel █ +█▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█ +██ +█▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄█ + + + + + + + + + +▍ + q Quit  r Refresh  ^a Comment on Selected Row ▏^p palette diff --git a/docs/my_profile_app_manual.md b/docs/my_profile_app_manual.md index 8eb90b78..24cd9b41 100644 --- a/docs/my_profile_app_manual.md +++ b/docs/my_profile_app_manual.md @@ -3,53 +3,43 @@ # My Profile App User Manual -The **My Profile App** is a Textual-based terminal user interface (TUI) application designed for Egeria users to manage their personal profiles, view their workspace, and interact with various Egeria metadata services directly from the command line. +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. -## Table of Contents - -1. [Overview](#overview) -2. [Prerequisites](#prerequisites) -3. [Configuration](#configuration) -4. [Getting Started](#getting-started) -5. [Key Features](#key-features) - - [Main Dashboard](#main-dashboard) - - [Profile Management](#profile-management) - - [Communities and Roles](#communities-and-roles) - - [Actions and Work Items](#actions-and-work-items) - - [Catalog and Shopping for Data](#catalog-and-shopping-for-data) - - [Bookmarks](#bookmarks) - - [Technology Type Explorer](#technology-type-explorer) -6. [Keyboard Shortcuts](#keyboard-shortcuts) -7. [Troubleshooting](#troubleshooting) +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). ---- +The app's source is in `my_egeria/my_egeria/DemoCode/My_Profile/`. -## Overview +## Table of Contents -The My Profile App provides a centralized dashboard for an Egeria user. It allows you to: -- View and edit your personal profile information. -- Manage your user identities and community memberships. -- Track assigned, sponsored, and requested actions. -- Browse data catalogs and "shop" for data assets. -- Explore technology types and processes registered in Egeria. +1. [Prerequisites](#prerequisites) +2. [Configuration](#configuration) +3. [Starting the App](#starting-the-app) +4. [The Splash Screen and Changing User](#the-splash-screen-and-changing-user) +5. [Creating Your Profile](#creating-your-profile) +6. [The Main Screen](#the-main-screen) +7. [Adding and Deleting Elements](#adding-and-deleting-elements) +8. [Comments and Threaded Responses](#comments-and-threaded-responses) +9. [Leaving Feedback](#leaving-feedback) +10. [Other Functions Menu](#other-functions-menu) +11. [Keyboard Reference](#keyboard-reference) +12. [Exit and Return Codes](#exit-and-return-codes) +13. [Troubleshooting](#troubleshooting) +14. [Known Limitations](#known-limitations) +15. [Running the Tests](#running-the-tests) -The application is built using the [Textual](https://textual.textualize.io/) framework and utilizes the `pyegeria` SDK to communicate with Egeria View Servers. +--- ## Prerequisites -Before running the My Profile App, ensure you have: -- **Python 3.12+** installed. -- The **pyegeria** package and its dependencies (`textual`, `rich`, `loguru`) installed in your Python environment. -- Access to a running **Egeria Platform** with a configured **View Server** (e.g., `qs-view-server`). -- Valid Egeria user credentials. +- **Python 3.12+**, with this repository's dependencies installed (`uv sync`, then `source .venv/bin/activate`). +- **pydantic 2.12.3 or later.** The app checks this at startup and exits with code `430` if an older version is found. This most often happens when the app is launched with a globally installed `textual` rather than the project's virtual environment. +- A running **Egeria platform** with a **view server** (for example `qs-view-server`), and a user ID and password for it. ## Configuration -The My Profile App uses the standard `pyegeria` configuration mechanism. You can configure the application using environment variables, a `.env` file, or a `config.json` file. - -### Environment Variables +The app uses the standard `pyegeria` configuration. Precedence is: environment variables, then a `.env` file, then `config.json`, then built-in defaults. -Set the following environment variables or add them to your `.env` file: +### Environment variables ```bash EGERIA_PLATFORM_URL=https://localhost:9443 @@ -58,9 +48,9 @@ EGERIA_USER=your_user_id EGERIA_USER_PASSWORD=your_password ``` -### Configuration File (`config.json`) +### `config.json` -Alternatively, you can use a `config.json` file in your project root: +`config.json` is looked for in `$PYEGERIA_CONFIG_DIRECTORY`, then `$PYEGERIA_ROOT_PATH`, then the current working directory. The file name can be changed with `PYEGERIA_CONFIG_FILE`. ```json { @@ -69,95 +59,283 @@ Alternatively, you can use a `config.json` file in your project root: "Egeria View Server": "qs-view-server" }, "User Profile": { - "User Name": "your_user_id", - "User Password": "your_password" + "user_name": "your_user_id", + "user_pwd": "your_password" } } ``` -## Getting Started +If no user or server is configured, the app falls back to user `garygeeke`, password `secret`, view server `qs-view-server` and platform `https://127.0.0.1:9443`. -To launch the application, navigate to the directory containing `my_profile_app.py` and run: +## Starting the App + +From the app folder, inside the project's virtual environment: ```bash +my_profile # installed console script, runs from any directory +# or, from the app folder: +cd my_egeria/my_egeria/DemoCode/My_Profile python my_profile_app.py +# or, with the Textual developer console available: +textual run --dev my_profile_app.py +``` + +To serve the app in a web browser instead of a terminal: + +```bash +serve_my_profile # textual serve on $MY_EGERIA_HOST:$MY_PROFILE_PORT (default 0.0.0.0:8020) ``` -Upon startup, the application will attempt to load your profile from the configured Egeria server. If no profile is found for the current user, the application will guide you through the profile creation process. +## The Splash Screen and Changing User + +When the app starts it shows a welcome card ("Welcome to My_Profile for Egeria Users, *user*!") while your profile loads from Egeria in the background. -## Key Features +- **Continue to App** (or `Escape`) closes the splash screen. If your profile was found, the main screen is already populated; if not, the app offers to create one (see below). +- **Change User** replaces the card with *User Name* and *Password* fields and a **Submit** button. Both fields are required. On submit the app discards the profile it was loading and reloads everything for the new user. Every screen opened afterwards uses the new user. -### Main Dashboard +## Creating Your Profile -The main screen is divided into several sections: -- **Projects**: Lists projects you are involved in. -- **Communities**: Displays the communities you belong to. -- **Roles**: Shows your assigned roles within the organization. -- **Teams**: Lists the teams you are a member of. -- **Actions**: Displays active work items, including requested, in-progress, and waiting actions. -- **User Identity**: Lists your registered identities. -- **Other Functions**: A menu for accessing advanced features like catalog shopping and technology explorers. +If Egeria has no profile for your user, the **Create Profile** screen opens. Fill in: -### Profile Management +| Field | Example | +| :--- | :--- | +| Courtesy Title | `Dr.` | +| Given Names | `Gary` | +| Family Name | `Geeke` | +| Preferred Name | `Gary Geeke` | +| Pronouns | `they/them` | +| Job Title | `Lead Data Architect` | +| Description | `Responsible for enterprise data modelling` | +| Employee ID | `EMP-10492` | +| Preferred Language | `en-US` | +| Resident Country | `United Kingdom` | +| Time Zone | `Europe/London` | -Through the **Edit Profile** option, you can update your personal details, including your display name and description. If you are a new user, the **Create Profile** screen will help you initialize your Egeria presence. +Press **Create Profile**. Egeria creates your `Person` profile, and the app reloads and shows the main screen. If creation fails or is cancelled, the app exits (code `403`). -### Communities and Roles +## The Main Screen -You can view details of your communities and roles directly from the main dashboard. Selecting a community allows you to view or add comments, facilitating collaboration within your metadata workspace. +The header shows your full name, user ID and **Karma Points** (your contribution score in Egeria). The panels are: -### Actions and Work Items +| Panel | Columns | Contents | +| :--- | :--- | :--- | +| **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 | +| **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 | +| **To-Dos** | To-Do Name, Activity Status, Description, GUID | Your to-dos | +| **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 | -The Actions table provides a real-time view of your responsibilities. You can track the status of various governance actions and work items assigned to you or requested by you. +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. -### Catalog and Shopping for Data +Main screen shortcuts: -The **Shop for Data** function allows you to browse registered catalogs, dictionaries, and glossaries. You can explore the metadata hierarchy and find assets relevant to your work. +| 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+k` | **Bookmark** the element in the selected row | +| `r` | Refresh all data from Egeria | +| `ctrl+f` | Leave feedback (works on every screen) | +| `q` | Quit the app | -### Bookmarks +## Adding and Deleting Elements -Bookmarks let you keep a personal list of elements you want to come back to. They are stored in Egeria as members of a private collection, `Bookmarks::`. The collection is attached to your profile through a `ResourceList` relationship with resource use "Bookmarks", and is created automatically the first time you add a bookmark. +Adding and deleting both go through the table's **edit screen**: -- **Open bookmarks** with `Ctrl+B` (or **User Bookmarks** in Other Functions). If a row is highlighted in a table on the main screen or the Shop for Data screen, its GUID is picked up for you. Tables without a GUID column, such as the glossary table, have the GUID looked up from the row's qualified name. -- **Add a bookmark** with `Ctrl+N`. The GUID box is pre-filled with the highlighted row's GUID, and you can edit it or paste another one. Then press **Add New Bookmark**. -- **Delete a bookmark** by highlighting it in the bookmarks table and pressing `Ctrl+R`. The GUID box is pre-filled with that bookmark's GUID. Then press **Delete Bookmark**. This removes the element from your bookmarks collection; the element itself is not deleted. +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. + - `Escape` – back to the main screen. -### Technology Type Explorer +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. -The **Technology Types** explorer provides a deep dive into the technical metadata registered in Egeria. You can browse technology types, templates, and associated processes, helping you understand the technical landscape of your metadata environment. +### Add screens -## Keyboard Shortcuts +Every Add screen works the same way: +- Fill in the fields. Those marked \* are required. +- Press **Add** (or `ctrl+a`). +- Some screens have a switch that links the new element to you. It is on by default. -The application supports the following global keyboard shortcuts: +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. -| Key | Action | -| --- | --- | -| `q` | Quit the application | -| `r` | Refresh data from the Egeria server | -| `Ctrl+B` | Open bookmarks, picking up the highlighted row's GUID (main and Shop for Data screens) | -| `Ctrl+N` / `Ctrl+R` | Add / delete a bookmark (on the bookmarks screen) | -| `Ctrl+C` | Force quit (standard terminal shortcut) | +| Table | Fields | Link switch | Egeria result | +| :--- | :--- | :--- | :--- | +| To-Dos | Name\*, Description\*, Priority (whole number, default 0) | – | A to-do assigned to you, status `REQUESTED` | +| Blogs | Name\*, Text\*, Situation | – | An entry in your blog | +| Journal | Title\*, Text\*, Situation | – | An entry in your journal | +| User Associations | Choose **Project** or **Community**, then that screen opens | | | +| Projects | Name\*, Description\*, Kind (`Project`, `Campaign`, `StudyProject`, `Task`, `PersonalProject`; default `Project`), Identifier, Start Date and Planned End Date (`YYYY-MM-DD`) | Add yourself to the project team | A project | +| Communities | Name\*, Description\* | – | A community | +| Roles | Name\*, Description\* | Appoint yourself to this role | A person role | +| Teams | Name\*, Description\* | Join this team as a member | A team; joining creates a TeamMember role for you, scoped to the team | +| My Collections | Name\*, Description\*, Category | – | A standalone collection | +| User Identity | User ID\*, Display Name, Distinguished Name | Link to your profile | A user identity | + +## Comments and Threaded Responses + +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**. + +**View and reply:** select a row and press `ctrl+s`. 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. + +If an element has no comments yet, the screen tells you to use `ctrl+a`. + +## Leaving Feedback + +Press `ctrl+f` on **any** screen, or choose **Leave Feedback** from the Other Functions menu. Pick a category (Bug, Suggestion, Question, Praise, Other), type your feedback, and press **Submit** (`ctrl+s`). `Escape` cancels. + +The app records which screen you were on, your user ID and the time, and stores the feedback as a note in a shared "My Profile App Feedback" note log in Egeria. + +The **feedback log** (`F3`, or **Feedback Log** in the menu) lists all feedback, newest first. It is only available to the app's feedback owner, `garygeeke`; other users are told the log is private. This restriction is enforced by the app, not by Egeria. + +## Other Functions Menu + +### User Identities +A read-only table of your user identities: display name, category, description, type, URL, GUID, qualified name, metadata collection ID and name, user ID and distinguished name. Press `q` or `Escape` to return. + +### Catalogs/Shop for Data +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` – back; `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. + +**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. + +### 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. + +### Subscriptions +A table of the digital subscriptions you created: name, status, description and GUID. Press `q` to return. + +### Technology Types +Browse the technology type hierarchy as a tree. Selecting a type shows its **catalog templates** and **governance action processes**: + +- **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. + +### 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. + +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. + +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". + +### Leave Feedback / Feedback Log +See [Leaving Feedback](#leaving-feedback). + +## Keyboard Reference + +| Key | Where | Action | +| :--- | :--- | :--- | +| `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 | +| `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` / `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) | + +## Exit and Return Codes + +When the app exits it returns a code; screens also use codes to talk to the app. The common ones are: + +| Code | Meaning | +| :--- | :--- | +| `200` | Success / normal exit | +| `201` | Alternative navigation or no match | +| `210` | Quit back to the main screen | +| `211` | Subscribe request | +| `212` | Sample data request | +| `250` | Show responses to a comment | +| `400` / `401` | Generic failure / profile action failed | +| `402` | Profile could not be loaded at startup | +| `403` | Profile creation cancelled or failed | +| `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 | + +The full list is in `RETURN_CODES.md` in the app folder. ## Troubleshooting -### Connection Issues -If the application fails to connect to Egeria, verify that: -- The `EGERIA_PLATFORM_URL` is correct and the platform is reachable. -- The `EGERIA_VIEW_SERVER` is running and active. -- Your network allows communication with the Egeria platform (check for VPN or firewall issues). +**The app exits immediately with code 430.** Your environment has an old pydantic, often because a globally installed `textual` was used. Activate the project's `.venv` and run `textual run --dev my_profile_app.py` or `python my_profile_app.py` from there. + +**The app exits with code 402.** The profile could not be retrieved. Check that: +- `EGERIA_PLATFORM_URL` is correct and reachable (VPN and firewall included); +- 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. -### Authentication Errors -If you encounter authentication errors: -- Double-check your `EGERIA_USER` and `EGERIA_USER_PASSWORD` settings. -- Ensure your user has the necessary permissions to access the View Server and User Profile OMAS. +**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`). + +## Known Limitations + +As of 2026-10-01, these features are incomplete or don't work as described elsewhere: + +- **Communities you add** are not linked to you. pyegeria has no call for community membership, so the community is created but won't appear in User Associations. +- **Not yet verified against a live Egeria server:** bookmarks, My Collections, joining a team, subscription links and starting processes. The test server was unavailable when they were added, so they have only been tested against fakes. + +## Running the Tests + +The test suite is in `tests/micro-tests/my_profile/`. By default it runs against in-memory fakes, and any real network call fails the test: + +```bash +pytest tests/micro-tests/my_profile/ +``` + +Set `PYEG_LIVE_EGERIA=1` to run the same tests against a real Egeria server (default `https://localhost:9443`; override with `PYEG_PLATFORM_URL`, `PYEG_SERVER_NAME`, `PYEG_USER_ID`, `PYEG_USER_PWD`). **Live mode writes to the server.** It creates subscriptions and template elements, and may create a profile. See `tests/micro-tests/my_profile/README.md`. --- -License: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), -Copyright Contributors to the ODPi Egeria project. ## Related Information +- [My Profile Reference Guide](My-Egeria-Doc.md) – architecture, screens and Egeria calls - [Egeria Project Documentation](https://egeria-project.org) - [pyegeria Programming Guide](user_programming.md) - [Dr.Egeria User Manual](dr_egeria_manual.md) -- [My Profile Reference Guide](My-Egeria-Doc.md) + +--- +License: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), +Copyright Contributors to the ODPi Egeria project. diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/AddCommentScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/AddCommentScreen.py index e9746b61..085f6529 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/AddCommentScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/AddCommentScreen.py @@ -36,6 +36,8 @@ def __init__(self, table_name, element_guid, *args, **kwargs): self.platform_url = (app_config.egeria_platform_url or "https://127.0.0.1:9443") self.user_name = app_user.user_name or "garygeeke" self.user_password = app_user.user_pwd or "secret" + self.comment = "" + self.comment_type = "" def on_mount(self): self.title = "Egeria - My Profile" @@ -67,6 +69,9 @@ def action_cancel(self): @on(Button.Pressed, "#add_comment_button") def handle_button_pressed(self): + self.action_add_comment() + + def action_add_comment(self): if self.comment and self.comment_type: self.comment_data = [self.comment, self.comment_type, self.element_guid] self.log(f"Comment data: {self.comment_data} being returned from screen") diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/AddToElementsScreens.py b/my_egeria/my_egeria/DemoCode/My_Profile/AddToElementsScreens.py index a5e60663..f013534e 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/AddToElementsScreens.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/AddToElementsScreens.py @@ -2,38 +2,52 @@ PDX-License-Identifier: Apache-2.0 Copyright Contributors to the ODPi Egeria project. - This file provides a user screen to allow the user to add todos to my_egeria. + This file provides the screens that add elements (todos, blog and journal entries, projects, + communities, roles, teams, collections and user identities) to Egeria for My Profile. """ -import pwd from datetime import datetime -from typing import Any -import optional from textual import on from textual.app import ComposeResult from textual.containers import ScrollableContainer, Horizontal from textual.screen import ModalScreen -from textual.widgets import DataTable, OptionList, Header, Static, Footer, Input, Button, Switch -from textual.widgets._option_list import Option +from textual.widgets import Static, Footer, Input, Button, Switch from pyegeria import Egeria, PyegeriaException, load_app_config, settings, print_basic_exception -class AddTodoScreen(ModalScreen): - """Add Todo Screen for My Profile App.""" +class BaseAddScreen(ModalScreen): + """Shared plumbing for the screens that add one element at a time to Egeria. + + A subclass describes its form with the class attributes below and implements + create_element(). If LINK_LABEL is set the form shows a "link to my profile" + switch, and link_element() is called after a successful create. + + The screen stays open after an add so the user can add several elements in + a row; Quit dismisses it with 200. + """ BINDINGS = [ - ("q", "app.quit", "Quit"), - ("ctrl+a", "add_new_todo", "Add New Todo") + ("q", "quit", "Quit"), + ("ctrl+a", "add_element", "Add"), ] CSS_PATH = "my_profile.tcss" + SCREEN_ID = "" + ELEMENT_NAME = "" # e.g. "Collection" - used for the title, button and messages + INTRO_TEXT = "" + # (input id, label, required) + FIELDS: list[tuple[str, str, bool]] = [] + # Label for the "link to my profile" switch; None means the form has no switch + LINK_LABEL: str | None = None + def __init__(self, selected_table, user_GUID, *args, **kwargs): - super().__init__(id="add_todo_screen", *args, **kwargs) + super().__init__(id=self.SCREEN_ID, *args, **kwargs) self.selected_table = selected_table + self.user_guid = user_GUID load_app_config() app_config = settings.Environment app_user = settings.User_Profile @@ -41,95 +55,111 @@ def __init__(self, selected_table, user_GUID, *args, **kwargs): 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.todo_name = "" - self.todo_description = "" - self.todo_priority = "" - self.todo_guid = "" - self.user_guid = user_GUID - self.link_todo_to_profile = True - - def on_mount(self): - main_screen = self.app.get_screen("main") - - self.todos_table = main_screen.query_one("#todos_table", DataTable) - assert self.todos_table is not None - - self.todos_table.zebra_stripes = True - self.todos_table.cursor_type = "row" - self.todos_table.focus() + self.link_to_profile = self.LINK_LABEL is not None + self.created_guids: list[str] = [] def compose(self) -> ComposeResult: - yield Static("Add Todo Screen") - yield ScrollableContainer( - Static("This screen is intended for the user who wants to add a small number of Todos\n" - "Please ensure that you have filled in all fields before clicking 'Add Todo'\n" - "For bulk additions please use Dr_Egeria instead."), - Input("Name of Todo", id="todo_name"), - Input("Description of Todo", id="todo_description"), - Input("Priority of Todo", id="todo_priority"), - Static("Status will be automatically set to 'REQUESTED'"), - Horizontal( - Button("Add Todo", id="add_todo_button", variant="primary"), - Button("Quit", id="quit_button", variant="warning") + yield Static(f"Add {self.ELEMENT_NAME} Screen") + widgets = [Static( + f"{self.INTRO_TEXT}" + f"Please fill in all required (*) fields before clicking 'Add {self.ELEMENT_NAME}'\n" + "For bulk additions please use Dr_Egeria instead.\n" + "Once additions are complete Quit and use the Refresh hot key on the main screen to update the display.")] + for field_id, label, required in self.FIELDS: + widgets.append(Static(f"{label}{' *' if required else ''}")) + widgets.append(Input(placeholder=label, id=field_id)) + if self.LINK_LABEL is not None: + widgets.append(Horizontal( + Static(self.LINK_LABEL), + Switch(value=True, id="link_to_profile"), + )) + widgets.append(Horizontal( + Button(f"Add {self.ELEMENT_NAME}", id="add_button", variant="primary"), + Button("Quit", id="quit_button", variant="warning"), )) + yield ScrollableContainer(*widgets, id="add_input_container") yield Footer() - def action_add_new_todo(self): - """ Call Egeria to add the new todo """ - tclient = Egeria( + def validate(self, values: dict[str, str]) -> str | None: + """Return an error message if the (non-empty) form values are unacceptable.""" + return None + + def create_element(self, client: Egeria, values: dict[str, str]) -> str: + """Create the element in Egeria from the form values and return its GUID.""" + raise NotImplementedError + + def link_element(self, client: Egeria, guid: str) -> None: + """Link the newly created element to the user's profile. + + The form values used for the create are available as self.last_values. + """ + raise NotImplementedError + + def _read_values(self) -> dict[str, str]: + return {field_id: self.query_one(f"#{field_id}", Input).value.strip() + for field_id, _, _ in self.FIELDS} + + def _clear_inputs(self) -> None: + for field_id, _, _ in self.FIELDS: + self.query_one(f"#{field_id}", Input).clear() + + def action_add_element(self) -> None: + """Validate the form, then create (and optionally link) the element in Egeria.""" + values = self._read_values() + missing = [label for field_id, label, required in self.FIELDS if required and not values[field_id]] + if missing: + self.notify(f"Please enter: {', '.join(missing)}", timeout=10, severity="error") + return + problem = self.validate(values) + if problem: + self.notify(problem, timeout=10, severity="error") + return + self.last_values = values + + client = Egeria( view_server=self.view_server, platform_url=self.platform_url, user_id=self.user_name, user_pwd=self.user_password ) - try: - token = tclient.create_egeria_bearer_token(self.user_name, self.user_password) - todo_guid = tclient.create_my_todo( - todo_name=self.todo_name, - description=self.todo_description, - priority=self.todo_priority, - activity_status="REQUESTED" - ) - self.log(f"Created ToDo: {todo_guid}") - + client.create_egeria_bearer_token(self.user_name, self.user_password) + guid = self.create_element(client, values) + self.created_guids.append(guid) + self.log(f"Created {self.ELEMENT_NAME}: {guid}") + self.notify(f"Created {self.ELEMENT_NAME}: {guid}", timeout=10, severity="information") + if self.link_to_profile: + if not self.user_guid: + self.notify(f"{self.ELEMENT_NAME} not linked: your profile GUID is unknown", + timeout=10, severity="warning") + else: + try: + self.link_element(client, guid) + self.notify(f"Linked {self.ELEMENT_NAME} to your profile", timeout=10, + severity="information") + except PyegeriaException as e: + print_basic_exception(e) + self.notify(f"Link {self.ELEMENT_NAME} to profile failed with return: {e}", + timeout=10, severity="error") + # Only clear the form on success, so a failed add can be corrected and retried + self._clear_inputs() except PyegeriaException as e: - self.notify(f"Add todo failed with return: {e}", timeout=10, severity="error") + print_basic_exception(e) + self.notify(f"Add {self.ELEMENT_NAME} failed with return: {e}", timeout=10, severity="error") finally: - tclient.close_session() - self.todo_name = "" - self.todo_description = "" - self.todo_priority = "" - self.todo_guid = "" - self.todo_link_guid = "" - self.query_one("#todo_name", Input).clear() - self.query_one("#todo_description", Input).clear() - self.query_one("#todo_priority", Input).clear() - return - - @on(Switch.Changed, "#link_todo_to_profile") - def handle_link_todo_to_profile_changed(self, event: Switch.Changed): - self.link_todo_to_profile = event.switch.value - - @on(Input.Changed) - def handle_input_changed(self, event: Input.Changed): - if event.input.id == "todo_name": - self.todo_name = event.input.value - if event.input.id == "todo_description": - self.todo_description = event.input.value - if event.input.id == "todo_priority": - self.todo_priority = event.input.value + client.close_session() + + @on(Switch.Changed, "#link_to_profile") + def handle_link_to_profile_changed(self, event: Switch.Changed): + self.link_to_profile = event.switch.value def action_quit(self): self.dismiss(200) - @on(Button.Pressed, "#add_todo_button") - def handle_add_todo_button(self, event: Button.Pressed): + @on(Button.Pressed, "#add_button") + def handle_add_button(self, event: Button.Pressed): """ Handle the add button press """ - if self.todo_name and self.todo_description: - self.action_add_new_todo() - else: - self.notify("Please enter at least anew todo name and description", timeout=10, severity="error") + self.action_add_element() @on(Button.Pressed, "#quit_button") def handle_quit_button(self, event: Button.Pressed): @@ -137,12 +167,42 @@ def handle_quit_button(self, event: Button.Pressed): self.action_quit() +class AddTodoScreen(BaseAddScreen): + """Add a To-Do for the current user.""" + + SCREEN_ID = "add_todo_screen" + ELEMENT_NAME = "Todo" + INTRO_TEXT = ("This screen is intended for the user who wants to add a small number of Todos\n" + "Status will be automatically set to 'REQUESTED'\n") + FIELDS = [ + ("todo_name", "Name of Todo", True), + ("todo_description", "Description of Todo", True), + ("todo_priority", "Priority of Todo (a whole number, default 0)", False), + ] + + def validate(self, values: dict[str, str]) -> str | None: + if values["todo_priority"] and not values["todo_priority"].isdigit(): + return "Priority must be a whole number" + return None + + def create_element(self, client: Egeria, values: dict[str, str]) -> str: + return client.create_my_todo( + todo_name=values["todo_name"], + description=values["todo_description"], + priority=int(values["todo_priority"] or 0), + activity_status="REQUESTED", + ) + + class AddAssociationScreen(ModalScreen): - """Add Association (Projects or Communities) Screen for My Profile App.""" + """Ask whether the new association is a Project or a Community. + + Dismisses with "project" or "community"; the app's add_association_callback then + opens AddProjectScreen or AddCommunityScreen. Quit dismisses with 200. + """ BINDINGS = [ - ("q", "app.quit", "Quit"), - ("ctrl+a", "add_new_association", "Add New Association") + ("q", "quit", "Quit"), ] CSS_PATH = "my_profile.tcss" @@ -150,1107 +210,296 @@ class AddAssociationScreen(ModalScreen): def __init__(self, selected_table, user_GUID, *args, **kwargs): super().__init__(id="add_association_screen", *args, **kwargs) self.selected_table = selected_table - load_app_config() - app_config = settings.Environment - app_user = settings.User_Profile self.user_guid = user_GUID - 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.project_name = "" - self.project_description = "" - self.project_classification = "" - self.project_identifier = "" - self.project_start_date = "" - self.project_end_date = "" - self.community_name = "" - self.community_description = "" - self.community_guid = "" - self.community_link_guid = "" - self.project_link_guid = "" - self.link_community_to_profile = False - self.link_project_to_profile = False - self.selected_element_type = None - self.todo_name = None - self.todo_description = None - self.todo_priority = None - self.todo_guid = None - - - def on_mount(self): - main_screen = self.app.get_screen("main") def compose(self) -> ComposeResult: yield Static("Add Association Screen") yield ScrollableContainer( - Static("This screen is intended for the user who wants to add a small number of new Projects or Communities to Egeria\n" - "First please select which element type you want to add, Project or Community, and the screen will change accordingly.\n" - "Please ensure that you have filled in all fields before clicking 'Add Association'\n" - "For bulk additions please use Dr_Egeria instead."), - Input(placeholder="Element type to be added:", id="element_type"), + Static("Which kind of association do you want to add?"), Horizontal( - Button("Select Element Type", id="select_element_type_button", variant="primary"), + Button("Project", id="choose_project_button", variant="primary"), + Button("Community", id="choose_community_button", variant="primary"), Button("Quit", id="quit_button", variant="warning"), - id="add_association_button_container" ), id="element_type_input", ) yield Footer() - def display_add_new_project_screen(self): - input_container = self.query_one("#element_type_input", ScrollableContainer) - input_container.remove_children() - input_container.border_title = "Add New Project" - input_container.mount(Static("Please fill in all input fields before clicking 'Add Project'"), - Input(placeholder="Name of the project:", id="project_name"), - Input(placeholder="Description of the project:", id="project_description"), - Static("Classification: Campaign, StudyProject, Task, PersonalProject or Project"), - Input(placeholder="Project Classification:", id="project_classification"), - Input(placeholder="Project Identifier:", id="project_identifier"), - Input(placeholder="Start Date (mm/dd/yyyy):", id="project_start_date"), - Input(placeholder="Planned End Date (mm/dd/yyyy):", id="project_end_date"), - Static("Link Project to your profile? True or False, Default = True"), - Switch(value=False, id="link_project_to_profile"), - Horizontal( - Button("Add Project", id="add_project_button"), - Button("Quit", id="quit_button") - ) - ) - - def display_add_new_community_screen(self): - input_container = self.query_one("#element_type_input", ScrollableContainer) - input_container.remove_children() - input_container.border_title = "Add New Community" - input_container.mount(Static("Please fill in all input fields before clicking 'Add Project'"), - Input(placeholder="Name of the community:", id="community_name"), - Input(placeholder="Description of the community:", id="community_description"), - Static("Link Community to your profile? True or False, Default = True"), - Switch(value=False, id="link_community_to_profile"), - Horizontal( - Button("Add Community", id="add_community_button"), - Button("Quit", id="quit_button") - ) - ) - - def action_add_new_community(self): - """ Call Egeria to add the new community """ - - tclient = Egeria( - view_server=self.view_server, - platform_url=self.platform_url, - user_id=self.user_name, - user_pwd=self.user_password - ) - - try: - token = tclient.create_egeria_bearer_token(self.user_name, self.user_password) - - community_body = { - "class": "NewElementRequestBody", - "typeName": "Community", # community type - "initialStatus": "ACTIVE", # initial status of the new element - "properties": { # properties for a Community instance - "class": "CommunityProperties", - "qualifiedName": tclient.__create_qualified_name__("Community", self.community_name), - "displayName": self.community_name, # community name to be displayed in UI - "description": self.community_description, # description of the new element (optional) - } - } - community_guid = tclient.create_community( - body=community_body - ) - self.log(f"Created Community: {community_guid}") - if self.link_community_to_profile is True: - try: - self.community_link_guid = tclient.link_community_to_profile( - community_guid=community_guid, - profile_guid=self.user_guid - ) - self.notify(f"Linked Community to profile: {self.community_link_guid}", timeout=10, severity="information") - except PyegeriaException as e: - self.log(f"Link community to profile failed with return: {e}") - except PyegeriaException as e: - self.notify(f"Add todo failed with return: {e}", timeout=10, severity="error") - finally: - tclient.close_session() - self.community_name = "" - self.community_description = "" - self.community_guid = "" - self.query_one("#community_name", Input).clear() - self.query_one("#community_description", Input).clear() - return - - def action_add_new_project(self): - """ Call Egeria to add the new project """ - tclient = Egeria( - view_server=self.view_server, - platform_url=self.platform_url, - user_id=self.user_name, - user_pwd=self.user_password - ) - try: - token = tclient.create_egeria_bearer_token(self.user_name, self.user_password) - project_body = { - "class": "NewElementRequestBody", - "properties": { - "classificationName": "Campaign", # type of project - "displayName": self.project_name, # display name - "description": self.project_description, # description - "identifier": self.project_identifier # business identifier for the project - } - } - project_guid = tclient.create_project( - anchor_guid=None, # The identity of the anchor element for the project. - parent_guid=None, # The identity of the parent element for the project. - parent_relationship_type_name=None,# The type of relationship to the parent element. - parent_at_end1=False, # True if the parent is at end 1 of the relationship. - display_name=self.project_name, # The display name of the project. - description=self.project_description,# A description of the project. - classification_name=self.project_classification, # The type of project - Campaign, StudyProject, Task, PersonalProject or Project. - identifier=self.project_identifier, # A business identifier for the project. - is_own_anchor=False, # True if the project is its own anchor. - status=None, # The project status. - phase=None, # The project phase. - health=None, # The project health. - start_date=self.project_start_date, # The start date of the project. - planned_end_date=self.project_end_date, # The planned completion date of the project. - body=project_body # A dict representing the details of the project to create. - ) - self.log(f"Created project: {project_guid}") - if self.link_project_to_profile is True: - try: - self.project_link_guid = tclient.link_project_to_profile( - profile_guid=self.user_guid, - project_guid=project_guid - ) - self.log(f"Linked project: {self.project_link_guid}") - self.notify(f"Linked project: {self.project_link_guid}", timeout=10, severity="information") - except PyegeriaException as e: - self.notify(f"Link project to profile failed with return: {e}", timeout=10, severity="error") - except PyegeriaException as e: - self.notify(f"Add project failed with return: {e}", timeout=10, severity="error") - finally: - tclient.close_session() - self.project_name = "" - self.project_description = "" - self.project_classification = "" - self.project_identifier = "" - self.project_start_date = "" - self.project_end_date = "" - self.query_one("#project_name", Input).clear() - self.query_one("#project_description", Input).clear() - self.query_one("#project_classification", Input).clear() - self.query_one("#project_identifier", Input).clear() - self.query_one("#project_start_date", Input).clear() - self.query_one("#project_end_date", Input).clear() - return - - @on(Switch.Changed, "#link_project_to_profile") - def handle_link_project_to_profile_changed(self, event: Switch.Changed): - self.link_project_to_profile = event.switch.value - - @on(Switch.Changed, "#link_community_to_profile") - def handle_link_community_to_profile_changed(self, event: Switch.Changed): - self.link_community_to_profile = event.switch.value - def action_quit(self): self.dismiss(200) - @on(Button.Pressed, "#select_element_type_button") - def handle_select_element_type_button(self, event: Button.Pressed): - self.event_id = event.control.id - self.selected_element_type = self.query_one("#element_type", Input).value - if self.selected_element_type.upper() == "PROJECT": - self.display_add_new_project_screen() - elif self.selected_element_type.upper() == "COMMUNITY": - self.display_add_new_community_screen() - else: - self.notify("Invalid element type selected, Only 'Project' or 'Community' are allowed.", severity="error", timeout=15) - - @on(Button.Pressed, "#add_community_button") - def handle_add_community_button(self, event: Button.Pressed): - self.event_id= event.control.id - """ Handle the add button press """ - self.community_name = self.query_one("#community_name", Input).value - self.community_description = self.query_one("#community_description", Input).value - if self.community_name and self.community_description: - self.action_add_new_community() - else: - self.notify("Please enter new community name and description before selecting Add Community button", timeout=10, severity="error") - - @on(Button.Pressed, "#add_project_button") - def handle_add_project_button(self, event: Button.Pressed): - """ Handle the add button press """ - self.event_id= event.control.id - self.project_name = self.query_one("#project_name", Input).value - self.project_description = self.query_one("#project_description", Input).value - self.project_classification = self.query_one("#project_classification", Input).value - self.project_identifier = self.query_one("#project_identifier", Input).value - self.project_start_date = self.query_one("#project_start_date", Input).value - self.project_end_date = self.query_one("#project_end_date", Input).value - if self.project_name and self.project_description: - self.action_add_new_project() - else: - self.notify("Please enter new project values before selecting Add Community button", - timeout=10, severity="error") + @on(Button.Pressed, "#choose_project_button") + def handle_choose_project(self, event: Button.Pressed): + self.dismiss("project") + + @on(Button.Pressed, "#choose_community_button") + def handle_choose_community(self, event: Button.Pressed): + self.dismiss("community") @on(Button.Pressed, "#quit_button") def handle_quit_button(self, event: Button.Pressed): """ Handle the quit button press """ self.action_quit() -class AddBlogEntryScreen(ModalScreen): - """Add Blog Entry Screen for My Profile App.""" - - BINDINGS = [ - ("q", "app.quit", "Quit"), - ("ctrl+a", "add_new_blog", "Add New Blog Entry") - ] - - CSS_PATH = "my_profile.tcss" - - def __init__(self, selected_table, user_GUID, *args, **kwargs): - super().__init__(id="add_blog_screen", *args, **kwargs) - self.selected_table = selected_table - load_app_config() - app_config = settings.Environment - app_user = settings.User_Profile - self.user_guid = user_GUID - 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.blog_entry_name = "" - self.blog_entry_description = "" - self.blog_entry_priority = "" - self.blog_entry_guid = "" - self.link_blog_entry_to_profile = True - - def on_mount(self): - main_screen = self.app.get_screen("main") - self.blogs_table = main_screen.query_one("#blogs_table", DataTable) - assert self.blogs_table is not None +class AddBlogEntryScreen(BaseAddScreen): + """Add an entry to the current user's blog.""" - self.blogs_table.zebra_stripes = True - self.blogs_table.cursor_type = "row" - self.blogs_table.focus() - - def compose(self) -> ComposeResult: - yield Static("Add Blog Entry Screen") - yield ScrollableContainer( - Static("This screen is intended for the user who wants to add a small number of blog entries\n" - "Please ensure that you have filled in all fields before clicking 'Add Blog Entry'\n" - "For bulk additions please use Dr_Egeria instead."), - Static("Name"), - Input("Name of Blog Entry", id="blog_entry_name"), - Static("Text"), - Input("Text of Entry", id="blog_entry_text"), - Static("Situation"), - Input("Situation", id="blog_entry_situation"), - Horizontal( - Button("Add Blog Entry", id="add_entry_button", variant="primary"), - Button("Quit", id="quit_button", variant="warning") - ), - id="blog_input_container", - ) - yield Footer() + SCREEN_ID = "add_blog_screen" + ELEMENT_NAME = "Blog Entry" + INTRO_TEXT = "This screen is intended for the user who wants to add a small number of blog entries\n" + FIELDS = [ + ("blog_entry_name", "Name of Blog Entry", True), + ("blog_entry_text", "Text of Entry", True), + ("blog_entry_situation", "Situation", False), + ] - def action_add_new_blog(self): - """ Call Egeria to add the new blog entry """ - tclient = Egeria( - view_server=self.view_server, - platform_url=self.platform_url, - user_id=self.user_name, - user_pwd=self.user_password - ) - try: - token = tclient.create_egeria_bearer_token(self.user_name, self.user_password) - body = { - "class": "NewAttachmentRequestBody", - "properties": { - "class": "BlogEntryProperties", - "qualifiedName": f"Blog::Blog-{datetime.now().isoformat()}", - "displayName": self.blog_entry_name, - "situation": self.blog_entry_situation, - "description": self.blog_entry_text, + def create_element(self, client: Egeria, values: dict[str, str]) -> str: + name = values["blog_entry_name"] + body = { + "class": "NewAttachmentRequestBody", + "properties": { + "class": "BlogEntryProperties", + "typeName": "BlogEntry", + "qualifiedName": client.make_feedback_qn("Blog", self.user_name, name), + "displayName": name, + "situation": values["blog_entry_situation"] or None, + "description": values["blog_entry_text"], } } - blog_entry_response = tclient.blog_my_activity(body=body) - assert isinstance(blog_entry_response, str) - blog_entry_guid = blog_entry_response - self.log(f"Created Blog Entry assigned to the current user: {blog_entry_guid}") - # if self.link_blog_entry_to_profile is True: - # try: - # tclient.link_element_to_profile( - # element_guid=self.user_guid, - # linked_element_guid=blog_entry_guid, - # relationship_type="BlogEntryToUser", - # relationship_properties={"class": "BlogEntryToUserProperties"}, - # ) - # self.notify(f"Linked Blog Entry to Profile: {blog_entry_guid}", timeout=10, severity="information") - # except PyegeriaException as e: - # self.notify(f"Link Blog Entry to Profile failed with return: {e}", timeout=10, severity="error") - except PyegeriaException as e: - self.notify(f"Add blog entry failed with return: {e}", timeout=10, severity="error") - finally: - tclient.close_session() - self.blog_entry_name = "" - self.blog_entry_description = "" - self.blog_entry_situation = "" - self.blog_entry_guid = "" - self.query_one("#blog_entry_name", Input).clear() - self.query_one("#blog_entry_text", Input).clear() - self.query_one("#blog_entry_situation", Input).clear() - self.query_one("#blog_input_container", ScrollableContainer).refresh() - return - - @on(Input.Changed) - def handle_input_changed(self, event: Input.Changed): - if event.input.id == "blog_entry_name": - self.blog_entry_name = event.input.value - if event.input.id == "blog_entry_text": - self.blog_entry_text = event.input.value - if event.input.id == "blog_entry_situation": - self.blog_entry_situation = event.input.value + # blog_my_activity attaches the entry to the user's own blog + return client.blog_my_activity(body=body) - def action_quit(self): - self.dismiss(200) - - @on(Button.Pressed, "#add_entry_button") - def handle_add_entry_button(self, event: Button.Pressed): - """ Handle the add button press """ - self.log(f"Blog entry name: {self.blog_entry_name}, blog entry text: {self.blog_entry_text}, blog entry situation: {self.blog_entry_situation}") - if self.blog_entry_name: - self.action_add_new_blog() - else: - self.notify("Please enter at least a new blog name and text", timeout=10, severity="error") - - @on(Button.Pressed, "#quit_button") - def handle_quit_button(self, event: Button.Pressed): - """ Handle the quit button press """ - self.action_quit() +class AddCommunityScreen(BaseAddScreen): + """Add a new Community to Egeria.""" -class AddCommunityScreen(ModalScreen): - """ Add Community Screen for My Profile App.""" - - BINDINGS = [ - ("q", "app.quit", "Quit"), - ("ctrl+a", "add_new_community", "Add New Community") + SCREEN_ID = "add_community_screen" + ELEMENT_NAME = "Community" + INTRO_TEXT = "This screen is intended for the user who wants to add a small number of Communities\n" + FIELDS = [ + ("community_display_name", "Name of Community", True), + ("community_description", "Description of Community", True), ] - CSS_PATH = "my_profile.tcss" - - def __init__(self, selected_table, user_GUID, *args, **kwargs): - super().__init__(id="add_community_screen", *args, **kwargs) - self.selected_table = selected_table - load_app_config() - app_config = settings.Environment - app_user = settings.User_Profile - self.user_guid = user_GUID - 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.community_description = "" - self.community_name = "" - self.link_community_to_profile = True - # - def on_mount(self): - main_screen = self.app.get_screen("main") - - def compose(self) -> ComposeResult: - yield Static("Add Community Screen") - yield ScrollableContainer( - Static("This screen is intended for the user who wants to add a small number of Communities\n" - "Please ensure that you have filled in all fields before clicking 'Add Community'\n" - "For bulk additions please use Dr_Egeria instead.\n" - "Following the add, please use the Refresh hot key on the main screen to display the updated data" - ), - Input("Name of Community", id="community_display_name"), - Input("Description of Community", id="community_description"), - Static("Domain Identifier will be automatically set to '0 - All Domains'"), - - Horizontal( - Static("Link Community to your profile? True or False, Default = True"), - Switch(value=True, id="link_community_to_profile") - ), - Horizontal( - Button("Add Community", id="add_community_button", variant="primary"), - Button("Quit", id="quit_button", variant="warning") - ) - ) - yield Footer() - - def action_add_new_community(self): - """ Call Egeria to add the new todo """ - tclient = Egeria( - view_server=self.view_server, - platform_url=self.platform_url, - user_id=self.user_name, - user_pwd=self.user_password - ) - - try: - token = tclient.create_egeria_bearer_token(self.user_name, self.user_password) - body = { - "class": "Community", - "properties": { - "typeName": "Community", # the actual Egeria type - "qualifiedName": tclient.__create_qualified_name__("Community", self.community_name), - "displayName": self.community_name, - "description": self.community_description, - "domainIdentifier": 0, # 0 = all domains + def create_element(self, client: Egeria, values: dict[str, str]) -> str: + name = values["community_display_name"] + body = { + "class": "NewElementRequestBody", + "isOwnAnchor": True, + "properties": { + "class": "CommunityProperties", + "typeName": "Community", + "qualifiedName": client.__create_qualified_name__("Community", name), + "displayName": name, + "description": values["community_description"], } } - response = tclient.create_governance_definition(body) - if isinstance(response, dict): - self.log(f"Created community with ID {response['guid']}") - if self.link_community_to_profile is True: - try: - tclient.link_element_to_profile( - element_guid=self.user_guid, - linked_element_guid=response["guid"], - relationship_type="CommunityToUser", - relationship_properties={"class": "CommunityToUserProperties"}, - ) - self.notify(f"Linked Community to Profile: {response['guid']}", timeout=10, severity="information") - except PyegeriaException as e: - self.notify(f"Link Community to Profile failed with return: {e}", timeout=10, severity="error") - else: - self.log(f"Error creating community: {response}") - except PyegeriaException as e: - print_basic_exception(e) - finally: - tclient.close_session() - - @on(Input.Changed) - def handle_input_changed(self, event: Input.Changed): - if event.input.id == "community_display_name": - self.community_name = event.input.value - if event.input.id == "community_description": - self.community_description = event.input.value - - @on(Switch.Changed, "#link_community_to_profile") - def handle_link_community_to_profile_changed(self, event: Switch.Changed): - self.link_community_to_profile = event.switch.value - - def action_quit(self): - self.dismiss(200) - - @on(Button.Pressed, "#add_community_button") - def handle_add_todo_button(self, event: Button.Pressed): - """ Handle the add button press """ - if self.community_name and self.community_description: - self.action_add_new_community() - else: - self.notify("Please enter a community name and description", timeout=10, severity="error") + return client.create_community(body=body) - @on(Button.Pressed, "#quit_button") - def handle_quit_button(self, event: Button.Pressed): - """ Handle the quit button press """ - self.dismiss(200) -class AddJournalEntryScreen(ModalScreen): - """ Add Journal Entry Screen for My Profile App.""" +class AddJournalEntryScreen(BaseAddScreen): + """Add an entry to the current user's journal.""" - BINDINGS = [ - ("q", "app.quit", "Quit"), - ("ctrl+a", "add_new_journal_entry", "Add New Journal Entry") + SCREEN_ID = "add_journal_entry_screen" + ELEMENT_NAME = "Journal Entry" + INTRO_TEXT = "" + FIELDS = [ + ("journal_entry_title", "Title of Entry", True), + ("journal_entry_text", "Text of Entry", True), + ("journal_entry_situation", "Situation of Entry", False), ] - CSS_PATH = "my_profile.tcss" - - def __init__(self, selected_table, user_GUID, *args, **kwargs): - super().__init__(id="add_journal_entry_screen", *args, **kwargs) - self.selected_table = selected_table - load_app_config() - app_config = settings.Environment - app_user = settings.User_Profile - self.user_guid = user_GUID - 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.journal_entry_title = "" - self.journal_entry_text = "" - self.journal_entry_qualified_name = "" - self.journal_entry_situation = "" - self.link_journal_entry_to_profile = True - - def on_mount(self): - main_screen = self.app.get_screen("main") - - self.journal_table = main_screen.query_one("#journal_table", DataTable) - self.journal_table.zebra_stripes = True - self.journal_table.cursor_type = "row" - self.journal_table.focus() - - def compose(self) -> ComposeResult: - yield Static("Add Journal Entry Screen") - yield ScrollableContainer( - Static("Please ensure that you have filled in all fields before clicking 'Add Journal Entry' Button\n" - "For bulk additions please use Dr_Egeria instead."), - Static("Title"), - Input("Title of Entry", id="journal_entry_title"), - Static("Text"), - Input("Text of Entry", id="journal_entry_text"), - Static("Situation"), - Input("Situation of Entry", id="journal_entry_situation"), - Horizontal( - Button("Add Journal Entry", id="add_journal_entry_button", variant="primary"), - Button("Quit", id="quit_button", variant="warning") - )) - yield Footer() - - def action_add_new_journal_entry(self): - """ Call Egeria to add the new entry """ - - self.qualified_name = f"{self.user_name}:{self.journal_entry_title}:JournalEntry:{datetime.now().isoformat()}" + def create_element(self, client: Egeria, values: dict[str, str]) -> str: + title = values["journal_entry_title"] body = { "class": "NewAttachmentRequestBody", "properties": { "class": "JournalEntryProperties", - "qualifiedName": self.qualified_name, - "displayName": self.journal_entry_title, - "situation": self.journal_entry_situation, - "description": self.journal_entry_text + "qualifiedName": client.make_feedback_qn("Journal", self.user_name, title), + "displayName": title, + "situation": values["journal_entry_situation"] or None, + "description": values["journal_entry_text"], } } - tclient = Egeria( - view_server=self.view_server, - platform_url=self.platform_url, - user_id=self.user_name, - user_pwd=self.user_password - ) - try: - token = tclient.create_egeria_bearer_token(self.user_name, self.user_password) - journal_entry_response = tclient.journal_my_activity(body=body) - assert isinstance(journal_entry_response, str) - self.log(f"Created Journal Entry for the current user: {journal_entry_response}") - except PyegeriaException as e: - self.notify(f"Add journal entry failed with return: {e}", timeout=10, severity="error") - finally: - tclient.close_session() - self.journal_entry_title = "" - self.journal_entry_text = "" - self.journal_entry_situation = "" - self.qualified_name = "" - self.query_one("#journal_entry_title", Input).clear() - self.query_one("#journal_entry_text", Input).clear() - self.query_one("#journal_entry_situation", Input).clear() - return - - @on(Input.Changed) - def handle_input_changed(self, event: Input.Changed): - if event.input.id == "journal_entry_title": - self.journal_entry_title = event.input.value - if event.input.id == "journal_entry_text": - self.journal_entry_text = event.input.value - if event.input.id == "journal_entry_situation": - self.journal_entry_situation = event.input.value - - @on(Switch.Changed, "#link_journal_entry_to_profile") - def handle_link_journal_entry_to_profile_changed(self, event: Switch.Changed): - self.link_journal_entry_to_profile = event.switch.value + # journal_my_activity attaches the entry to the user's own journal + return client.journal_my_activity(body=body) - def action_quit(self): - self.dismiss(200) - @on(Button.Pressed, "#add_journal_entry_button") - def handle_add_journal_entry_button(self, event: Button.Pressed): - """ Handle the add button press """ - if self.journal_entry_title and self.journal_entry_text: - self.action_add_new_journal_entry() - else: - self.notify("Please enter at least a new title and text for the entry", timeout=10, severity="error") +PROJECT_CLASSIFICATIONS = ["Project", "Campaign", "StudyProject", "Task", "PersonalProject"] - @on(Button.Pressed, "#quit_button") - def handle_quit_button(self, event: Button.Pressed): - """ Handle the quit button press """ - self.action_quit() +class AddProjectScreen(BaseAddScreen): + """Add a new Project to Egeria, optionally adding the user to its team.""" -class AddProjectScreen(ModalScreen): - """Add project Screen for My Profile App.""" - - BINDINGS = [ - ("q", "app.quit", "Quit"), - ("ctrl+a", "add_new_project", "Add New Project") + SCREEN_ID = "add_project_screen" + ELEMENT_NAME = "Project" + INTRO_TEXT = "This screen is intended for the user who wants to add a small number of Projects\n" + FIELDS = [ + ("project_name", "Name of Project", True), + ("project_description", "Description of Project", True), + ("project_classification", f"Kind of project: {', '.join(PROJECT_CLASSIFICATIONS)} (default Project)", False), + ("project_identifier", "Project Identifier", False), + ("project_start_date", "Start Date (YYYY-MM-DD)", False), + ("project_end_date", "Planned End Date (YYYY-MM-DD)", False), ] + LINK_LABEL = "Add yourself to the project team? Default = True" + + def validate(self, values: dict[str, str]) -> str | None: + kind = values["project_classification"] + if kind and kind not in PROJECT_CLASSIFICATIONS: + return f"Kind of project must be one of: {', '.join(PROJECT_CLASSIFICATIONS)}" + for field_id in ("project_start_date", "project_end_date"): + if values[field_id]: + try: + datetime.strptime(values[field_id], "%Y-%m-%d") + except ValueError: + return "Dates must be in the form YYYY-MM-DD" + return None + + def create_element(self, client: Egeria, values: dict[str, str]) -> str: + return client.create_project( + display_name=values["project_name"], + description=values["project_description"], + classification_name=values["project_classification"] or "Project", + identifier=values["project_identifier"] or None, + is_own_anchor=True, + start_date=values["project_start_date"] or None, + planned_end_date=values["project_end_date"] or None, + ) - CSS_PATH = "my_profile.tcss" + def link_element(self, client: Egeria, guid: str) -> None: + client.add_to_project_team(project_guid=guid, actor_guid=self.user_guid) - def __init__(self, selected_table, user_GUID, *args, **kwargs): - super().__init__(id="add_project_screen", *args, **kwargs) - self.selected_table = selected_table - load_app_config() - app_config = settings.Environment - app_user = settings.User_Profile - 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.project_name = "" - self.project_description = "" - self.project_identifier = "" - def on_mount(self): - main_screen = self.app.get_screen("main") +class AddRoleScreen(BaseAddScreen): + """Add a new Person Role to Egeria, optionally appointing the user to it.""" - def compose(self) -> ComposeResult: - yield Static("Add Project Screen") - yield ScrollableContainer( - Static("This screen is intended for the user who wants to add a small number of Projects\n" - "Please ensure that you have filled in all fields before clicking 'Add Project'\n" - "For bulk additions please use Dr_Egeria instead."), - Input("Name of Project", id="project_name"), - Input("Description of project", id="project_description"), - Input("Poject Identifier", id="project_identifier"), - Horizontal( - Static("Link Project to your profile? True or False, Default = True"), - Switch(value=True, id="link_project_to_profile") - ), - Horizontal( - Button("Add Project", id="add_project_button", variant="primary"), - Button("Quit", id="quit_button", variant="warning") - )) - yield Footer() + SCREEN_ID = "add_role_screen" + ELEMENT_NAME = "Role" + INTRO_TEXT = "This screen is intended for the user who wants to add a small number of Roles\n" + FIELDS = [ + ("role_name", "Name of Role", True), + ("role_description", "Description of Role", True), + ] + LINK_LABEL = "Appoint yourself to this role? Default = True" - def action_add_new_project(self): - """ Call Egeria to add the new project """ - tclient = Egeria( - view_server=self.view_server, - platform_url=self.platform_url, - user_id=self.user_name, - user_pwd=self.user_password - ) + def create_element(self, client: Egeria, values: dict[str, str]) -> str: + name = values["role_name"] body = { "class": "NewElementRequestBody", + "isOwnAnchor": True, "properties": { - "classificationName": "Project", # The type of project - "displayName": self.project_name, # Display name for the new element. - "description": self.project_description, # Description for the new element. - "identifier": self.project_identifier, # A business identifier for the element (e.g., a unique code). + "class": "PersonRoleProperties", + "typeName": "PersonRole", + "qualifiedName": client.__create_qualified_name__("PersonRole", name), + "displayName": name, + "description": values["role_description"], + } } - } - - try: - token = tclient.create_egeria_bearer_token(self.user_name, self.user_password) - project_guid = tclient.create_project( - anchor_guid=None, # The identity of the anchor element for the project - parent_guid=None, # The identity of the parent element for the project - parent_relationship_type_name="Project", # The type of relationship to the parent element. - parent_at_end1=False, - display_name=body["properties"]["displayName"], # Display name of the new element. - description=body["properties"]["description"], # Description of the new element (optional). - classification_name=body["properties"]["classificationName"], - identifier=body["properties"]["identifier"], - is_own_anchor=True, # True if this project is its own anchor. - status=None, - phase=None, - health=None, - start_date=None, - planned_end_date=None, - body=body - ) - self.log(f"Created Project assigned to the current user: {project_guid}") - if self.link_project_to_profile is True: - try: - link_project_body = { - "class": "NewRelationshipRequestBody", - "properties": { - "relationshipType": "ProjectLinkedToUser" - } - } - - project_link_guid = tclient.add_to_project_team( - project_guid="PROJECT_GUID", # identity of the project to update - actor_guid=tclient.__create_egeria_user_id__(), - # identity of the actor to add (current logged in user) - assignment_type=None, # Name of the role the actor plays in the project. - description="", # Date at which the actor becomes active in the project. - body=body - ) - self.notify(f"Project linked to current user, response {project_link_guid}") - except PyegeriaException as e: - print_basic_exception(e) - self.notify(f"Link project to user failed with return: {e}", timeout=10, severity="error") - except PyegeriaException as e: - self.notify(f"Add project failed with return: {e}", timeout=10, severity="error") - finally: - tclient.close_session() - self.project_name = "" - self.project_description = "" - self.project_identifier = "" - self.project_guid = "" - self.query_one("#project_name", Input).clear() - self.query_one("#project_description", Input).clear() - self.query_one("#project_identifier", Input).clear() - return - - @on(Input.Changed) - def handle_input_changed(self, event: Input.Changed): - if event.input.id == "project_name": - self.project_name = event.input.value - if event.input.id == "project_description": - self.project_description = event.input.value - if event.input.id == "project_identifier": - self.project_identifier = event.input.value - - @on(Switch.Changed, "#link_project_to_profile") - def handle_link_project_to_profile_changed(self, event: Switch.Changed): - self.link_project_to_profile = event.switch.value - - def action_quit(self): - self.dismiss(200) - - @on(Button.Pressed, "#add_todo_button") - def handle_add_project_button(self, event: Button.Pressed): - """ Handle the add button press """ - if self.project_name and self.project_description and self.project_identifier: - self.action_add_new_project() - else: - self.notify("Please enter a project name, description and identifier", timeout=10, severity="error") + return client.create_actor_role(body=body) - @on(Button.Pressed, "#quit_button") - def handle_quit_button(self, event: Button.Pressed): - """ Handle the quit button press """ - self.action_quit() + def link_element(self, client: Egeria, guid: str) -> None: + client.link_person_role_to_profile(person_role_guid=guid, person_profile_guid=self.user_guid) -class AddRoleScreen(ModalScreen): - """ Add Role Screen for My Profile App.""" +class AddTeamScreen(BaseAddScreen): + """Add a new Team to Egeria, optionally making the user a member of it.""" - BINDINGS = [ - ("q", "app.quit", "Quit"), - ("ctrl+a", "add_new_role", "Add New Role") + SCREEN_ID = "add_team_screen" + ELEMENT_NAME = "Team" + INTRO_TEXT = "This screen is intended for the user who wants to add a small number of new Teams\n" + FIELDS = [ + ("team_name", "Name of Team", True), + ("team_description", "Description of Team", True), ] + LINK_LABEL = "Join this team as a member? Default = True" - CSS_PATH = "my_profile.tcss" - - def __init__(self, selected_table, user_GUID, *args, **kwargs): - super().__init__(id="add_role_screen", *args, **kwargs) - self.user_GUID = user_GUID - self.selected_table = selected_table - load_app_config() - app_config = settings.Environment - app_user = settings.User_Profile - 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.role_name = "" - self.role_description = "" - self.link_self_to_role = True - - def on_mount(self): - main_screen = self.app.get_screen("main") - - def compose(self) -> ComposeResult: - yield Static("Add Role Screen") - yield ScrollableContainer( - Static("This screen is intended for the user who wants to add a small number of Roles\n" - "Please ensure that you have filled in all fields before clicking 'Add Role'\n" - "For bulk additions please use Dr_Egeria instead." - "Once additions are complete Quit and use the Refresh hot key on the main screen to update the display."), - Input("Name of role", id="role_name"), - Input("Description of role", id="role_description"), - Horizontal( - Static("Link to this role in your profile? (True or False, default is True)"), - Switch(value=True, id="link_self_to_role"), - ), - Horizontal( - Button("Add Role", id="add_role_button", variant="primary"), - Button("Quit", id="quit_button", variant="warning") - )) - yield Footer() - - def action_add_new_role(self): - """ Call Egeria to add the new role """ - - tclient = Egeria( - view_server=self.view_server, - platform_url=self.platform_url, - user_id=self.user_name, - user_pwd=self.user_password - ) - - token = tclient.create_egeria_bearer_token(self.user_name, self.user_password) + def create_element(self, client: Egeria, values: dict[str, str]) -> str: + name = values["team_name"] + # A Team is an actor profile in Egeria + body = { + "class": "NewElementRequestBody", + "isOwnAnchor": True, + "properties": { + "class": "TeamProperties", + "typeName": "Team", + "qualifiedName": client.__create_qualified_name__("Team", name), + "displayName": name, + "description": values["team_description"], + } + } + return client.create_actor_profile(body=body) + def link_element(self, client: Egeria, guid: str) -> None: + # Team membership in Egeria: a TeamMember role (a PersonRole subtype), appointed + # to the person and scoped to the team by an AssignmentScope relationship + team_name = self.last_values["team_name"] role_body = { "class": "NewElementRequestBody", "isOwnAnchor": True, - "effectiveFrom": "{{$isoTimestamp}}", - "effectiveTo": "{{$isoTimestamp}}", "properties": { - "class": "ActorRoleProperties", - "typeName": "Role", - "actorProfileGroups": [], - "qualifiedName": tclient.__create_qualified_name__("Role:", self.role_name), - "displayName": self.role_name, - "description": self.role_description, - "scope": "0", - "additionalProperties": { - }, - "extendedProperties": { - } + "class": "PersonRoleProperties", + "typeName": "TeamMember", + "qualifiedName": client.__create_qualified_name__("TeamMember", f"{team_name}-{self.user_name}"), + "displayName": f"Member of {team_name}", + "description": f"{self.user_name}'s membership of team {team_name}", } } + role_guid = client.create_actor_role(body=role_body) + client.link_person_role_to_profile(person_role_guid=role_guid, person_profile_guid=self.user_guid) + client.link_assignment_scope(scope_element_guid=guid, actor_guid=role_guid) - try: - self.role_guid = tclient.create_actor_role(body=role_body) - self.log(f"Created Role by the current user: {self.role_guid}") - if self.link_self_to_role is True: - link_body = { - "class": "NewRelationshipRequestBody", - "externalSourceGUID": "add guid here", - "externalSourceName": "add qualified name here", - "effectiveTime": "{{$isoTimestamp}}", - "forLineage": False, - "forDuplicateProcessing": False, - "properties": { - "class": "PersonRoleAppointmentProperties", - "effectiveFrom": "{{$isoTimestamp}}", - "effectiveTo": "" - } - } - try: - self.link_guid = tclient.link_person_role_to_profile({ - self.role_guid: str, - self.user_GUID: str, - "body": link_body, - }) - except(PyegeriaException) as e: - self.notify(f"Linking role failed with return: {e}", timeout=10, severity="error") - print_basic_exception(e) - else: - self.link_guid = "" - self.log(f"Link to profile not requested: {self.link_self_to_role}") - except PyegeriaException as e: - self.notify(f"Add role failed with return: {e}", timeout=10, severity="error") - finally: - tclient.close_session() - self.role_name = "" - self.role_description = "" - self.role_guid = "" - self.query_one("#role_name", Input).clear() - self.query_one("#role_description", Input).clear() - return - - @on(Switch.Changed, "#link_self_to_role") - def handle_link_self_to_role_changed(self, event: Switch.Changed): - self.link_self_to_role = event.switch.value - - @on(Input.Changed) - def handle_input_changed(self, event: Input.Changed): - if event.input.id == "role_name": - self.role_name = event.input.value - if event.input.id == "role_description": - self.role_description = event.input.value - - def action_quit(self): - self.dismiss(200) - - @on(Button.Pressed, "#add_role_button") - def handle_add_role_button(self, event: Button.Pressed): - """ Handle the add button press """ - if self.role_name and self.role_description: - self.action_add_new_role() - else: - self.notify("Please enter at least anew todo name and description", timeout=10, severity="error") - - @on(Button.Pressed, "#quit_button") - def handle_quit_button(self, event: Button.Pressed): - """ Handle the quit button press """ - self.action_quit() - -class AddTeamScreen(ModalScreen): - """Main Screen for My Profile App.""" +class AddCollectionScreen(BaseAddScreen): + """ Add a new Collection to Egeria""" - BINDINGS = [ - ("q", "app.quit", "Quit"), - ("ctrl+a", "add_new_team", "Add New Team") + SCREEN_ID = "add_collection_screen" + ELEMENT_NAME = "Collection" + INTRO_TEXT = "This screen is intended for the user who wants to add a small number of Collections\n" + FIELDS = [ + ("collection_name", "Name of Collection", True), + ("collection_description", "Description of Collection", True), + ("collection_category", "Category of Collection", False), ] - CSS_PATH = "my_profile.tcss" - - def __init__(self, selected_table, user_GUID, *args, **kwargs): - super().__init__(id="add_team_screen", *args, **kwargs) - self.selected_table = selected_table - self.user_guid = user_GUID - load_app_config() - app_config = settings.Environment - app_user = settings.User_Profile - 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.team_name = "" - self.team_description = "" - self.team_identifier = "" - self.team_status = "" - self.team_phase = "" - self.team_health = "" + def create_element(self, client: Egeria, values: dict[str, str]) -> str: + # With no body, create_collection builds a self-anchored collection and its qualified name + return client.create_collection( + display_name=values["collection_name"], + description=values["collection_description"], + category=values["collection_category"] or None, + ) - def on_mount(self): - main_screen = self.app.get_screen("main") - def compose(self) -> ComposeResult: - yield Static("Add Team Screen") - yield ScrollableContainer( - Static("This screen is intended for the user who wants to add a small number of new Teams\n" - "Please ensure that you have filled in all fields before clicking 'Add Team'\n" - "For bulk additions please use Dr_Egeria instead.\n" - "Once new teams have been added use the Refresh hot key on the main screen to update the list of teams"), - Input("Name of Team", id="team_name"), - Input("Description of Team", id="team_description"), - Input("Identifier of Team", id="team_identifier"), - Input("Status of Team, 'Active' or 'Deleted'", id="team_status"), - Input("Phase of Team, 'planning', 'in_progress', 'completed'", id="team_phase"), - Input("Health of Team, 'green', 'yellow', 'red'", id="team_health"), - Static("Domain will be automatically set to '0' - all domains"), - Horizontal( - Static("Link Team to your profile? True or False, Default = True"), - Switch(value=True, id="link_team_to_profile") - ), - Horizontal( - Button("Add Team", id="add_team_button", variant="primary"), - Button("Quit", id="quit_button", variant="warning") - )) +class AddUserIdentityScreen(BaseAddScreen): + """ Add a new User Identity to Egeria""" - def action_add_new_team(self): - """ Call Egeria to add the new team """ - tclient = Egeria( - view_server=self.view_server, - platform_url=self.platform_url, - user_id=self.user_name, - user_pwd=self.user_password - ) + SCREEN_ID = "add_user_identity_screen" + ELEMENT_NAME = "User Identity" + INTRO_TEXT = "This screen is intended for the user who wants to add a small number of User Identities\n" + FIELDS = [ + ("user_identity_user_id", "User ID", True), + ("user_identity_display_name", "Display Name", False), + ("user_identity_distinguished_name", "Distinguished Name", False), + ] + LINK_LABEL = "Link User Identity to your profile? True or False, Default = True" - team_body = { + def create_element(self, client: Egeria, values: dict[str, str]) -> str: + user_id = values["user_identity_user_id"] + body = { "class": "NewElementRequestBody", + "isOwnAnchor": True, "properties": { - "classificationName": "Team", # classification type - "displayName": self.team_name, - "description": self.team_description, - "identifier": self.team_identifier, - "isOwnAnchor": False, - "status": self.team_status, # project status (e.g. 'active') - "phase": self.team_phase, # project phase (e.g. 'planning', 'in_progress') - "health": self.team_health # project health (e.g. 'green', 'yellow', 'red') + "class": "UserIdentityProperties", + "typeName": "UserIdentity", + "qualifiedName": client.__create_qualified_name__("UserIdentity", user_id), + "displayName": values["user_identity_display_name"] or user_id, + "userId": user_id, + "distinguishedName": values["user_identity_distinguished_name"] or None, + } } - } - tclient.create_egeria_bearer_token(self.user_name, self.user_password) - self.link_team_to_user = self.query_one("#link_team_to_profile") - try: - team_guid = tclient.create_team( - anchor_guid=None, - parent_guid=None, - parent_relationship_type_name=None, # optional - parent_at_end1=False, # False by default; indicates the relationship is at end-2 (project) - display_name=self.team_name, # project name - description=self.team_description, # project description - classification_name="Team", # team type - identifier=None, - is_own_anchor=True, # True if this team is its own anchor element - status="Active", # team status (e.g. 'Active' or 'Deleted') - phase=self.team_phase, # team phase (e.g. 'planning', 'in_progress') - health=self.team_health, # team health (e.g. 'green', 'yellow', 'red') - start_date=None, - planned_end_date=None, - body=team_body - ) - self.log(f"Created Team: {team_guid}") - self.notify(f"Team created, guid {team_guid}") - if self.link_team_to_user == True: - try: - # Add the new team GUID to the actor's profile - team_link_body = { - "class": "NewClassificationRequestBody", - "properties": { - "class": "SecurityGroupMembershipProperties", - "groups": [team_guid], - "effectiveFrom": "{{$isoTimestamp}}", - "effectiveTo": "{{$isoTimestamp}}" - }, - "externalSourceGUID": self.user_guid, - "externalSourceName": "", # e.g. 'My Actor Profile' - } - tclient.add_security_group_membershipdef( - user_identity_guid="current_user_GUID", # Replace with actual GUID - security_groups=["new_team_GUID"], - body=team_link_body, # Add the new team to the actor's profile - ) - except PyegeriaException as e: - print_basic_exception(e) - self.notify(f"Add team link to profile failed with return: {e}", timeout=10, severity="error") - except PyegeriaException as e: - self.notify(f"Add team failed with return: {e}", timeout=10, severity="error") - finally: - tclient.close_session() - self.team_name = "" - self.team_description = "" - self.team_identifier = "" - self.team_status = "" - self.team_phase = "" - self.team_health = "" - self.team_guid = "" - self.query_one("#team_name", Input).clear() - self.query_one("#team_description", Input).clear() - self.query_one("#team_identifier", Input).clear() - self.query_one("#team_status", Input).clear() - self.query_one("#team_phase", Input).clear() - self.query_one("#team_health", Input).clear() - return - - @on(Input.Changed) - def handle_input_changed(self, event: Input.Changed): - if event.input.id == "team_name": - self.team_name = event.input.value - if event.input.id == "team_description": - self.team_description = event.input.value - if event.input.id == "team_identifier": - self.team_identifier = event.input.value - if event.input.id == "team_status": - self.team_status = event.input.value - if event.input.id == "team_phase": - self.team_phase = event.input.value - if event.input.id == "team_health": - self.team_health = event.input.value - - @on(Switch.Changed, "#link_team_to_profile") - def handle_link_team_to_profile_changed(self, event: Switch.Changed): - self.link_team_to_profile = event.switch.value - - def action_quit(self): - self.dismiss(200) - - @on(Button.Pressed, "#add_team_button") - def handle_add_team_button(self, event: Button.Pressed): - """ Handle the add button press """ - if self.team_name and self.team_description: - self.action_add_new_team() - else: - self.notify("Please enter all the required fields before pressing the 'Add Team' button", timeout=10, severity="error") - - @on(Button.Pressed, "#quit_button") - def handle_quit_button(self, event: Button.Pressed): - """ Handle the quit button press """ - self.action_quit() - -class AddCollectionScreen(ModalScreen): - """ Add a new Collection to Egeria""" - def __init__(self, *args, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - pass + return client.create_user_identity(body=body) - -class AddUserIdentityScreen(ModalScreen): - """ Add a new User Identity to Egeria""" - def __init__(self, *args, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - pass + def link_element(self, client: Egeria, guid: str) -> None: + client.link_identity_to_profile(user_identity_guid=guid, actor_profile_guid=self.user_guid) diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py index 2bfbbe42..4184fe39 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/MainScreen.py @@ -21,6 +21,7 @@ class MainScreen(Screen): ("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"), ] @@ -90,6 +91,11 @@ def compose(self) -> ComposeResult: DataTable(id="user_identity_table"), id="main_identities_container" ) + yield ScrollableContainer( + Static("Projects"), + DataTable(id="projects_table"), + id="main_projects_container" + ) yield Footer(id="main_footer") @@ -215,6 +221,14 @@ async def action_add_to_table(self): else: self.notify("Please select a table to add to.", timeout=5, severity="warning") + def action_bookmark_row(self): + """ Add the element in the selected row to the user's bookmarks """ + table_name, row_k = self.get_current_table_and_row() + if not (table_name and row_k): + self.notify("Please select a row to bookmark.", timeout=5, severity="warning") + return + self.app.bookmark_table_row(self.query_one(f"#{table_name}", DataTable), row_k) + def action_bookmarks(self): """ Manages BookMarks for the currently logged in user If a table row is selected, its GUID is pre-filled as the target of a new bookmark""" diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/MyBookMarksScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/MyBookMarksScreen.py index db97b102..8d3a16f2 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/MyBookMarksScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/MyBookMarksScreen.py @@ -2,7 +2,7 @@ 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. + This file provides the bookmarks screen for My Profile. """ from textual import on @@ -10,19 +10,19 @@ from textual.containers import ScrollableContainer, Container, Horizontal from textual.screen import ModalScreen from textual.widgets import DataTable, Header, Static, Footer, Input, Button -from pyegeria import (load_app_config, - settings, - print_basic_exception, - Egeria) class MyBookMarksScreen(ModalScreen): - """Main Screen for My Profile App.""" + """List, add and remove the current user's bookmarks. + + The Egeria work is done by the app's BookmarksMixin (add_my_bookmark, + delete_my_bookmark, list_my_bookmarks); this screen only drives it. + """ BINDINGS = [ ("q", "quit", "Quit"), ("ctrl+n", "new_bookmark", "Add New Bookmark"), - ("ctrl+r", "remove_bookmark", "Delete Selected Bookmark"), + ("d", "remove_bookmark", "Remove Highlighted Bookmark"), ] CSS_PATH = "my_profile.tcss" @@ -31,99 +31,78 @@ def __init__(self, my_bookmarks, *args, target_guid: str | None = None, **kwargs super().__init__(id="bookmark_screen", *args, **kwargs) self.title = "Egeria" self.sub_title = "My Bookmarks" - load_app_config() - app_config = settings.Environment - app_user = settings.User_Profile - 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.my_bookmarks_data = my_bookmarks - # GUID of the row selected on the main screen, if any, used to pre-fill a new bookmark + # Rows of (name, type, GUID) + self.my_bookmarks_data = list(my_bookmarks or []) + # GUID of the row highlighted when the screen was opened, if any, used to pre-fill a new bookmark self.target_guid = target_guid or "" - def on_mount(self) -> None: - self.my_bookmark_table = self.query_one("#my_bookmark_table", DataTable) - self.my_bookmark_table.zebra_stripes = True - self.my_bookmark_table.cursor_type = "row" - self.my_bookmark_table.add_columns("Name", "Type", "GUID") - self.log(f"Bookmark Data:{self.my_bookmarks_data}") - if not self.my_bookmarks_data: - self.my_bookmark_table.add_row("No Bookmarks found for", self.user_name, "") - else: - # Each entry is [display name, type name, GUID of the bookmarked element] - for entry in self.my_bookmarks_data: - self.my_bookmark_table.add_row(entry[0], entry[1], entry[2], key=entry[2]) - def compose(self) -> ComposeResult: yield Header(show_clock=True) yield ScrollableContainer( - Static(f"Existing Bookmarks, (if any)"), - DataTable(id="my_bookmark_table"), + Static("Your bookmarks. Ctrl+N adds one by GUID; 'd' removes the highlighted one."), + DataTable(id="my_bookmark_table", zebra_stripes=True, cursor_type="row"), id="bookmarks_table_container", ) - yield Container( - Static("Ctrl+N to add a bookmark, Ctrl+R to delete the highlighted bookmark", id="bookmark_guid"), - id="action_bookmark_container" - ) + yield Container(id="action_bookmark_container") yield Footer() - def selected_bookmark_guid(self) -> str: - """ GUID of the bookmark highlighted in the table, or "" if there isn't one """ + def on_mount(self) -> None: + table = self.query_one("#my_bookmark_table", DataTable) + table.add_columns("Name", "Type", "GUID") + self._fill_table() + + def _fill_table(self) -> None: table = self.query_one("#my_bookmark_table", DataTable) - if not self.my_bookmarks_data or not 0 <= table.cursor_row < table.row_count: - return "" - return table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value or "" + table.clear() + for name, element_type, guid in self.my_bookmarks_data: + table.add_row(name, element_type, guid, key=guid) + if not self.my_bookmarks_data: + self.notify("You have no bookmarks yet - press Ctrl+N to add one", timeout=5) + + def _reload(self) -> None: + rows = self.app.list_my_bookmarks() + if rows is not None: + self.my_bookmarks_data = rows + self._fill_table() def action_quit(self) -> None: """ The user elects to quit the bookmarks function """ self.dismiss(200) - @on(Button.Pressed, "#quit_add_bookmark") - @on(Button.Pressed, "#quit_delete_bookmark") - def handle_quit_button(self, event: Button.Pressed) -> None: - self.dismiss(200) - def action_new_bookmark(self) -> None: """ The user wants to add a bookmark""" input_container = self.query_one("#action_bookmark_container", Container) input_container.remove_children() - input_container.mount(Input(value=self.target_guid, - placeholder="GUID of the target of the bookmark", - id="add_bookmark_guid")) - input_container.mount(Horizontal( + input_container.mount( + Input(value=self.target_guid, placeholder="GUID of the element to bookmark", id="add_bookmark_guid"), + Horizontal( Button("Add New Bookmark", id="add_new_bookmark", variant="primary"), - Button("Quit", id="quit_add_bookmark", variant="warning"))) + Button("Cancel", id="cancel_add_bookmark", variant="warning"), + ), + ) + self.query_one("#add_bookmark_guid", Input).focus() @on(Button.Pressed, "#add_new_bookmark") def handle_add_new_bookmark(self, event: Button.Pressed) -> None: input_guid = self.query_one("#add_bookmark_guid", Input).value.strip() if not input_guid: self.notify("You must provide the GUID of the item you want to bookmark before you press the button!", - timeout=10, - severity="warning") + timeout=10, severity="warning") return - self.app.add_my_bookmark(input_guid) - self.dismiss(200) + if self.app.add_my_bookmark(input_guid): + self.query_one("#action_bookmark_container", Container).remove_children() + self._reload() + + @on(Button.Pressed, "#cancel_add_bookmark") + def handle_cancel_add_bookmark(self, event: Button.Pressed) -> None: + self.query_one("#action_bookmark_container", Container).remove_children() def action_remove_bookmark(self) -> None: - """ The user wants to delete a bookmark, the highlighted bookmark's GUID is pre-filled """ - input_container = self.query_one("#action_bookmark_container", Container) - input_container.remove_children() - input_container.mount(Input(value=self.selected_bookmark_guid(), - placeholder="GUID of the bookmark to delete", - id="del_bookmark_guid")) - input_container.mount(Horizontal( - Button("Delete Bookmark", id="delete_bookmark", variant="primary"), - Button("Quit", id="quit_delete_bookmark", variant="warning"))) - - @on(Button.Pressed, "#delete_bookmark") - def handle_delete_bookmark(self, event: Button.Pressed) -> None: - input_guid = self.query_one("#del_bookmark_guid", Input).value.strip() - if not input_guid: - self.notify("You must provide the GUID of the bookmark you want to delete before you press the button!", - timeout=10, - severity="warning") + """ The user wants to remove the highlighted bookmark""" + table = self.query_one("#my_bookmark_table", DataTable) + if table.row_count == 0: + self.notify("There is no bookmark to remove", timeout=5, severity="warning") return - self.app.delete_my_bookmark(input_guid) - self.dismiss(200) + guid = table.get_row_at(table.cursor_row)[2] + if self.app.delete_my_bookmark(guid): + self._reload() diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/SearchForTermScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/SearchForTermScreen.py index e871ed0e..5181a123 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/SearchForTermScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/SearchForTermScreen.py @@ -18,7 +18,7 @@ class SearchForTermScreen(ModalScreen): BINDINGS = [("q", "quit", "Quit"), - ("g", "go back", "Go back"), + ("g", "go_back", "Go back"), ] 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 e1a8c9cf..529cf4ce 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/ShopForDataScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/ShopForDataScreen.py @@ -6,6 +6,8 @@ """ +from typing import Any + from pyegeria import load_app_config, settings from textual import on from textual.app import ComposeResult @@ -21,7 +23,9 @@ class ShopForDataScreen(Screen): ("s", "sample_data_source", "Sample data source"), ("u", "subscribe_to_data_source", "Subscribe"), ("ctrl+s", "subscribe_to_data_source", "Subscribe"), - ("ctrl+b", "bookmarks", "Bookmark Selected Row"), + ("t", "search_for_term", "Search Glossary Terms"), + ("k", "bookmark_row", "Bookmark"), + ("ctrl+b", "bookmarks", "Bookmarks"), ("b", "back", "Go back")] CSS_PATH = "my_profile.tcss" @@ -195,27 +199,29 @@ def handle_row_highlighted(self, event: DataTable.RowHighlighted) -> None: self.data_table_highlighted = event.data_table.id self.log(f"Table {self.data_table_highlighted} Row highlighted: {self.row_highlighted}, cursor: {self.cursor_row_highlighted}") + def _highlighted_table_and_row(self) -> tuple[DataTable | None, Any]: + """ The focused (or last highlighted) table and the row under its cursor, if any """ + table = self.focused if isinstance(self.focused, DataTable) else None + if table is None and getattr(self, "data_table_highlighted", None): + table = self.query_one(f"#{self.data_table_highlighted}", DataTable) + if table is None or table.row_count == 0: + return table, None + return table, table.coordinate_to_cell_key(table.cursor_coordinate).row_key + def action_bookmarks(self) -> None: """ Manages BookMarks for the currently logged in user The GUID of the highlighted row, if any, is pre-filled as the target of a new bookmark""" - table = None - row_key = None - focused = getattr(self, "focused", None) - if isinstance(focused, DataTable): - table = focused - elif self.data_table_highlighted: - try: - table = self.query_one("#" + self.data_table_highlighted, DataTable) - except Exception: - table = None - if table is not None and 0 <= table.cursor_row < table.row_count: - try: - row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key - except Exception: - row_key = None - self.log(f"Bookmark from Table: {getattr(table, 'id', None)}, Row: {row_key}") + table, row_key = self._highlighted_table_and_row() self.app.show_my_bookmarks(self.app.get_row_guid(table, row_key)) + def action_bookmark_row(self) -> None: + """ Add the element in the highlighted row to the user's bookmarks """ + table, row_key = self._highlighted_table_and_row() + if row_key is None: + self.notify("Please highlight a row to bookmark.", timeout=5, severity="warning") + return + self.app.bookmark_table_row(table, row_key) + def action_back(self) -> None: """ The back option in the footer has been selected. Dismiss the screen.""" self.dismiss([200]) diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/SplashScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/SplashScreen.py index c77bbf25..bab79a2d 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/SplashScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/SplashScreen.py @@ -19,7 +19,8 @@ class SplashScreen(ModalScreen): """ SplashScreen for my_profile_app """ - BINDINGS = [("escape", "app.pop_screen", "Exit")] + # Escape must dismiss (not pop) the screen, so that MyProfileApp.mainline still runs + BINDINGS = [("escape", "continue_to_app", "Continue")] CSS_PATH = "my_profile.tcss" @@ -61,6 +62,9 @@ def compose(self) -> ComposeResult: # yield Static(classes="empty") yield Footer() + def action_continue_to_app(self): + self.dismiss() + @on(Button.Pressed, "#continue") def handle_continue(self): self.dismiss() diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/TechnologyTypeScreens.py b/my_egeria/my_egeria/DemoCode/My_Profile/TechnologyTypeScreens.py index badd9bce..fd5c4309 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/TechnologyTypeScreens.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/TechnologyTypeScreens.py @@ -22,7 +22,7 @@ class TechnologyTypeOptionsScreen(ModalScreen): """ Modal screen to display a technology type's templates and processes.""" BINDINGS = [("q", "dismiss", "Quit"), - ("b", "back", "Go_Back"), + ("b", "go_back", "Go_Back"), # ("ctl+s", "Select", "tech_type_option_select") ] @@ -262,8 +262,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", "back", "Go back"), - ("ctl+e", "expand", "Toggle Twisties")] + ("b", "quit", "Go back")] CSS_PATH = "my_profile.tcss" @@ -292,6 +291,9 @@ def __init__( self.tech_type_processes = tech_type_processes self.full_process = None self.selected_process = None + self.selected_t_process = None + # Input widget id -> the request parameter name it collects + self.parameter_names: dict[str, str] = {} async def on_mount(self) -> None: """ On Mount function of the Technology_Type_Templatess screen.""" @@ -353,6 +355,8 @@ async def on_mount(self) -> None: placeholder_text.styles.height = 8 placeholder_input = Input(id=f"{safe_name}_placeholder_input", placeholder="Enter value here") + # Remember the real parameter name: the CSS-safe id can't be turned back into it + self.parameter_names[placeholder_input.id] = name or safe_name self.log(f"Placeholder: {placeholder_text.text}\n {placeholder_input}") # Mount the TextArea and the associated Input field into the ScrollableContainer @@ -390,7 +394,7 @@ def handle_submit_button_pressed(self, event: Button.Pressed) -> None: save_input_data:dict = {} for input_widget in self.query("Input"): self.log(f"Input widget: {input_widget.id}, value: {input_widget.value}") - save_input_data.update({input_widget.id: input_widget.value}) + save_input_data.update({self.parameter_names.get(input_widget.id, input_widget.id): input_widget.value}) self.log(f"Save input data: {save_input_data}") self.dismiss(["input", save_input_data, self.full_process]) @@ -404,7 +408,7 @@ class TechnologyTypesScreen(ModalScreen): """Modal screen to display technology types in Egeria.""" BINDINGS = [("q", "dismiss(200)", "Quit"), ("g", "go_back", "Back"), - ("ctl+e", "expand", "Toggle Twisties")] + ("ctrl+e", "expand", "Toggle Twisties")] CSS_PATH = "my_profile.tcss" @@ -540,8 +544,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", "back", "Go back"), - ("ctl+e", "expand", "Toggle Twisties")] + ("b", "quit", "Go back")] CSS_PATH = "my_profile.tcss" diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/ViewSubscriptionsScreen.py b/my_egeria/my_egeria/DemoCode/My_Profile/ViewSubscriptionsScreen.py index 4c589cfe..35583493 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/ViewSubscriptionsScreen.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/ViewSubscriptionsScreen.py @@ -2,31 +2,24 @@ PDX-License-Identifier: Apache-2.0 Copyright Contributors to the ODPi Egeria project. - This file provides a user screen to allow the user to add todos to my_egeria. + This file provides a screen listing the current user's digital subscriptions. """ -import pwd -from datetime import datetime -from typing import Any - -import optional -from textual import on from textual.app import ComposeResult -from textual.containers import ScrollableContainer, Horizontal +from textual.containers import ScrollableContainer from textual.screen import ModalScreen -from textual.widgets import DataTable, OptionList, Header, Static, Footer, Input, Button, Switch -from textual.widgets._option_list import Option +from textual.widgets import DataTable, Header, Static, Footer from pyegeria import Egeria, PyegeriaException, load_app_config, settings, print_basic_exception +from profile_utils import element_summary class ViewSubscriptionsScreen(ModalScreen): - """View Subscription Screen for the current user in My Profile App.""" + """View the digital subscriptions created by the current user.""" BINDINGS = [ - ("q", "app.quit", "Quit"), - ("ctrl+a", "add_new_todo", "Add New Todo") + ("q", "quit", "Quit"), ] CSS_PATH = "my_profile.tcss" @@ -44,40 +37,47 @@ def __init__(self, *args, **kwargs): def compose(self) -> ComposeResult: yield Header(show_clock=True) yield ScrollableContainer( - Static("View Subscriptions for logged in User", id="view_subsciptions_static"), + Static("Your digital subscriptions. Press q to return.", id="view_subsciptions_static"), + DataTable(id="subscriptions_table", zebra_stripes=True, cursor_type="row"), id="view-subscriptions_container", ) yield Footer() def on_mount(self): - - 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) # uses env vars; or pass (user, password) explicitly - + table = self.query_one("#subscriptions_table", DataTable) + table.add_columns("Name", "Status", "Description", "GUID") + for row in self.get_my_subscriptions(): + table.add_row(*row) + if table.row_count == 0: + self.notify("You have no digital subscriptions", timeout=5) + + def get_my_subscriptions(self) -> list[tuple[str, str, str, str]]: + """(name, status, description, GUID) for each DigitalSubscription the user created.""" + eclient = None try: - # --- API call (show at minimum the required params; document optional ones) --- + eclient = Egeria(self.view_server, self.platform_url, self.user_name, self.user_password) + eclient.create_egeria_bearer_token(self.user_name, self.user_password) subscriptions = eclient.find_collections( search_string="*", - metadata_element_type="DigitalSubscription" + metadata_element_type_name="DigitalSubscription", + output_format="JSON", ) - - # --- Output rendering --- - if isinstance(subscriptions, list): - self.log(f"Found {len(subscriptions)} subscription items") - self.log(f"Subscriptions: {subscriptions}") - elif isinstance(subscriptions, str): - self.log(f"Response from get subscriptions: {subscriptions}") - self.notify(f"Response from get subscriptions: {subscriptions}") - else: - self.log(f"Unrecognized Response from get subscriptions: {subscriptions}") - self.notify("Unrecognized Response from get subscriptions: {subscriptions}") - except PyegeriaException as e: print_basic_exception(e) - self.notify(f"Pyegeria error response from get subscriptions: {e}") + self.notify(f"Could not retrieve your subscriptions: {e}", timeout=10, severity="error") + return [] finally: - eclient.close_session() \ No newline at end of file + if eclient: + eclient.close_session() + + rows = [] + for element in subscriptions if isinstance(subscriptions, list) else []: + summary = element_summary(element) + if summary.get("created_by") != self.user_name or not summary.get("guid"): + continue + status = str((element.get("properties") or {}).get("contentStatus") or "") + rows.append((summary["name"], status, summary["description"], summary["guid"])) + return rows + + def action_quit(self): + self.dismiss(200) diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/bookmarks_handler.py b/my_egeria/my_egeria/DemoCode/My_Profile/bookmarks_handler.py new file mode 100644 index 00000000..e9bae699 --- /dev/null +++ b/my_egeria/my_egeria/DemoCode/My_Profile/bookmarks_handler.py @@ -0,0 +1,213 @@ +""" + PDX-License-Identifier: Apache-2.0 + Copyright Contributors to the ODPi Egeria project. + + Bookmarks handler mixin for My Profile Textual App. + + pyegeria has no favourites API, so a user's bookmarks are the members of a + private collection with a fixed qualified name (see bookmarks_qualified_name). + The collection is created the first time the user bookmarks something. It is + anchored to the user's profile and linked from it by a ResourceList relationship + whose resourceUse is "Bookmarks", so MyProfile.get_my_resources() finds it. +""" + +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 MyBookMarksScreen import MyBookMarksScreen +from profile_utils import element_summary, row_identity + +BOOKMARKS_DISPLAY_NAME = "My Bookmarks" +BOOKMARKS_RESOURCE_USE = "Bookmarks" + + +def bookmarks_qualified_name(user_name: str) -> str: + """Qualified name of the collection holding a user's bookmarks.""" + return f"Bookmarks::{user_name}" + + +class BookmarksMixin: + """Mixin class providing the bookmarks feature for MyProfileApp.""" + + def _bookmarks_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 _find_bookmarks_collection(self, client: Egeria) -> str | None: + """GUID of the user's bookmarks collection, or None if it doesn't exist yet.""" + qualified_name = bookmarks_qualified_name(self.user_name) + response = client.get_collections_by_name(name=qualified_name, output_format="JSON") + for element in response if isinstance(response, list) else []: + summary = element_summary(element) + if summary.get("qualified_name") == qualified_name and summary.get("guid"): + return summary["guid"] + return None + + def _bookmarks_profile_guid(self, client: Egeria) -> str: + """GUID of the user's profile, which owns the bookmarks collection.""" + if getattr(self, "user_GUID", ""): + return self.user_GUID + profile = client.get_my_profile(output_format="JSON", graph_query_depth=0) + if isinstance(profile, list) and profile: + profile = profile[0] + guid = element_summary(profile).get("guid", "") + if not guid: + raise ValueError(f"Unable to find the profile for {self.user_name} to attach bookmarks to") + self.user_GUID = guid + return guid + + def _get_or_create_bookmarks_collection(self, client: Egeria) -> str: + guid = self._find_bookmarks_collection(client) + if guid: + return guid + profile_guid = self._bookmarks_profile_guid(client) + body = { + "class": "NewElementRequestBody", + "anchorGUID": profile_guid, + "isOwnAnchor": False, + "parentGUID": profile_guid, + "parentRelationshipTypeName": "ResourceList", + "parentAtEnd1": True, + "parentRelationshipProperties": { + "class": "ResourceListProperties", + "resourceUse": BOOKMARKS_RESOURCE_USE, + "resourceUseDescription": f"Private bookmarks for {self.user_name}", + }, + "properties": { + "class": "CollectionProperties", + "qualifiedName": bookmarks_qualified_name(self.user_name), + "displayName": BOOKMARKS_DISPLAY_NAME, + "description": f"Elements bookmarked by {self.user_name} in My Profile", + "category": BOOKMARKS_RESOURCE_USE, + } + } + guid = client.create_collection(body=body) + self.log(f"Created bookmarks collection {guid}") + return guid + + def _bookmark_rows(self, client: Egeria, collection_guid: str) -> list[tuple[str, str, str]]: + response = client.get_collection_members(collection_guid=collection_guid, output_format="JSON") + rows = [] + for element in response if isinstance(response, list) else []: + summary = element_summary(element) + if summary.get("guid"): + rows.append((summary["name"], summary["type"], summary["guid"])) + return rows + + def list_my_bookmarks(self) -> list[tuple[str, str, str]] | None: + """The user's bookmarks as (name, type, GUID) rows, or None if Egeria could not be reached.""" + client = None + try: + client = self._bookmarks_client() + collection_guid = self._find_bookmarks_collection(client) + if not collection_guid: + return [] + return self._bookmark_rows(client, collection_guid) + except PyegeriaException as e: + self.log(f"Retrieving bookmarks failed: {e}") + self.notify(f"Could not retrieve your bookmarks: {e}", timeout=10, severity="error") + return None + finally: + if client: + client.close_session() + + def show_my_bookmarks(self, target_guid: str | None = None) -> None: + """Show the bookmarks screen for the current user. + target_guid, if supplied, is pre-filled as the GUID of a new bookmark.""" + bookmarks = self.list_my_bookmarks() + if bookmarks is None: + return + self.push_screen(MyBookMarksScreen(bookmarks, target_guid=target_guid), callback=self.my_bookmarks_callback) + + def my_bookmarks_callback(self, result: Any) -> None: + self.log(f"Bookmarks screen returned: {result}") + + def add_my_bookmark(self, target_guid: str) -> bool: + """Bookmark the element with the given GUID. Returns True on success.""" + client = None + try: + client = self._bookmarks_client() + collection_guid = self._get_or_create_bookmarks_collection(client) + if target_guid in {row[2] for row in self._bookmark_rows(client, collection_guid)}: + self.notify("That element is already bookmarked", timeout=10, severity="warning") + return False + client.add_to_collection(collection_guid=collection_guid, element_guid=target_guid) + self.notify(f"Bookmarked {target_guid}", timeout=10, severity="information") + return True + except (PyegeriaException, ValueError) as e: + self.log(f"Adding bookmark {target_guid} failed: {e}") + self.notify(f"Adding the bookmark failed: {e}", timeout=10, severity="error") + return False + finally: + if client: + client.close_session() + + def get_row_guid(self, table: Any, row_key: Any) -> str | None: + """GUID of the element shown in a table row, or None if the row doesn't identify one. + + Read from the row's GUID column; rows with only a Qualified Name column + (e.g. Shop for Data's glossaries) are resolved to a GUID through Egeria. + """ + if table is None or row_key is None: + return None + guid, qualified_name = row_identity(table, row_key) + if guid or not qualified_name: + return guid or None + client = None + try: + client = self._bookmarks_client() + resolved = client.get_guid_for_name(qualified_name) + # "No elements found" is returned, rather than an exception, when nothing matches + return resolved if isinstance(resolved, str) and resolved and "No " not in resolved else None + except PyegeriaException as e: + self.log(f"Looking up the GUID for {qualified_name} failed: {e}") + return None + finally: + if client: + client.close_session() + + def bookmark_table_row(self, table: Any, row_key: Any) -> bool: + """Bookmark the element shown in a table row, found by its GUID column. + + Rows with only a Qualified Name column (e.g. Shop for Data's glossaries) + are resolved to a GUID first. Returns True if the bookmark was added. + """ + guid = self.get_row_guid(table, row_key) + if not guid: + self.notify("That row doesn't identify an Egeria element, so it can't be bookmarked", + timeout=10, severity="warning") + return False + return self.add_my_bookmark(guid) + + def delete_my_bookmark(self, target_guid: str) -> bool: + """Remove the element with the given GUID from the user's bookmarks. Returns True on success.""" + client = None + try: + client = self._bookmarks_client() + collection_guid = self._find_bookmarks_collection(client) + if not collection_guid: + self.notify("You have no bookmarks to remove", timeout=10, severity="warning") + return False + client.remove_from_collection(collection_guid=collection_guid, element_guid=target_guid) + self.notify(f"Removed bookmark {target_guid}", timeout=10, severity="information") + return True + except PyegeriaException as e: + self.log(f"Removing bookmark {target_guid} failed: {e}") + self.notify(f"Removing the bookmark failed: {e}", timeout=10, severity="error") + return False + finally: + if client: + client.close_session() 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 b8cc2123..336e1dbf 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 @@ -284,62 +284,58 @@ async def add_to_tables(self, selected_table: str, selected_row: Any) -> None: await self.push_screen(screen_cls(self.selected_table, self.user_GUID), callback=getattr(self, callback_name)) - def add_role_callback(self, result: Any) -> None: - """Called from AddRoleScreen when user adds a role.""" - if result: - self.log(f"Added role {result}") - self.switch_screen("main") + def _after_add(self, kind: str, result: Any) -> None: + """Return to the main screen once an Add screen closes, and re-read the profile. + + Popping back to main (rather than returning to the edit screen the Add screen + was opened from) also drops that edit screen's now-stale copy of the rows. + """ + self.log(f"Add {kind} screen closed with: {result}") + self._show_main_screen() + self.call_later(self.action_refresh) def add_association_callback(self, result: Any) -> None: - """Called from AddAssociationScreen when user adds an association.""" - if result: - self.log(f"Added association {result}") - self.switch_screen("main") + """Called from AddAssociationScreen with the kind of association to add.""" + self.log(f"Add association chose: {result}") + if result == "project": + self.push_screen(AddProjectScreen("projects_table", self.user_GUID), + callback=self.add_project_callback) + elif result == "community": + self.push_screen(AddCommunityScreen("communities_table", self.user_GUID), + callback=self.add_community_callback) + + def add_role_callback(self, result: Any) -> None: + """Called when AddRoleScreen closes.""" + self._after_add("role", result) def add_project_callback(self, result: Any) -> None: - """Called from AddProjectScreen when user adds a project.""" - if result: - self.log(f"Added project {result}") - self.switch_screen("main") + """Called when AddProjectScreen closes.""" + self._after_add("project", result) def add_community_callback(self, result: Any) -> None: - """Called from AddCommunityScreen when user adds a community.""" - if result: - self.log(f"Added community {result}") - self.switch_screen("main") + """Called when AddCommunityScreen closes.""" + self._after_add("community", result) def add_team_callback(self, result: Any) -> None: - """Called from AddTeamScreen when user adds a team.""" - if result: - self.log(f"Added team {result}") - self.switch_screen("main") + """Called when AddTeamScreen closes.""" + self._after_add("team", result) def add_blog_entry_callback(self, result: Any) -> None: - """Called from AddBlogEntryScreen when user adds a blog entry.""" - if result: - self.log(f"Added blog entry {result}") - self.switch_screen("main") + """Called when AddBlogEntryScreen closes.""" + self._after_add("blog entry", result) def add_journal_entry_callback(self, result: Any) -> None: - """Called from AddJournalEntryScreen when user adds a journal entry.""" - if result: - self.log(f"Added journal entry {result}") - self.switch_screen("main") + """Called when AddJournalEntryScreen closes.""" + self._after_add("journal entry", result) def add_todo_callback(self, result: Any) -> None: - """Called from AddTodoScreen when user adds a todo.""" - if result: - self.log(f"Added todo {result}") - self.switch_screen("main") + """Called when AddTodoScreen closes.""" + self._after_add("todo", result) def add_collection_callback(self, result: Any) -> None: - """Called from AddCollectionScreen when user adds a collection.""" - if result: - self.log(f"Added collection {result}") - self.switch_screen("main") + """Called when AddCollectionScreen closes.""" + self._after_add("collection", result) def add_user_identity_callback(self, result: Any) -> None: - """Called from AddUserIdentityScreen when user adds a user identity.""" - if result: - self.log(f"Added user identity {result}") - self.switch_screen("main") + """Called when AddUserIdentityScreen closes.""" + self._after_add("user identity", result) 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 96ecd1c7..5793f19d 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/my_profile.tcss +++ b/my_egeria/my_egeria/DemoCode/My_Profile/my_profile.tcss @@ -102,7 +102,7 @@ ModalScreen > Header, ModalScreen > Footer { #feedback_container, #feedback_log_container, #main_identities_container { - column-span: 3; + column-span: 2; width: 100%; height: 100%; } 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 d90c6a36..dc019d49 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 @@ -10,8 +10,6 @@ from typing import Any import asyncio -from textual.containers import ScrollableContainer - # Add the project root to sys.path to allow running this script from any directory root_path = Path(__file__).resolve().parents[4] if str(root_path) not in sys.path: @@ -81,16 +79,19 @@ bools_to_strings, extract_glossary_terms, check_request_serialization, + element_summary, ) from tech_types_handler import TechTypesMixin from shop_for_data_handler import ShopForDataMixin from team_roles_handler import TeamRolesMixin from elements_crud_handler import ElementsCrudMixin from feedback_handler import FeedbackMixin, FEEDBACK_LOG_OWNER +from bookmarks_handler import BookmarksMixin, bookmarks_qualified_name from MyBookMarksScreen import MyBookMarksScreen -class MyProfileApp(App, TechTypesMixin, ShopForDataMixin, TeamRolesMixin, ElementsCrudMixin, FeedbackMixin): +class MyProfileApp(App, TechTypesMixin, ShopForDataMixin, TeamRolesMixin, ElementsCrudMixin, FeedbackMixin, + BookmarksMixin): """My Profile App. Retrieves a user's profile from Egeria and displays current work items. @@ -232,6 +233,10 @@ async def mainline(self, splash_return: Any = None) -> None: self._load_task.cancel() self.user_name = splash_return[0] self.user_password = splash_return[1] + # Screens read the user from pyegeria's (cached) settings, so record the + # switch there too, or their Egeria calls would still run as the old user + settings.User_Profile.user_name = self.user_name + settings.User_Profile.user_pwd = self.user_password self.refresh_bindings() await self._load_or_create_profile() else: @@ -361,6 +366,8 @@ def _process_profile_data(self, profile_struct: list[dict]) -> None: self.log(f"my_todos_data: {self.my_todos_data}") + self.my_collections = self._load_my_collections() + # User GUID — resolve self-scoped self.user_GUID = "" if isinstance(self.user_profile, dict) and self.user_profile.get("GUID"): @@ -405,6 +412,25 @@ def _process_profile_data(self, profile_struct: list[dict]) -> None: else: self.user_identity = self.user_identities.get("User-Identities") or [] + def _load_my_collections(self) -> list[dict[str, str]]: + """Collections created by the current user, other than their bookmarks collection.""" + eclient = None + try: + eclient = Egeria(view_server=self.view_server, platform_url=self.platform_url, + user_id=self.user_name, user_pwd=self.user_password) + eclient.create_egeria_bearer_token(self.user_name, self.user_password) + response = eclient.find_collections(search_string="*", output_format="JSON") + except PyegeriaException as e: + self.log(f"Error retrieving My Collections: {e!s}") + return [] + finally: + if eclient: + eclient.close_session() + bookmarks_qn = bookmarks_qualified_name(self.user_name) + return [summary for summary in (element_summary(e) for e in (response if isinstance(response, list) else [])) + if summary.get("created_by") == self.user_name and summary.get("guid") + and summary.get("qualified_name") != bookmarks_qn] + def new_profile_return(self, result: int) -> None: """This function handles either the return from the create new profile screen or when the user already has a profile continue processing.""" @@ -526,6 +552,9 @@ def _populate_tables_sync(self) -> Any: self.my_collections_table.add_columns("Collection Name", "Collection Description", "Collection GUID") self.my_collections_table.zebra_stripes = True self.my_collections_table.cursor_type = "row" + for collection in getattr(self, "my_collections", []): + self.my_collections_table.add_row( + collection["name"], collection["description"], collection["guid"]) # Populate rows if self.projects_table: @@ -651,9 +680,18 @@ async def handle_option_selected(self, event: OptionList.OptionSelected) -> None await self.action_view_feedback_log() def status_callback(self, status_callback_rc: Any) -> None: - """Callback routine from the status screen.""" + """Callback routine from the status screen: the user has read the message, so carry on.""" self.log(f"Status screen returned: {status_callback_rc}") - self.exit(status_callback_rc) + self.show_main_screen() + + @on(DataTable.RowSelected, "#roles_table") + def _on_roles_row_selected(self, event: DataTable.RowSelected) -> None: + """Open the team roster for a TeamLeader/TeamMember role. + + Registered here rather than on TeamRolesMixin: Textual only collects @on + handlers from its own classes, so a decorator on the plain mixin never fires. + """ + self.handle_roles_table_row_selection(event) def show_main_screen(self) -> None: """Show or switch back to the main screen by unwinding the screen stack.""" @@ -728,178 +766,6 @@ def get_data_product_catalog_table(self) -> int: self.digital_product_catalog_table.loading=False return 200 - def get_row_guid(self, table: DataTable | None, row_key) -> str | None: - """ Return the GUID of the element in the given row of a DataTable, or None if there isn't one. - Uses any column whose heading ends in "GUID" (e.g. "GUID", "Collection GUID"); if the table - has no GUID column (e.g. the Glossary table) the row's Qualified Name is resolved via Egeria """ - if table is None or row_key is None: - return None - try: - columns = {column.label.plain: key for key, column in table.columns.items()} - for heading, key in columns.items(): - if heading.endswith("GUID"): - guid = str(table.get_cell(row_key, key) or "").strip() - self.log(f"GUID from table: {table.id}, row: {row_key}, GUID: {guid}") - return guid or None - if "Qualified Name" not in columns: - return None - qualified_name = str(table.get_cell(row_key, columns["Qualified Name"]) or "").strip() - except Exception as e: - self.log(f"Unable to retrieve GUID from table: {table.id}, row: {row_key}: {e}") - return None - if not qualified_name: - return None - eclient = None - try: - eclient = self._connect_to_egeria() - guid = eclient.get_element_guid_by_unique_name(qualified_name) - self.log(f"GUID for Qualified Name: {qualified_name}, GUID: {guid}") - # "No elements found" is returned, rather than an exception, when nothing matches - return guid if isinstance(guid, str) and guid and not guid.startswith("No ") else None - except Exception as e: - self.log(f"Unable to resolve GUID for Qualified Name: {qualified_name}: {e}") - return None - finally: - if eclient: - eclient.close_session() - - # Bookmarks are members of a private collection belonging to the user. The collection is - # anchored to the user's profile and linked to it by a ResourceList relationship whose - # resourceUse is "Bookmarks"; it is created the first time the user adds a bookmark. - BOOKMARKS_RESOURCE_USE = "Bookmarks" - - def _bookmarks_qualified_name(self) -> str: - return f"Bookmarks::{self.user_name}" - - def _connect_to_egeria(self) -> Egeria: - eclient = Egeria(self.view_server, self.platform_url, self.user_name, self.user_password) - eclient.create_egeria_bearer_token(self.user_name, self.user_password) - return eclient - - def _profile_guid(self, eclient: Egeria) -> str: - """ GUID of the logged on user's profile, retrieving it from Egeria if not already known """ - if not self.user_GUID: - profile = eclient.get_my_profile(output_format="JSON", graph_query_depth=0) - if isinstance(profile, list) and profile: - profile = profile[0] - if isinstance(profile, dict): - self.user_GUID = profile.get("elementHeader", {}).get("guid", "") - return self.user_GUID - - def _get_bookmarks_collection(self, eclient: Egeria, create: bool = False) -> str | None: - """ Return the GUID of the user's bookmarks collection. If it doesn't exist yet it is created - when create is True, otherwise None is returned """ - qualified_name = self._bookmarks_qualified_name() - guid = eclient.get_element_guid_by_unique_name(qualified_name) - # "No elements found" is returned, rather than an exception, when the collection doesn't exist - if isinstance(guid, str) and guid and not guid.startswith("No "): - return guid - if not create: - return None - - profile_guid = self._profile_guid(eclient) - if not profile_guid: - raise ValueError(f"Unable to find the profile for {self.user_name} to attach bookmarks to") - body = { - "class": "NewElementRequestBody", - "anchorGUID": profile_guid, - "isOwnAnchor": False, - "parentGUID": profile_guid, - "parentRelationshipTypeName": "ResourceList", - "parentAtEnd1": True, - "parentRelationshipProperties": { - "class": "ResourceListProperties", - "resourceUse": self.BOOKMARKS_RESOURCE_USE, - "resourceUseDescription": f"Private bookmarks for {self.user_name}", - }, - "properties": { - "class": "CollectionProperties", - "qualifiedName": qualified_name, - "displayName": f"Bookmarks for {self.user_name}", - "description": f"Elements bookmarked by {self.user_name} in My Profile", - "category": self.BOOKMARKS_RESOURCE_USE, - }, - } - guid = eclient.create_collection(body=body) - self.log(f"Created bookmarks collection {qualified_name}: {guid}") - return guid - - def _get_bookmarks(self, eclient: Egeria, collection_guid: str | None) -> list[list[str]]: - """ Return [display name, type, GUID] for each bookmarked element """ - if not collection_guid: - return [] - members = eclient.get_collection_members(collection_guid, output_format="JSON") - if not isinstance(members, list): - return [] - bookmarks = [] - for member in members: - header = member.get("elementHeader", {}) - properties = member.get("properties", {}) - bookmarks.append([ - properties.get("displayName") or properties.get("qualifiedName") or "", - header.get("type", {}).get("typeName", ""), - header.get("guid", ""), - ]) - return bookmarks - - def show_my_bookmarks(self, target_guid: str | None = None) -> None: - """ Access Egeria to retrieve all bookmarks for the current user - target_guid, if supplied, is pre-filled as the GUID of a new bookmark """ - eclient = None - try: - eclient = self._connect_to_egeria() - my_bookmarks = self._get_bookmarks(eclient, self._get_bookmarks_collection(eclient)) - self.log(f"Bookmarks for {self.user_name}: {my_bookmarks}") - except Exception as e: - self.log(f"Error retrieving bookmarks: {e}") - self.notify(f"An error occurred retrieving bookmarks from Egeria: {e}", severity="error", timeout=10) - return - finally: - if eclient: - eclient.close_session() - - self.push_screen(MyBookMarksScreen(my_bookmarks or None, target_guid=target_guid)) - - def add_my_bookmark(self, target_guid) -> None: - """ Add a bookmark for the user, input is the GUID of the item to bookmark """ - eclient = None - try: - eclient = self._connect_to_egeria() - collection_guid = self._get_bookmarks_collection(eclient, create=True) - existing = {bookmark[2] for bookmark in self._get_bookmarks(eclient, collection_guid)} - if target_guid in existing: - self.notify("That element is already bookmarked", severity="warning") - return - self.log(f"Adding {target_guid} to bookmarks collection {collection_guid}") - eclient.add_to_collection(collection_guid, target_guid) - self.notify(f"Successfully bookmarked element {target_guid}") - except Exception as e: - self.log(f"Error adding bookmark: {e}") - self.notify(f"Unable to add bookmark: {e}", severity="error", timeout=10) - finally: - if eclient: - eclient.close_session() - - def delete_my_bookmark(self, target_guid) -> None: - """ Delete a bookmark for the user, input is the GUID of the bookmarked element """ - eclient = None - try: - eclient = self._connect_to_egeria() - collection_guid = self._get_bookmarks_collection(eclient) - existing = {bookmark[2] for bookmark in self._get_bookmarks(eclient, collection_guid)} - if target_guid not in existing: - self.notify("That element is not bookmarked", severity="warning") - return - self.log(f"Removing {target_guid} from bookmarks collection {collection_guid}") - eclient.remove_from_collection(collection_guid, target_guid) - self.notify(f"Successfully deleted bookmark for element {target_guid}") - except Exception as e: - self.log(f"Error deleting bookmark: {e}") - self.notify(f"Unable to delete bookmark: {e}", severity="error", timeout=10) - finally: - if eclient: - eclient.close_session() - def add_comment(self, table_name, table_row): self.table_name = table_name self.table_row = table_row @@ -954,15 +820,15 @@ def add_comment(self, table_name, table_row): def add_comment_callback(self, comment_content): """ Process feedback from thew add comment functions and then return to main screen""" # If the return is an integer, then either success or failure, return to main screen - self.log(f"Return from Add Comment Screen: {comment_content}, length: {len(comment_content)}") - if isinstance(comment_content, int): - self.switch_screen(MainScreen()) + self.log(f"Return from Add Comment Screen: {comment_content}") + # An integer return is cancel/quit (200) or no element selected (400); the + # main screen is already showing once AddCommentScreen has dismissed + if not isinstance(comment_content, (list, tuple)): return # Check that all required data has been returned (Comment, Type, and the GUID of the element the comment applies to) if len(comment_content) != 3: self.notify("Return from Add Comment Screen incomplete, please retry", severity="error", timeout=30) self.log(f"Return from Add Comment Screen: {comment_content}, length: {len(comment_content)}") - self.switch_screen(MainScreen()) return # We have 3 return data items so extract them to individual variables self.comment = comment_content[0] @@ -972,9 +838,9 @@ def add_comment_callback(self, comment_content): if not self.comment or not self.type or not self.element_guid: self.notify("A row must be selected and Both comment text and comment type are required!") self.log(f"One or more element missing: {comment_content}, length: {len(comment_content)}") - self.switch_screen(MainScreen()) return # We have all the required data, then try to create the Egeria client + eclient = None try: eclient = Egeria(self.view_server, self.platform_url, @@ -1000,16 +866,13 @@ def add_comment_callback(self, comment_content): ) self.log(f"Comment successfully added! New Comment GUID: {response}") self.notify(f"Comment successfully added! New Comment GUID: {response}") - container = (self.query_one("#show_comments_container", ScrollableContainer)) - container.remove_children() except Exception as e: self.log(f"Failed to add comment: {e}") - self.notify(f"Failed to add comment: {e} \n Please try again.") + self.notify(f"Failed to add comment: {e} \n Please try again.", severity="error") return finally: if eclient: eclient.close_session() - self.switch_screen(MainScreen()) return(200) # Compatibility wrappers delegating to profile_utils @@ -1038,6 +901,10 @@ async def get_guid_for_qualified_name(self, qname: str) -> str: eclient.close_session() return guid +def main() -> None: + """Entry point for the my_profile console script.""" + MyProfileApp().run() + + if __name__ == "__main__": - app = MyProfileApp() - app.run() + main() diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/profile_utils.py b/my_egeria/my_egeria/DemoCode/My_Profile/profile_utils.py index 1d4f804b..2443c13b 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/profile_utils.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/profile_utils.py @@ -116,3 +116,47 @@ def extract_glossary_terms(text: str) -> list[str]: pattern = r"GlossaryTerm::([^,\']+)" matches = re.findall(pattern, text) return [match.strip() for match in matches] + + +def element_summary(element: Any) -> dict[str, str]: + """Flatten a raw (JSON-format) Egeria element into the fields the app's tables show. + + Accepts either an element itself or a relationship result that wraps one in + 'relatedElement' (as collection-member queries may return). + """ + if not isinstance(element, dict): + return {} + inner = element.get("relatedElement") if isinstance(element.get("relatedElement"), dict) else element + header = inner.get("elementHeader") or {} + props = inner.get("properties") or {} + versions = header.get("versions") or {} + element_type = header.get("type") or {} + return { + "guid": str(header.get("guid") or ""), + "name": str(props.get("displayName") or props.get("name") or props.get("qualifiedName") or ""), + "description": str(props.get("description") or ""), + "qualified_name": str(props.get("qualifiedName") or ""), + "type": str(element_type.get("typeName") or ""), + "created_by": str(versions.get("createdBy") or ""), + } + + +def row_identity(table: Any, row_key: Any) -> tuple[str, str]: + """(GUID, qualified name) of a DataTable row, read from its columns; either may be "". + + A GUID column is one labelled "GUID" or ending " GUID" (e.g. "Collection GUID"). + """ + guid = qualified_name = "" + try: + values = table.get_row(row_key) + except Exception: + return "", "" + for index, column in enumerate(table.columns.values()): + if index >= len(values): + break + label = column.label.plain.strip() + if not guid and (label == "GUID" or label.endswith(" GUID")): + guid = str(values[index] or "").strip() + elif not qualified_name and label == "Qualified Name": + qualified_name = str(values[index] or "").strip() + return guid, qualified_name diff --git a/my_egeria/my_egeria/DemoCode/My_Profile/shop_for_data_handler.py b/my_egeria/my_egeria/DemoCode/My_Profile/shop_for_data_handler.py index 9d515fac..14d54922 100644 --- a/my_egeria/my_egeria/DemoCode/My_Profile/shop_for_data_handler.py +++ b/my_egeria/my_egeria/DemoCode/My_Profile/shop_for_data_handler.py @@ -6,6 +6,7 @@ """ import sys +import time import asyncio from pathlib import Path from typing import Any @@ -916,33 +917,65 @@ def create_subscription_callback(self, result: Any) -> None: or "" ) if isinstance(result, dict) else (self.selected_item if hasattr(self, "selected_item") else "") + self._create_subscription(display_name, description, identifier, status, item_guid) + + def _create_subscription(self, display_name: str, description: str, identifier: str, + status: str, item_guid: str) -> str | None: + """Create a digital subscription to item_guid, owned by the current user. + + The subscription is linked to the item (AgreementItem) and to the user's profile + (DigitalSubscriber). Returns the subscription GUID, or None if it couldn't be created. + """ + display_name = display_name or f"Subscription to {item_guid}" body = { "class": "NewElementRequestBody", "isOwnAnchor": True, - "anchorScopeGUID": None, - "parentGUID": None, - "parentRelationshipTypeName": "CollectionMembership", - "parentAtEnd1": False, "properties": { "class": "DigitalSubscriptionProperties", - "qualifiedName": "DigitalSubscription::" + display_name, - "displayName": display_name or "display name", + "qualifiedName": f"DigitalSubscription::{self.user_name}::{display_name}::{int(time.time())}", + "displayName": display_name, "description": description, - "contentStatus": "ACTIVE", + "contentStatus": status or "DRAFT", "identifier": identifier, "supportLevel": "Community" } } - + s_client = None try: s_client = ProductManager(self.view_server, self.platform_url, self.user_name, self.user_password) s_client.create_egeria_bearer_token(self.user_name, self.user_password) - res = s_client.create_digital_subscription(body) - self.app.log(f"Created digital subscription successfully: {res}") - self.app.notify(f"Created digital subscription for {display_name or item_guid}") + subscription_guid = s_client.create_digital_subscription(body) + self.app.log(f"Created digital subscription successfully: {subscription_guid}") except Exception as e: self.app.log(f"Error creating digital subscription in callback: {e}") - self.app.notify(f"Error creating digital subscription: {e}") + self.app.notify(f"Error creating digital subscription: {e}", severity="error") + if s_client: + s_client.close_session() + return None + + problems = [] + try: + if item_guid: + s_client.link_agreement_item(agreement_guid=subscription_guid, agreement_item_guid=item_guid) + else: + problems.append("no item was selected") + user_guid = getattr(self, "user_GUID", "") + if user_guid: + s_client.link_subscriber(subscriber_guid=user_guid, subscription_guid=subscription_guid) + else: + problems.append("your profile GUID is unknown") + except Exception as e: + self.app.log(f"Error linking digital subscription {subscription_guid}: {e}") + problems.append(str(e)) + finally: + s_client.close_session() + + if problems: + self.app.notify(f"Created digital subscription {display_name}, but could not link it: " + f"{'; '.join(problems)}", severity="warning") + else: + self.app.notify(f"Created digital subscription {display_name}") + return subscription_guid async def request_to_subscribe_data_source( self, @@ -1174,15 +1207,9 @@ async def generic_data_view_callback(self, result: Any) -> None: else (result[1] if len(result) > 1 and result[1] else self.selected_item) ) self.app.log(f"Subscribing to data element from sample view: {element_qname}") - try: - s_client = ProductManager(self.view_server, self.platform_url, self.user_name, self.user_password) - s_client.create_egeria_bearer_token(self.user_name, self.user_password) - s_client.create_digital_subscription(element_qname) - self.app.notify(f"Created digital subscription for {element_qname}") - except Exception as e: - self.app.log(f"Error creating digital subscription: {e}") - self.app.notify(f"Error creating digital subscription: {e}") - await self.app.push_screen(CreateSubscriptionRequestScreen(self.selected_item), callback=self.create_subscription_callback) + # Same request form as the other subscribe paths, so the subscription is named and linked + await self.app.push_screen(CreateSubscriptionRequestScreen(self.selected_item), + callback=self.create_subscription_callback) elif isinstance(result, int) and result == 210: self.show_main_screen() else: 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 ca03f973..ad90ea20 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 @@ -18,12 +18,10 @@ if str(current_dir) not in sys.path: sys.path.append(str(current_dir)) -from textual import on from textual.widgets import DataTable, Static from textual.containers import ScrollableContainer from pyegeria import exec_report_spec, PyegeriaException, print_basic_exception, load_app_config, settings from MyTeamScreen import MyTeam -from ShopForDataScreen import ShopForDataScreen from StatusScreen import StatusScreen @@ -44,8 +42,11 @@ def __init__(self, *args, **kwargs) -> None: 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" - @on(DataTable.RowSelected, "#roles_table") def handle_roles_table_row_selection(self, event: DataTable.RowSelected) -> Any: + """Show the team roster for a selected TeamLeader/TeamMember role. + + Called from MyProfileApp's roles_table RowSelected handler. + """ role_table = event.data_table selected_row_key = event.row_key selected_row_data = role_table.get_row(selected_row_key) @@ -184,11 +185,9 @@ def search_for_term_callback(self, status: Any) -> None: self.log("Search for term screen completed successfully") self._show_main_screen() elif status == 201: - self.log("No matches found for search term") - self.push_screen( - ShopForDataScreen(), - callback=self.shop_for_data_callback, - ) + self.log("Search for term: back to Shop for Data") + # Reopen through the handler so the catalog tables are reloaded, not left empty + self.call_later(self.handle_shop_for_data_option) else: self.log(f"Error in Search for term screen: {status}") error_category = "Search for Term" 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 b1dec5d1..b4a66bb9 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 @@ -41,10 +41,12 @@ async def handle_technology_types_option(self) -> None: self.log(f"Tech Type Response: {self.tech_type_response} | {self.tech_type_list}") if self.tech_type_response == []: self.log("No technology types found.") - self.exit(200) + self.notify("No technology types are defined in Egeria", timeout=10, severity="warning") + return elif len(self.tech_type_response) == 3 and int(self.tech_type_response) >= 400: self.log("Error fetching technology types.") self.exit(int(self.tech_type_response)) + return self.log("Technology types fetched successfully.") self.log("Displaying technology types...") await self.push_screen( @@ -367,8 +369,11 @@ def tech_type_processes_callback(self, result: Any) -> Any: if isinstance(self.input_data, dict): for input_key, input_value in self.input_data.items(): self.log(f"Processing input value: {input_key} with value: {input_value}") - input_fix1 = input_key.replace("_", " ") - input_fix2 = input_fix1.replace(" placeholder input", "").replace(" process input", "") + # Keys are real parameter names; only a raw widget id needs converting back + input_fix2 = input_key + for suffix in ("_placeholder_input", "_process_input"): + if input_fix2.endswith(suffix): + input_fix2 = input_fix2[: -len(suffix)].replace("_", " ") self.log(f"Process input key after cleaning: {input_fix2}") my_process_property_values.append({ "class": "SupportedRequestParameter", @@ -389,19 +394,32 @@ def tech_type_processes_callback(self, result: Any) -> Any: self.log(f"Original input: {self.result}") self.log(f"full process: {self.full_process}") - additional_properties = self.full_process.get("additionalProperties", {}) if isinstance(self.full_process, dict) else {} - template_guid = additional_properties.get("templateGUID") if isinstance(additional_properties, dict) else None + process_qualified_name = self.full_process.get("qualifiedName") if isinstance(self.full_process, dict) else None + if not process_qualified_name: + self.push_screen(StatusScreen("Governance Action Process: the selected process has no qualified name, " + "so it cannot be started"), callback=self.status_callback) + return 418 + # Only send parameters the user actually filled in + request_parameters = {p["name"]: p["value"] for p in my_process_property_values if p["value"]} - request_body: dict = { - "displayName": self.full_process.get("displayName") if isinstance(self.full_process, dict) else None, - "description": self.full_process.get("description") if isinstance(self.full_process, dict) else None, - "additionalProperties": { - "templateGUID": template_guid - }, - "specification": { - "supportedRequestParameter": my_process_property_values - } - } + try: + tokendata = self.autoc.create_egeria_bearer_token(self.user_name, self.user_password) + client = AutomatedCuration(self.view_server, self.platform_url, self.user_name, self.user_password, tokendata) + process_guid = client.initiate_gov_action_process( + action_type_qualified_name=process_qualified_name, + request_parameters=request_parameters or None, + ) + except Exception as e: + self.log(f"Exception in initiate_gov_action_process: {e}") + message = print_basic_exception(e) if isinstance(e, PyegeriaException) else str(e) + self.push_screen(StatusScreen(f"Error starting governance action process\n{message}"), + callback=self.status_callback) + return 420 + + self.log(f"Started process {process_qualified_name}: {process_guid}") + self.push_screen(StatusScreen(f"Governance action process started with GUID: '{process_guid}'"), + callback=self.status_callback) + return 200 def tech_type_processes_details(self, tech_type: Any, selected_t_option_selected: Any) -> Any: """Retrieve process data for Technology Type Processes screen.""" diff --git a/tests/micro-tests/my_profile/test_bookmarks_handler.py b/tests/micro-tests/my_profile/test_bookmarks_handler.py new file mode 100644 index 00000000..18ff678a --- /dev/null +++ b/tests/micro-tests/my_profile/test_bookmarks_handler.py @@ -0,0 +1,292 @@ +""" + PDX-License-Identifier: Apache-2.0 + Copyright Contributors to the ODPi Egeria project. + + Unit tests for the bookmarks mixin and screen (bookmarks are members of a + personal collection). +""" + +from unittest.mock import MagicMock, patch + +import pytest +from textual.app import App +from textual.widgets import DataTable, Input, Button + +from bookmarks_handler import BookmarksMixin, bookmarks_qualified_name, BOOKMARKS_DISPLAY_NAME +from MyBookMarksScreen import MyBookMarksScreen +from pyegeria import PyegeriaException + +BOOKMARKS_QN = bookmarks_qualified_name("garygeeke") + + +def _element(guid, name, type_name="Asset", qualified_name=None): + return {"elementHeader": {"guid": guid, "type": {"typeName": type_name}}, + "properties": {"displayName": name, "qualifiedName": qualified_name or f"{type_name}::{name}"}} + + +BOOKMARKS_COLLECTION = _element("bm-coll-guid", BOOKMARKS_DISPLAY_NAME, "Collection", BOOKMARKS_QN) + + +class DummyApp(BookmarksMixin): + 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("bookmarks_handler.Egeria") as mock_egeria: + yield mock_egeria.return_value + + +class TestBookmarksMixin: + def test_list_without_collection_is_empty(self, client): + client.get_collections_by_name.return_value = "No elements found" + assert DummyApp().list_my_bookmarks() == [] + client.get_collection_members.assert_not_called() + client.close_session.assert_called_once() + + def test_list_returns_member_rows(self, client): + client.get_collections_by_name.return_value = [BOOKMARKS_COLLECTION] + # Members may come back wrapped in relatedElement + client.get_collection_members.return_value = [ + _element("asset-1", "Sales Data", "DataSet"), + {"relatedElement": _element("asset-2", "Churn Model", "Asset")}, + ] + rows = DummyApp().list_my_bookmarks() + client.get_collection_members.assert_called_once_with(collection_guid="bm-coll-guid", output_format="JSON") + assert rows == [("Sales Data", "DataSet", "asset-1"), ("Churn Model", "Asset", "asset-2")] + + def test_list_ignores_same_name_collection_with_other_qualified_name(self, client): + client.get_collections_by_name.return_value = [ + _element("other-guid", BOOKMARKS_DISPLAY_NAME, "Collection", "Bookmarks::erinoverview")] + assert DummyApp().list_my_bookmarks() == [] + + def test_list_error_returns_none(self, client): + client.get_collections_by_name.side_effect = PyegeriaException("boom") + app = DummyApp() + assert app.list_my_bookmarks() is None + assert app.notices[-1][1] == "error" + + def test_add_creates_collection_first_time(self, client): + client.get_collections_by_name.return_value = [] + client.create_collection.return_value = "new-coll-guid" + assert DummyApp().add_my_bookmark("asset-1") is True + body = client.create_collection.call_args.kwargs["body"] + assert body["properties"]["qualifiedName"] == BOOKMARKS_QN == "Bookmarks::garygeeke" + # Anchored to, and linked from, the user's profile so get_my_resources() finds it + assert body["anchorGUID"] == body["parentGUID"] == "profile-guid" + assert body["isOwnAnchor"] is False + assert body["parentRelationshipTypeName"] == "ResourceList" + assert body["parentRelationshipProperties"]["resourceUse"] == "Bookmarks" + client.add_to_collection.assert_called_once_with(collection_guid="new-coll-guid", element_guid="asset-1") + + def test_add_looks_up_profile_guid_when_not_known(self, client): + client.get_collections_by_name.return_value = [] + client.get_my_profile.return_value = [{"elementHeader": {"guid": "looked-up-profile"}, "properties": {}}] + app = DummyApp() + app.user_GUID = "" + assert app.add_my_bookmark("asset-1") is True + assert client.create_collection.call_args.kwargs["body"]["parentGUID"] == "looked-up-profile" + + def test_add_already_bookmarked_is_refused(self, client): + client.get_collections_by_name.return_value = [BOOKMARKS_COLLECTION] + client.get_collection_members.return_value = [_element("asset-1", "Sales Data", "DataSet")] + app = DummyApp() + assert app.add_my_bookmark("asset-1") is False + client.add_to_collection.assert_not_called() + assert app.notices[-1][1] == "warning" + + def test_add_reuses_existing_collection(self, client): + client.get_collections_by_name.return_value = [BOOKMARKS_COLLECTION] + assert DummyApp().add_my_bookmark("asset-1") is True + client.create_collection.assert_not_called() + client.add_to_collection.assert_called_once_with(collection_guid="bm-coll-guid", element_guid="asset-1") + + def test_add_failure_returns_false(self, client): + client.get_collections_by_name.return_value = [BOOKMARKS_COLLECTION] + client.add_to_collection.side_effect = PyegeriaException("bad guid") + assert DummyApp().add_my_bookmark("nope") is False + client.close_session.assert_called_once() + + def test_delete_removes_member(self, client): + client.get_collections_by_name.return_value = [BOOKMARKS_COLLECTION] + assert DummyApp().delete_my_bookmark("asset-1") is True + client.remove_from_collection.assert_called_once_with(collection_guid="bm-coll-guid", element_guid="asset-1") + + def test_delete_without_collection(self, client): + client.get_collections_by_name.return_value = [] + assert DummyApp().delete_my_bookmark("asset-1") is False + client.remove_from_collection.assert_not_called() + + def test_show_pushes_screen(self, client): + client.get_collections_by_name.return_value = [] + app = DummyApp() + app.show_my_bookmarks() + screen, callback = app.pushed[0] + assert isinstance(screen, MyBookMarksScreen) + assert callback == app.my_bookmarks_callback + + def test_show_passes_target_guid_to_screen(self, client): + client.get_collections_by_name.return_value = [] + app = DummyApp() + app.show_my_bookmarks(target_guid="asset-7") + assert app.pushed[0][0].target_guid == "asset-7" + + +class BookmarksHostApp(App): + """Host app standing in for MyProfileApp's bookmark methods.""" + + def __init__(self, rows): + super().__init__() + self.rows = list(rows) + self.target_guid = None + self.add_my_bookmark = MagicMock(side_effect=self._add) + self.delete_my_bookmark = MagicMock(side_effect=self._delete) + self.dismissed = None + + def _add(self, guid): + self.rows.append(("New", "Asset", guid)) + return True + + def _delete(self, guid): + self.rows = [r for r in self.rows if r[2] != guid] + return True + + def list_my_bookmarks(self): + return list(self.rows) + + async def on_mount(self): + self.screen_under_test = MyBookMarksScreen(list(self.rows), target_guid=self.target_guid) + await self.push_screen(self.screen_under_test, callback=lambda r: setattr(self, "dismissed", r)) + + +class TestMyBookMarksScreen: + @pytest.mark.asyncio + async def test_lists_bookmarks(self): + app = BookmarksHostApp([("Sales Data", "DataSet", "asset-1")]) + async with app.run_test() as pilot: + table = app.screen_under_test.query_one("#my_bookmark_table", DataTable) + assert table.row_count == 1 + assert table.get_row_at(0) == ["Sales Data", "DataSet", "asset-1"] + + @pytest.mark.asyncio + async def test_add_bookmark(self): + app = BookmarksHostApp([]) + async with app.run_test() as pilot: + screen = app.screen_under_test + screen.action_new_bookmark() + await pilot.pause() + screen.query_one("#add_bookmark_guid", Input).value = "asset-9" + screen.query_one("#add_new_bookmark", Button).press() + await pilot.pause() + app.add_my_bookmark.assert_called_once_with("asset-9") + assert screen.query_one("#my_bookmark_table", DataTable).row_count == 1 + + @pytest.mark.asyncio + async def test_add_bookmark_is_prefilled_with_target_guid(self): + app = BookmarksHostApp([]) + app.target_guid = "asset-5" + async with app.run_test() as pilot: + screen = app.screen_under_test + screen.action_new_bookmark() + await pilot.pause() + assert screen.query_one("#add_bookmark_guid", Input).value == "asset-5" + + @pytest.mark.asyncio + async def test_remove_highlighted_bookmark(self): + app = BookmarksHostApp([("A", "Asset", "asset-1"), ("B", "Asset", "asset-2")]) + async with app.run_test() as pilot: + screen = app.screen_under_test + table = screen.query_one("#my_bookmark_table", DataTable) + table.move_cursor(row=1) + screen.action_remove_bookmark() + await pilot.pause() + app.delete_my_bookmark.assert_called_once_with("asset-2") + assert table.row_count == 1 + + @pytest.mark.asyncio + async def test_quit(self): + app = BookmarksHostApp([]) + async with app.run_test() as pilot: + app.screen_under_test.action_quit() + await pilot.pause() + assert app.dismissed == 200 + + +class TableHostApp(App): + """Host app with a single DataTable, for row-identity and bookmark-row tests.""" + + def __init__(self, columns, rows): + super().__init__() + self.columns, self.rows = columns, rows + + def compose(self): + yield DataTable(id="t") + + def on_mount(self): + table = self.query_one("#t", DataTable) + table.add_columns(*self.columns) + for i, row in enumerate(self.rows): + table.add_row(*row, key=f"r{i}") + + +class TestBookmarkTableRow: + @pytest.mark.asyncio + async def test_row_identity_reads_guid_and_qualified_name_columns(self): + from profile_utils import row_identity + app = TableHostApp(["Collection Name", "Qualified Name", "Collection GUID"], + [("Clinical", "Collection::Clinical", "coll-1")]) + async with app.run_test(): + assert row_identity(app.query_one("#t", DataTable), "r0") == ("coll-1", "Collection::Clinical") + + @pytest.mark.asyncio + async def test_bookmarks_row_by_guid(self, client): + client.get_collections_by_name.return_value = [BOOKMARKS_COLLECTION] + app = TableHostApp(["Role Name", "Description", "GUID"], [("Steward", "Looks after data", "role-1")]) + async with app.run_test(): + assert DummyApp().bookmark_table_row(app.query_one("#t", DataTable), "r0") is True + client.get_guid_for_name.assert_not_called() + client.add_to_collection.assert_called_once_with(collection_guid="bm-coll-guid", element_guid="role-1") + + @pytest.mark.asyncio + async def test_bookmarks_row_by_qualified_name(self, client): + client.get_collections_by_name.return_value = [BOOKMARKS_COLLECTION] + client.get_guid_for_name.return_value = "gloss-1" + app = TableHostApp(["Glossary Name", "Description", "Qualified Name"], + [("Clinical Terms", "Terms", "Glossary::Clinical-Terms")]) + async with app.run_test(): + assert DummyApp().bookmark_table_row(app.query_one("#t", DataTable), "r0") is True + client.get_guid_for_name.assert_called_once_with("Glossary::Clinical-Terms") + client.add_to_collection.assert_called_once_with(collection_guid="bm-coll-guid", element_guid="gloss-1") + + @pytest.mark.asyncio + async def test_row_without_identity_is_refused(self, client): + app = TableHostApp(["Name", "Description"], [("Something", "No id here")]) + async with app.run_test(): + dummy = DummyApp() + assert dummy.bookmark_table_row(app.query_one("#t", DataTable), "r0") is False + client.add_to_collection.assert_not_called() + assert dummy.notices[-1][1] == "warning" + + @pytest.mark.asyncio + async def test_unresolvable_qualified_name_is_refused(self, client): + client.get_guid_for_name.return_value = "No elements found" + app = TableHostApp(["Glossary Name", "Qualified Name"], [("Gone", "Glossary::Gone")]) + async with app.run_test(): + assert DummyApp().bookmark_table_row(app.query_one("#t", DataTable), "r0") is False + client.add_to_collection.assert_not_called() diff --git a/tests/micro-tests/my_profile/test_elements_crud_handler.py b/tests/micro-tests/my_profile/test_elements_crud_handler.py index 9ef80859..2a81d31f 100644 --- a/tests/micro-tests/my_profile/test_elements_crud_handler.py +++ b/tests/micro-tests/my_profile/test_elements_crud_handler.py @@ -64,6 +64,12 @@ def push_screen(self, screen, callback=None): def switch_screen(self, screen_id): self.switched_screens.append(screen_id) + def call_later(self, callback, *args, **kwargs): + self.called_later = getattr(self, "called_later", []) + [callback] + + async def action_refresh(self): + pass + class TestElementsCrudMixinCallbacks: """Tests for ElementsCrudMixin callbacks and screen transitions.""" @@ -245,20 +251,40 @@ async def test_add_to_tables_unexpected(self): "callback_name", [ "add_role_callback", - "add_association_callback", "add_project_callback", "add_community_callback", "add_team_callback", "add_blog_entry_callback", "add_journal_entry_callback", "add_todo_callback", + "add_collection_callback", + "add_user_identity_callback", ], ) - def test_add_callbacks_switch_main(self, callback_name): + def test_add_callbacks_return_to_main_and_refresh(self, callback_name): + app = DummyApp() + getattr(app, callback_name)(200) + assert app.shown_main_screen is True + assert app.called_later == [app.action_refresh] + + @pytest.mark.parametrize( + "choice,expected_screen_cls,expected_callback", + [ + ("project", AddProjectScreen, "add_project_callback"), + ("community", AddCommunityScreen, "add_community_callback"), + ], + ) + def test_add_association_callback_opens_chosen_screen(self, choice, expected_screen_cls, expected_callback): + app = DummyApp() + app.add_association_callback(choice) + screen, callback = app.pushed_screens[0] + assert isinstance(screen, expected_screen_cls) + assert callback == getattr(app, expected_callback) + + def test_add_association_callback_quit(self): app = DummyApp() - cb = getattr(app, callback_name) - cb("new-guid-123") - assert "main" in app.switched_screens + app.add_association_callback(200) + assert app.pushed_screens == [] def test_show_comments_and_callback(self): app = DummyApp() diff --git a/tests/micro-tests/my_profile/test_generic_data_view_screen.py b/tests/micro-tests/my_profile/test_generic_data_view_screen.py index 4de2fb42..5a280394 100644 --- a/tests/micro-tests/my_profile/test_generic_data_view_screen.py +++ b/tests/micro-tests/my_profile/test_generic_data_view_screen.py @@ -319,24 +319,17 @@ def test_parse_tabular_data_set_report(self): assert rows[1][0] == "REC-2" assert "Value: Val-2" in rows[1][1] - @pytest.mark.live_capable @pytest.mark.asyncio - async def test_generic_data_view_callback_subscribe(self, backend): - """Calls create_digital_subscription — a real write when running live.""" - mock_pm_cls = backend.patch("shop_for_data_handler.ProductManager") - if not backend.live: - mock_pm = MagicMock() - mock_pm_cls.return_value = mock_pm - - app = DummyShopForDataApp(backend) + async def test_generic_data_view_callback_subscribe(self): + """Subscribe from the sample view opens the same request form as the other paths.""" + from CreateSubscriptionRequestScreen import CreateSubscriptionRequestScreen + app = DummyShopForDataApp() + app.selected_item = "guid-prod-1" + app.push_screen = AsyncMock() await app.generic_data_view_callback([211, "Product 1", "DP::Product1"]) - - if backend.live: - # The real client is built inside the handler, so only the class - # construction is observable through the wrapping mock. - assert mock_pm_cls.called - else: - mock_pm.create_digital_subscription.assert_called_once_with("DP::Product1") + screen = app.push_screen.call_args.args[0] + assert isinstance(screen, CreateSubscriptionRequestScreen) + assert app.push_screen.call_args.kwargs["callback"] == app.create_subscription_callback @pytest.mark.asyncio async def test_generic_data_view_callback_quit(self): 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 87f9545c..caf2f476 100644 --- a/tests/micro-tests/my_profile/test_my_profile_app.py +++ b/tests/micro-tests/my_profile/test_my_profile_app.py @@ -42,9 +42,31 @@ def stub_profile_client(backend, profile_data, identities, todos): identities, # for User-Identities lookup ] mock_mp.get_my_to_dos.return_value = todos + # The My Collections lookup goes through the Egeria facade, not MyProfile + mock_egeria = MagicMock() + mock_egeria.find_collections.return_value = SAMPLE_COLLECTIONS + backend.always_fake("my_profile_app.Egeria", returns=mock_egeria) return backend.always_fake("my_profile_app.MyProfile", returns=mock_mp) +def _collection(guid, name, created_by, qualified_name=None): + return { + "elementHeader": {"guid": guid, "type": {"typeName": "Collection"}, + "versions": {"createdBy": created_by}}, + "properties": {"displayName": name, "description": f"{name} description", + "qualifiedName": qualified_name or f"Collection::{name}"}, + } + + +# Raw (JSON-format) find_collections results: only the first is the user's own, +# non-bookmarks collection, so My Collections should show exactly one row. +SAMPLE_COLLECTIONS = [ + _collection("coll-guid-mine", "Clinical Trials", "garygeeke"), + _collection("coll-guid-other", "Someone Else's", "erinoverview"), + _collection("coll-guid-bookmarks", "My Bookmarks", "garygeeke", "Bookmarks::garygeeke"), +] + + class TestMyProfileAppLifecycle: """Tests for MyProfileApp lifecycle, initialization, and data loading.""" @@ -84,6 +106,8 @@ async def test_app_on_mount_success( main_screen = app.get_screen("main") for table_id, fake_rows in ( + ("#projects_table", 1), + ("#my_collections_table", 1), ("#roles_table", 1), ("#teams_table", 1), ("#todos_table", 1), @@ -127,6 +151,28 @@ async def test_app_on_mount_prompt_create_profile(self, backend): assert isinstance(app.screen, CreateProfileScreen) + @pytest.mark.asyncio + async def test_splash_escape_still_prompts_create_profile(self, backend): + """Escape on the splash screen must dismiss it (running mainline), not pop it.""" + mock_mp = MagicMock() + mock_mp.create_egeria_bearer_token.return_value = "token" + mock_mp._async_get_my_profile = AsyncMock(return_value=[]) + backend.always_fake("my_profile_app.MyProfile", returns=mock_mp) + + app = MyProfileApp() + async with app.run_test() as pilot: + for _ in range(10): + if isinstance(app.screen, SplashScreen): + break + await pilot.pause(0.1) + assert isinstance(app.screen, SplashScreen) + await pilot.press("escape") + for _ in range(50): + if isinstance(app.screen, CreateProfileScreen): + break + await pilot.pause(0.1) + assert isinstance(app.screen, CreateProfileScreen) + @pytest.mark.asyncio async def test_app_load_profile_exception_exits_402(self, backend): # Always faked: exercises the app's error handling, not the server's. @@ -211,10 +257,88 @@ async def test_handle_option_selected(self, option_name, handler_attr, screen_cl assert isinstance(call_args[0], screen_cls) def test_status_callback(self): + """Closing a status screen returns to the main screen; it must not end the app.""" app = MyProfileApp() app.exit = MagicMock() + app.show_main_screen = MagicMock() app.status_callback(200) - app.exit.assert_called_once_with(200) + app.exit.assert_not_called() + app.show_main_screen.assert_called_once() + + @pytest.mark.asyncio + async def test_change_user_updates_settings_for_screens(self): + """After Change User, screens that read pyegeria settings must see the new user.""" + from pyegeria import settings + from AddToElementsScreens import AddTodoScreen + saved = (settings.User_Profile.user_name, settings.User_Profile.user_pwd) + app = MyProfileApp() + app._load_or_create_profile = AsyncMock() + app.refresh_bindings = MagicMock() + try: + await app.mainline(["erinoverview", "erin-pwd"]) + assert (app.user_name, app.user_password) == ("erinoverview", "erin-pwd") + screen = AddTodoScreen("todos_table", "user-guid-123") + assert (screen.user_name, screen.user_password) == ("erinoverview", "erin-pwd") + app._load_or_create_profile.assert_awaited_once() + finally: + settings.User_Profile.user_name, settings.User_Profile.user_pwd = saved + + @pytest.mark.asyncio + async def test_ctrl_k_bookmarks_selected_row( + self, backend, sample_profile_data, sample_user_identities, sample_todos_data + ): + stub_profile_client(backend, sample_profile_data, sample_user_identities, sample_todos_data) + app = MyProfileApp() + app.bookmark_table_row = MagicMock(return_value=True) + async with app.run_test(size=(180, 50)) as pilot: + for _ in range(20): + if isinstance(app.screen, SplashScreen): + break + await pilot.pause(0.1) + app.screen.query_one("#continue", Button).press() + await pilot.pause(0.3) + main = app.get_screen("main") + table = main.query_one("#roles_table", DataTable) + table.focus() + await pilot.pause() + await pilot.press("ctrl+k") + await pilot.pause() + app.bookmark_table_row.assert_called_once() + called_table, row_key = app.bookmark_table_row.call_args.args + assert called_table is table and row_key is not None + + def test_roles_row_selection_is_registered(self): + """The roles_table RowSelected handler must be one Textual actually dispatches.""" + handlers = MyProfileApp._decorated_handlers.get(DataTable.RowSelected, []) + assert any(method.__name__ == "_on_roles_row_selected" for method, _ in handlers) + + def test_roles_row_selected_delegates_to_mixin(self): + app = MyProfileApp() + app.handle_roles_table_row_selection = MagicMock() + event = MagicMock() + app._on_roles_row_selected(event) + app.handle_roles_table_row_selection.assert_called_once_with(event) + + @pytest.mark.parametrize("returned", [200, 400, None]) + def test_add_comment_callback_ignores_cancel(self, returned): + """Cancel/quit/no-selection returns are not errors and make no Egeria call.""" + app = MyProfileApp() + with patch("my_profile_app.Egeria") as mock_egeria: + assert app.add_comment_callback(returned) is None + mock_egeria.assert_not_called() + + def test_add_comment_callback_success(self): + app = MyProfileApp() + app.notify = MagicMock() + with patch("my_profile_app.Egeria") as mock_egeria: + client = mock_egeria.return_value + client.add_comment_to_element.return_value = "comment-guid-1" + assert app.add_comment_callback(["Nice", "question", "elem-guid-1"]) == 200 + client.add_comment_to_element.assert_called_once_with( + element_guid="elem-guid-1", comment="Nice", comment_type="QUESTION") + client.close_session.assert_called_once() + # No "Failed to add comment" notice after a successful add + assert all("Failed" not in str(c) for c in app.notify.call_args_list) def test_show_main_screen(self): app = MyProfileApp() diff --git a/tests/micro-tests/my_profile/test_screens.py b/tests/micro-tests/my_profile/test_screens.py index f54daca8..738a2ee2 100644 --- a/tests/micro-tests/my_profile/test_screens.py +++ b/tests/micro-tests/my_profile/test_screens.py @@ -28,6 +28,8 @@ AddJournalEntryScreen, AddTodoScreen, AddAssociationScreen, + AddCollectionScreen, + AddUserIdentityScreen, ) from EditElementsScreens import ( EditProfileScreen, @@ -49,6 +51,7 @@ ) from SelectionOverviewScreen import SelectionOverviewScreen from ShopForDataScreen import ShopForDataScreen +from ViewSubscriptionsScreen import ViewSubscriptionsScreen class ScreenTestHostApp(App): @@ -303,6 +306,8 @@ class TestAddToElementsScreens: (AddJournalEntryScreen, "journal_table"), (AddTodoScreen, "todos_table"), (AddAssociationScreen, "associations_table"), + (AddCollectionScreen, "my_collections_table"), + (AddUserIdentityScreen, "user_identity_table"), ], ) async def test_add_screens_mount_and_cancel(self, screen_cls, table_id): @@ -313,6 +318,274 @@ async def test_add_screens_mount_and_cancel(self, screen_cls, table_id): assert app.dismissed_result == 200 +class TestBaseAddScreen: + """Tests for the BaseAddScreen-driven add screens (Collection, User Identity).""" + + @pytest.fixture + def mock_egeria(self): + with patch("AddToElementsScreens.Egeria") as mock_egeria: + # MagicMock doesn't auto-create dunder-style attributes, so supply this one + mock_egeria.return_value.__create_qualified_name__ = MagicMock( + side_effect=lambda type_name, name: f"{type_name}::{name}") + yield mock_egeria + + @staticmethod + def _fill(screen, values): + for field_id, value in values.items(): + screen.query_one(f"#{field_id}", Input).value = value + + @pytest.mark.asyncio + async def test_missing_required_field_does_not_call_egeria(self, mock_egeria): + app = ScreenTestHostApp(lambda: AddCollectionScreen("my_collections_table", "user-guid-123")) + async with app.run_test() as pilot: + self._fill(app.target_screen, {"collection_name": "My Collection"}) + app.target_screen.action_add_element() + await pilot.pause() + mock_egeria.assert_not_called() + + @pytest.mark.asyncio + async def test_add_collection(self, mock_egeria): + client = mock_egeria.return_value + client.create_collection.return_value = "coll-guid-1" + app = ScreenTestHostApp(lambda: AddCollectionScreen("my_collections_table", "user-guid-123")) + async with app.run_test() as pilot: + screen = app.target_screen + self._fill(screen, {"collection_name": "My Collection", + "collection_description": "Things I use"}) + # press() rather than pilot.click(): the button sits below the test terminal's visible area + screen.query_one("#add_button", Button).press() + await pilot.pause() + client.create_collection.assert_called_once_with( + display_name="My Collection", description="Things I use", category=None) + client.close_session.assert_called_once() + assert screen.created_guids == ["coll-guid-1"] + # The form is cleared after a successful add + assert screen.query_one("#collection_name", Input).value == "" + + @pytest.mark.asyncio + async def test_add_user_identity_and_link(self, mock_egeria): + client = mock_egeria.return_value + client.create_user_identity.return_value = "uid-guid-1" + app = ScreenTestHostApp(lambda: AddUserIdentityScreen("user_identity_table", "user-guid-123")) + async with app.run_test() as pilot: + self._fill(app.target_screen, {"user_identity_user_id": "erinoverview"}) + app.target_screen.action_add_element() + await pilot.pause() + props = client.create_user_identity.call_args.kwargs["body"]["properties"] + assert props["class"] == "UserIdentityProperties" + assert props["userId"] == "erinoverview" + assert props["displayName"] == "erinoverview" + client.link_identity_to_profile.assert_called_once_with( + user_identity_guid="uid-guid-1", actor_profile_guid="user-guid-123") + + @pytest.mark.asyncio + async def test_add_user_identity_without_link(self, mock_egeria): + client = mock_egeria.return_value + client.create_user_identity.return_value = "uid-guid-1" + app = ScreenTestHostApp(lambda: AddUserIdentityScreen("user_identity_table", "user-guid-123")) + async with app.run_test() as pilot: + self._fill(app.target_screen, {"user_identity_user_id": "erinoverview"}) + app.target_screen.query_one("#link_to_profile").value = False + await pilot.pause() + app.target_screen.action_add_element() + await pilot.pause() + client.create_user_identity.assert_called_once() + client.link_identity_to_profile.assert_not_called() + + @pytest.mark.asyncio + async def test_failed_create_keeps_form(self, mock_egeria): + from pyegeria import PyegeriaException + client = mock_egeria.return_value + client.create_collection.side_effect = PyegeriaException("Create failed") + app = ScreenTestHostApp(lambda: AddCollectionScreen("my_collections_table", "user-guid-123")) + async with app.run_test() as pilot: + screen = app.target_screen + self._fill(screen, {"collection_name": "My Collection", + "collection_description": "Things I use"}) + screen.action_add_element() + await pilot.pause() + assert screen.created_guids == [] + assert screen.query_one("#collection_name", Input).value == "My Collection" + client.close_session.assert_called_once() + + +class TestMigratedAddScreens: + """The Add screens built on BaseAddScreen call the right Egeria methods.""" + + @pytest.fixture + def mock_egeria(self): + with patch("AddToElementsScreens.Egeria") as mock_egeria: + client = mock_egeria.return_value + client.__create_qualified_name__ = MagicMock( + side_effect=lambda type_name, name: f"{type_name}::{name}") + client.make_feedback_qn = MagicMock( + side_effect=lambda kind, src, name: f"{kind}::{src}::{name}") + yield mock_egeria + + async def _add(self, screen_cls, table_id, values, link=None): + app = ScreenTestHostApp(lambda: screen_cls(table_id, "user-guid-123")) + async with app.run_test() as pilot: + screen = app.target_screen + for field_id, value in values.items(): + screen.query_one(f"#{field_id}", Input).value = value + if link is not None: + screen.query_one("#link_to_profile").value = link + await pilot.pause() + screen.action_add_element() + await pilot.pause() + return screen + + @pytest.mark.asyncio + async def test_add_todo(self, mock_egeria): + await self._add(AddTodoScreen, "todos_table", + {"todo_name": "Review", "todo_description": "Review specs", "todo_priority": "2"}) + mock_egeria.return_value.create_my_todo.assert_called_once_with( + todo_name="Review", description="Review specs", priority=2, activity_status="REQUESTED") + + @pytest.mark.asyncio + async def test_add_todo_rejects_bad_priority(self, mock_egeria): + await self._add(AddTodoScreen, "todos_table", + {"todo_name": "Review", "todo_description": "Review specs", "todo_priority": "High"}) + mock_egeria.assert_not_called() + + @pytest.mark.asyncio + async def test_add_blog_entry(self, mock_egeria): + await self._add(AddBlogEntryScreen, "blogs_table", + {"blog_entry_name": "Day 1", "blog_entry_text": "Started"}) + body = mock_egeria.return_value.blog_my_activity.call_args.kwargs["body"] + assert body["properties"]["class"] == "BlogEntryProperties" + assert body["properties"]["displayName"] == "Day 1" + assert body["properties"]["description"] == "Started" + + @pytest.mark.asyncio + async def test_add_journal_entry(self, mock_egeria): + await self._add(AddJournalEntryScreen, "journal_table", + {"journal_entry_title": "Notes", "journal_entry_text": "Text"}) + body = mock_egeria.return_value.journal_my_activity.call_args.kwargs["body"] + assert body["properties"]["class"] == "JournalEntryProperties" + + @pytest.mark.asyncio + async def test_add_community(self, mock_egeria): + await self._add(AddCommunityScreen, "communities_table", + {"community_display_name": "Data Club", "community_description": "Chat"}) + body = mock_egeria.return_value.create_community.call_args.kwargs["body"] + assert body["properties"]["class"] == "CommunityProperties" + assert body["properties"]["qualifiedName"] == "Community::Data Club" + + @pytest.mark.asyncio + async def test_add_project_and_join_team(self, mock_egeria): + client = mock_egeria.return_value + client.create_project.return_value = "proj-guid-1" + await self._add(AddProjectScreen, "projects_table", + {"project_name": "Clinical", "project_description": "Trial data", + "project_classification": "Campaign", "project_start_date": "2026-10-01"}) + kwargs = client.create_project.call_args.kwargs + assert kwargs["classification_name"] == "Campaign" + assert kwargs["start_date"] == "2026-10-01" + client.add_to_project_team.assert_called_once_with(project_guid="proj-guid-1", actor_guid="user-guid-123") + + @pytest.mark.asyncio + async def test_add_project_rejects_bad_date(self, mock_egeria): + await self._add(AddProjectScreen, "projects_table", + {"project_name": "Clinical", "project_description": "Trial data", + "project_start_date": "01/10/2026"}) + mock_egeria.assert_not_called() + + @pytest.mark.asyncio + async def test_add_role_and_appoint(self, mock_egeria): + client = mock_egeria.return_value + client.create_actor_role.return_value = "role-guid-1" + await self._add(AddRoleScreen, "roles_table", + {"role_name": "Steward", "role_description": "Looks after data"}) + body = client.create_actor_role.call_args.kwargs["body"] + assert body["properties"]["typeName"] == "PersonRole" + client.link_person_role_to_profile.assert_called_once_with( + person_role_guid="role-guid-1", person_profile_guid="user-guid-123") + + @pytest.mark.asyncio + async def test_add_role_without_appointment(self, mock_egeria): + client = mock_egeria.return_value + await self._add(AddRoleScreen, "roles_table", + {"role_name": "Steward", "role_description": "Looks after data"}, link=False) + client.create_actor_role.assert_called_once() + client.link_person_role_to_profile.assert_not_called() + + @pytest.mark.asyncio + async def test_add_team_and_join(self, mock_egeria): + client = mock_egeria.return_value + client.create_actor_profile.return_value = "team-guid-1" + client.create_actor_role.return_value = "member-role-guid-1" + await self._add(AddTeamScreen, "teams_table", + {"team_name": "Platform", "team_description": "Runs Egeria"}) + body = client.create_actor_profile.call_args.kwargs["body"] + assert body["properties"]["class"] == "TeamProperties" + assert body["properties"]["typeName"] == "Team" + role_body = client.create_actor_role.call_args.kwargs["body"] + assert role_body["properties"]["typeName"] == "TeamMember" + client.link_person_role_to_profile.assert_called_once_with( + person_role_guid="member-role-guid-1", person_profile_guid="user-guid-123") + client.link_assignment_scope.assert_called_once_with( + scope_element_guid="team-guid-1", actor_guid="member-role-guid-1") + + @pytest.mark.asyncio + async def test_add_team_without_joining(self, mock_egeria): + client = mock_egeria.return_value + await self._add(AddTeamScreen, "teams_table", + {"team_name": "Platform", "team_description": "Runs Egeria"}, link=False) + client.create_actor_profile.assert_called_once() + client.create_actor_role.assert_not_called() + client.link_assignment_scope.assert_not_called() + + @pytest.mark.asyncio + @pytest.mark.parametrize("button_id,expected", [ + ("#choose_project_button", "project"), + ("#choose_community_button", "community"), + ]) + async def test_association_chooser(self, button_id, expected): + app = ScreenTestHostApp(lambda: AddAssociationScreen("associations_table", "user-guid-123")) + async with app.run_test() as pilot: + app.target_screen.query_one(button_id, Button).press() + await pilot.pause() + assert app.dismissed_result == expected + + +class TestViewSubscriptionsScreen: + """The Subscriptions screen lists the current user's own digital subscriptions.""" + + @staticmethod + def _subscription(guid, name, created_by, status="ACTIVE"): + return {"elementHeader": {"guid": guid, "type": {"typeName": "DigitalSubscription"}, + "versions": {"createdBy": created_by}}, + "properties": {"displayName": name, "description": f"{name} desc", "contentStatus": status}} + + @pytest.mark.asyncio + @patch("ViewSubscriptionsScreen.Egeria") + async def test_lists_own_subscriptions(self, mock_egeria): + client = mock_egeria.return_value + client.find_collections.return_value = [ + self._subscription("sub-1", "Sales Feed", "garygeeke", "PROPOSED"), + self._subscription("sub-2", "Not Mine", "erinoverview"), + ] + app = ScreenTestHostApp(ViewSubscriptionsScreen) + async with app.run_test() as pilot: + await pilot.pause() + assert client.find_collections.call_args.kwargs["metadata_element_type_name"] == "DigitalSubscription" + table = app.target_screen.query_one("#subscriptions_table", DataTable) + assert table.row_count == 1 + assert table.get_row_at(0) == ["Sales Feed", "PROPOSED", "Sales Feed desc", "sub-1"] + client.close_session.assert_called_once() + + @pytest.mark.asyncio + @patch("ViewSubscriptionsScreen.Egeria") + async def test_quit_closes_screen_not_app(self, mock_egeria): + mock_egeria.return_value.find_collections.return_value = "No elements found" + app = ScreenTestHostApp(ViewSubscriptionsScreen) + async with app.run_test() as pilot: + await pilot.press("q") + await pilot.pause() + assert app.dismissed_result == 200 + + class TestEditElementsScreens: """Tests for EditElements screens.""" @@ -472,6 +745,22 @@ async def test_shop_for_data_screen_catalog_selection(self): await pilot.pause() assert app.dismissed_result == ["catalog", "Cat::Prod1", "Prod1", "guid-prod-999"] + @pytest.mark.asyncio + async def test_shop_for_data_screen_bookmark_highlighted_row(self): + t2 = DataTable(id="digital_product_catalog_table") + app = ScreenTestHostApp(lambda: ShopForDataScreen(digital_product_catalog_table=t2)) + app.bookmark_table_row = MagicMock(return_value=True) + async with app.run_test() as pilot: + target = app.target_screen.query_one("#digital_product_catalog_table", DataTable) + target.add_columns("Name", "Desc", "QN", "GUID") + row_key = target.add_row("Prod1", "Desc1", "Cat::Prod1", "guid-prod-999") + target.focus() + target.move_cursor(row=0) + await pilot.pause() + await pilot.press("k") + await pilot.pause() + app.bookmark_table_row.assert_called_once_with(target, row_key) + @pytest.mark.asyncio async def test_shop_for_data_screen_default_config(self): app = ScreenTestHostApp(lambda: ShopForDataScreen()) 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 1e865325..c3720ae6 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 @@ -335,6 +335,37 @@ def test_create_subscription_callback_dict_with_guid(self, backend): }) assert any("Subscription created" in msg for msg in app.log_messages) + def test_create_subscription_links_item_and_subscriber(self): + app = DummyShopApp() + app.user_GUID = "profile-guid-1" + app.notify = MagicMock() + with patch("shop_for_data_handler.ProductManager") as mock_pm_cls: + client = mock_pm_cls.return_value + client.create_digital_subscription.return_value = "sub-guid-1" + app.create_subscription_callback({ + "guid": "item-guid-123", "displayName": "Sales Feed", "Status": "PROPOSED", + "description": "Daily", "identifier": "SF1", + }) + body = client.create_digital_subscription.call_args.args[0] + assert body["properties"]["contentStatus"] == "PROPOSED" + assert body["properties"]["displayName"] == "Sales Feed" + client.link_agreement_item.assert_called_once_with( + agreement_guid="sub-guid-1", agreement_item_guid="item-guid-123") + client.link_subscriber.assert_called_once_with( + subscriber_guid="profile-guid-1", subscription_guid="sub-guid-1") + client.close_session.assert_called_once() + + def test_create_subscription_reports_failed_link(self): + app = DummyShopApp() + app.user_GUID = "profile-guid-1" + app.notify = MagicMock() + with patch("shop_for_data_handler.ProductManager") as mock_pm_cls: + client = mock_pm_cls.return_value + client.create_digital_subscription.return_value = "sub-guid-1" + client.link_agreement_item.side_effect = Exception("not a GUID") + assert app._create_subscription("Feed", "", "", "DRAFT", "Some::QName") == "sub-guid-1" + assert app.notify.call_args.kwargs.get("severity") == "warning" + @pytest.mark.live_capable @pytest.mark.asyncio async def test_shop_for_data_callback_direct_subscribe(self, backend): 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 236c2971..35221cd5 100644 --- a/tests/micro-tests/my_profile/test_team_roles_handler.py +++ b/tests/micro-tests/my_profile/test_team_roles_handler.py @@ -178,11 +178,12 @@ def test_search_for_term_callback_200(self): assert app.shown_main_screen is True def test_search_for_term_callback_201_shop_for_data(self): + """Going back reopens Shop for Data through its handler, so the tables reload.""" app = DummyTeamRolesApp() + app.handle_shop_for_data_option = MagicMock() + app.call_later = MagicMock() app.search_for_term_callback(201) - assert len(app.pushed_screens) == 1 - screen, cb = app.pushed_screens[0] - assert isinstance(screen, ShopForDataScreen) + app.call_later.assert_called_once_with(app.handle_shop_for_data_option) def test_search_for_term_callback_error(self): app = DummyTeamRolesApp() 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 a6eb8525..358e90f0 100644 --- a/tests/micro-tests/my_profile/test_tech_types_handler.py +++ b/tests/micro-tests/my_profile/test_tech_types_handler.py @@ -9,7 +9,7 @@ """ import re -from unittest.mock import MagicMock, AsyncMock +from unittest.mock import MagicMock, AsyncMock, patch import pytest from tech_types_handler import TechTypesMixin @@ -94,9 +94,13 @@ async def test_handle_technology_types_option_empty(self, backend): app = DummyTechTypesApp(backend) backend.patch_object(TechTypesMixin, "fetch_technology_types", AsyncMock()) app.tech_type_response = [] + app.notify = MagicMock() await app.handle_technology_types_option() - assert app.exit_code == 200 + # No types is not an error: say so and stay in the app + assert app.exit_code is None + assert app.pushed_screens == [] + app.notify.assert_called_once() @pytest.mark.asyncio async def test_handle_technology_types_option_error(self, backend): @@ -300,6 +304,38 @@ def test_tech_type_processes_callback_valid_input(self): "additionalProperties": {"templateGUID": "tmpl-1"}, }, ] - app.tech_type_processes_callback(input_result) + # No qualifiedName on the process: it cannot be started, and the user is told + rc = app.tech_type_processes_callback(input_result) assert app.input_data == {"target_server_process_input": "srv1"} assert app.full_process["displayName"] == "Provision Server" + assert rc == 418 + assert isinstance(app.pushed_screens[0][0], StatusScreen) + + def test_tech_type_processes_callback_starts_process(self): + app = DummyTechTypesApp() + app.autoc = MagicMock() + with patch("tech_types_handler.AutomatedCuration") as mock_ac_cls: + mock_ac_cls.return_value.initiate_gov_action_process.return_value = "proc-guid-1" + rc = app.tech_type_processes_callback([ + "input", + {"target_server_process_input": "srv1", "unused_process_input": "", "max_retries": "3"}, + {"displayName": "Provision Server", "qualifiedName": "GAP::Provision-Server"}, + ]) + mock_ac_cls.return_value.initiate_gov_action_process.assert_called_once_with( + action_type_qualified_name="GAP::Provision-Server", + request_parameters={"target server": "srv1", "max_retries": "3"}, + ) + assert rc == 200 + screen, _ = app.pushed_screens[0] + assert "proc-guid-1" in screen.status_message + + def test_tech_type_processes_callback_start_fails(self): + app = DummyTechTypesApp() + app.autoc = MagicMock() + with patch("tech_types_handler.AutomatedCuration") as mock_ac_cls: + mock_ac_cls.return_value.initiate_gov_action_process.side_effect = PyegeriaException("no engine") + rc = app.tech_type_processes_callback([ + "input", {}, {"displayName": "Provision Server", "qualifiedName": "GAP::Provision-Server"}, + ]) + assert rc == 420 + assert "Error starting" in app.pushed_screens[0][0].status_message