diff --git a/AGENTS.md b/AGENTS.md index 898d2eb51..1e3a0b7ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,12 +6,13 @@ Thrive (codenamed Jupiter) is a life planning tool with a monorepo containing: -| Service | Port | Tech | -|---|---|---| -| **WebAPI** (backend) | 8004 | Python/FastAPI, SQLite | -| **Public API** | 8020 | Python/FastAPI (proxies to WebAPI) | -| **WebUI** (frontend) | 10020 | TypeScript/Remix/React | -| **Docs** | 8000 | Python/MkDocs | +| Service | Tech | +|---|---| +| **WebAPI** (backend) | Python/FastAPI, SQLite | +| **WebUI** (frontend) | TypeScript/Remix/React | +| **API** | Python/FastAPI | +| **MCP** | Python/FastAPI | +| **Docs** | Python/MkDocs | ### Tool versions @@ -26,7 +27,7 @@ mise run prepare ### Running services -Start all 4 services (WebAPI, API, WebUI, Docs) via mise: +Start all 5 services (WebAPI, API, MCP, WebUI, Docs) via mise: ```bash mise run run:srv --instance diff --git a/gen/py/webapi-client/jupiter_webapi_client/api/infra/__init__.py b/gen/py/webapi-client/jupiter_webapi_client/api/infra/__init__.py new file mode 100644 index 000000000..2d7c0b23d --- /dev/null +++ b/gen/py/webapi-client/jupiter_webapi_client/api/infra/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/gen/py/webapi-client/jupiter_webapi_client/api/infra/get_entity_mutation_history.py b/gen/py/webapi-client/jupiter_webapi_client/api/infra/get_entity_mutation_history.py new file mode 100644 index 000000000..82b018d35 --- /dev/null +++ b/gen/py/webapi-client/jupiter_webapi_client/api/infra/get_entity_mutation_history.py @@ -0,0 +1,202 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error_response import ErrorResponse +from ...models.get_entity_mutation_history_args import GetEntityMutationHistoryArgs +from ...models.get_entity_mutation_history_result import GetEntityMutationHistoryResult +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + body: GetEntityMutationHistoryArgs | Unset = UNSET, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/get-entity-mutation-history", + } + + if not isinstance(body, Unset): + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ErrorResponse | GetEntityMutationHistoryResult | None: + if response.status_code == 200: + response_200 = GetEntityMutationHistoryResult.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = ErrorResponse.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = ErrorResponse.from_dict(response.json()) + + return response_401 + + if response.status_code == 404: + response_404 = ErrorResponse.from_dict(response.json()) + + return response_404 + + if response.status_code == 406: + response_406 = ErrorResponse.from_dict(response.json()) + + return response_406 + + if response.status_code == 409: + response_409 = ErrorResponse.from_dict(response.json()) + + return response_409 + + if response.status_code == 410: + response_410 = ErrorResponse.from_dict(response.json()) + + return response_410 + + if response.status_code == 422: + response_422 = ErrorResponse.from_dict(response.json()) + + return response_422 + + if response.status_code == 426: + response_426 = ErrorResponse.from_dict(response.json()) + + return response_426 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ErrorResponse | GetEntityMutationHistoryResult]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: GetEntityMutationHistoryArgs | Unset = UNSET, +) -> Response[ErrorResponse | GetEntityMutationHistoryResult]: + """Use case for loading the history of mutations for an entity. + + Args: + body (GetEntityMutationHistoryArgs | Unset): Arguments for the entity mutation history. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | GetEntityMutationHistoryResult] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: GetEntityMutationHistoryArgs | Unset = UNSET, +) -> ErrorResponse | GetEntityMutationHistoryResult | None: + """Use case for loading the history of mutations for an entity. + + Args: + body (GetEntityMutationHistoryArgs | Unset): Arguments for the entity mutation history. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | GetEntityMutationHistoryResult + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: GetEntityMutationHistoryArgs | Unset = UNSET, +) -> Response[ErrorResponse | GetEntityMutationHistoryResult]: + """Use case for loading the history of mutations for an entity. + + Args: + body (GetEntityMutationHistoryArgs | Unset): Arguments for the entity mutation history. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | GetEntityMutationHistoryResult] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: GetEntityMutationHistoryArgs | Unset = UNSET, +) -> ErrorResponse | GetEntityMutationHistoryResult | None: + """Use case for loading the history of mutations for an entity. + + Args: + body (GetEntityMutationHistoryArgs | Unset): Arguments for the entity mutation history. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | GetEntityMutationHistoryResult + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/gen/py/webapi-client/jupiter_webapi_client/models/__init__.py b/gen/py/webapi-client/jupiter_webapi_client/models/__init__.py index bc9a1ed73..fbdf61d9e 100644 --- a/gen/py/webapi-client/jupiter_webapi_client/models/__init__.py +++ b/gen/py/webapi-client/jupiter_webapi_client/models/__init__.py @@ -248,6 +248,8 @@ from .gen_load_runs_result import GenLoadRunsResult from .gen_log import GenLog from .gen_log_entry import GenLogEntry +from .get_entity_mutation_history_args import GetEntityMutationHistoryArgs +from .get_entity_mutation_history_result import GetEntityMutationHistoryResult from .get_summaries_args import GetSummariesArgs from .get_summaries_result import GetSummariesResult from .goal import Goal @@ -302,6 +304,7 @@ from .habit_update_args_skip_rule import HabitUpdateArgsSkipRule from .heading_block import HeadingBlock from .heading_block_kind import HeadingBlockKind +from .history_entry import HistoryEntry from .home_config import HomeConfig from .home_config_load_args import HomeConfigLoadArgs from .home_config_load_result import HomeConfigLoadResult @@ -1185,6 +1188,8 @@ "GenLoadRunsResult", "GenLog", "GenLogEntry", + "GetEntityMutationHistoryArgs", + "GetEntityMutationHistoryResult", "GetSummariesArgs", "GetSummariesResult", "Goal", @@ -1239,6 +1244,7 @@ "HabitUpdateArgsSkipRule", "HeadingBlock", "HeadingBlockKind", + "HistoryEntry", "HomeConfig", "HomeConfigLoadArgs", "HomeConfigLoadResult", diff --git a/gen/py/webapi-client/jupiter_webapi_client/models/get_entity_mutation_history_args.py b/gen/py/webapi-client/jupiter_webapi_client/models/get_entity_mutation_history_args.py new file mode 100644 index 000000000..ffd5dc2ae --- /dev/null +++ b/gen/py/webapi-client/jupiter_webapi_client/models/get_entity_mutation_history_args.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.named_entity_tag import NamedEntityTag +from ..types import UNSET, Unset + +T = TypeVar("T", bound="GetEntityMutationHistoryArgs") + + +@_attrs_define +class GetEntityMutationHistoryArgs: + """Arguments for the entity mutation history. + + Attributes: + entity_type (NamedEntityTag): A tag for all known entities. + entity_ref_id (str): A generic entity id. + retrieve_offset (int | None | Unset): + retrieve_limit (int | None | Unset): + """ + + entity_type: NamedEntityTag + entity_ref_id: str + retrieve_offset: int | None | Unset = UNSET + retrieve_limit: int | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + entity_type = self.entity_type.value + + entity_ref_id = self.entity_ref_id + + retrieve_offset: int | None | Unset + if isinstance(self.retrieve_offset, Unset): + retrieve_offset = UNSET + else: + retrieve_offset = self.retrieve_offset + + retrieve_limit: int | None | Unset + if isinstance(self.retrieve_limit, Unset): + retrieve_limit = UNSET + else: + retrieve_limit = self.retrieve_limit + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "entity_type": entity_type, + "entity_ref_id": entity_ref_id, + } + ) + if retrieve_offset is not UNSET: + field_dict["retrieve_offset"] = retrieve_offset + if retrieve_limit is not UNSET: + field_dict["retrieve_limit"] = retrieve_limit + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + entity_type = NamedEntityTag(d.pop("entity_type")) + + entity_ref_id = d.pop("entity_ref_id") + + def _parse_retrieve_offset(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + retrieve_offset = _parse_retrieve_offset(d.pop("retrieve_offset", UNSET)) + + def _parse_retrieve_limit(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + retrieve_limit = _parse_retrieve_limit(d.pop("retrieve_limit", UNSET)) + + get_entity_mutation_history_args = cls( + entity_type=entity_type, + entity_ref_id=entity_ref_id, + retrieve_offset=retrieve_offset, + retrieve_limit=retrieve_limit, + ) + + get_entity_mutation_history_args.additional_properties = d + return get_entity_mutation_history_args + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/gen/py/webapi-client/jupiter_webapi_client/models/get_entity_mutation_history_result.py b/gen/py/webapi-client/jupiter_webapi_client/models/get_entity_mutation_history_result.py new file mode 100644 index 000000000..86ff6afab --- /dev/null +++ b/gen/py/webapi-client/jupiter_webapi_client/models/get_entity_mutation_history_result.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.history_entry import HistoryEntry + from ..models.user import User + + +T = TypeVar("T", bound="GetEntityMutationHistoryResult") + + +@_attrs_define +class GetEntityMutationHistoryResult: + """Results for the entity mutation history. + + Attributes: + entries (list[HistoryEntry]): + users (list[User]): + total_cnt (int): + page_size (int): + """ + + entries: list[HistoryEntry] + users: list[User] + total_cnt: int + page_size: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + entries = [] + for entries_item_data in self.entries: + entries_item = entries_item_data.to_dict() + entries.append(entries_item) + + users = [] + for users_item_data in self.users: + users_item = users_item_data.to_dict() + users.append(users_item) + + total_cnt = self.total_cnt + + page_size = self.page_size + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "entries": entries, + "users": users, + "total_cnt": total_cnt, + "page_size": page_size, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.history_entry import HistoryEntry + from ..models.user import User + + d = dict(src_dict) + entries = [] + _entries = d.pop("entries") + for entries_item_data in _entries: + entries_item = HistoryEntry.from_dict(entries_item_data) + + entries.append(entries_item) + + users = [] + _users = d.pop("users") + for users_item_data in _users: + users_item = User.from_dict(users_item_data) + + users.append(users_item) + + total_cnt = d.pop("total_cnt") + + page_size = d.pop("page_size") + + get_entity_mutation_history_result = cls( + entries=entries, + users=users, + total_cnt=total_cnt, + page_size=page_size, + ) + + get_entity_mutation_history_result.additional_properties = d + return get_entity_mutation_history_result + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/gen/py/webapi-client/jupiter_webapi_client/models/history_entry.py b/gen/py/webapi-client/jupiter_webapi_client/models/history_entry.py new file mode 100644 index 000000000..c90a1bd87 --- /dev/null +++ b/gen/py/webapi-client/jupiter_webapi_client/models/history_entry.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="HistoryEntry") + + +@_attrs_define +class HistoryEntry: + """An instance of the history. + + Attributes: + entity_name (str): + mutation_name (str): + event_kind (str): + event_name (str): + timestamp (str): A timestamp in the application. + source (str): + user_ref_id (str): A generic entity id. + entity_version (int): + data (str): + """ + + entity_name: str + mutation_name: str + event_kind: str + event_name: str + timestamp: str + source: str + user_ref_id: str + entity_version: int + data: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + entity_name = self.entity_name + + mutation_name = self.mutation_name + + event_kind = self.event_kind + + event_name = self.event_name + + timestamp = self.timestamp + + source = self.source + + user_ref_id = self.user_ref_id + + entity_version = self.entity_version + + data = self.data + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "entity_name": entity_name, + "mutation_name": mutation_name, + "event_kind": event_kind, + "event_name": event_name, + "timestamp": timestamp, + "source": source, + "user_ref_id": user_ref_id, + "entity_version": entity_version, + "data": data, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + entity_name = d.pop("entity_name") + + mutation_name = d.pop("mutation_name") + + event_kind = d.pop("event_kind") + + event_name = d.pop("event_name") + + timestamp = d.pop("timestamp") + + source = d.pop("source") + + user_ref_id = d.pop("user_ref_id") + + entity_version = d.pop("entity_version") + + data = d.pop("data") + + history_entry = cls( + entity_name=entity_name, + mutation_name=mutation_name, + event_kind=event_kind, + event_name=event_name, + timestamp=timestamp, + source=source, + user_ref_id=user_ref_id, + entity_version=entity_version, + data=data, + ) + + history_entry.additional_properties = d + return history_entry + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/gen/ts/webapi-client/gen/ApiClient.ts b/gen/ts/webapi-client/gen/ApiClient.ts index 4e7d6fbae..dd1c3e742 100644 --- a/gen/ts/webapi-client/gen/ApiClient.ts +++ b/gen/ts/webapi-client/gen/ApiClient.ts @@ -18,6 +18,7 @@ import { GenService } from './services/GenService'; import { HabitsService } from './services/HabitsService'; import { HomeService } from './services/HomeService'; import { InboxTasksService } from './services/InboxTasksService'; +import { InfraService } from './services/InfraService'; import { JournalsService } from './services/JournalsService'; import { LifePlanService } from './services/LifePlanService'; import { McpKeyService } from './services/McpKeyService'; @@ -55,6 +56,7 @@ export class ApiClient { public readonly habits: HabitsService; public readonly home: HomeService; public readonly inboxTasks: InboxTasksService; + public readonly infra: InfraService; public readonly journals: JournalsService; public readonly lifePlan: LifePlanService; public readonly mcpKey: McpKeyService; @@ -103,6 +105,7 @@ export class ApiClient { this.habits = new HabitsService(this.request); this.home = new HomeService(this.request); this.inboxTasks = new InboxTasksService(this.request); + this.infra = new InfraService(this.request); this.journals = new JournalsService(this.request); this.lifePlan = new LifePlanService(this.request); this.mcpKey = new McpKeyService(this.request); diff --git a/gen/ts/webapi-client/gen/index.ts b/gen/ts/webapi-client/gen/index.ts index 6676636d2..b5dcafd45 100644 --- a/gen/ts/webapi-client/gen/index.ts +++ b/gen/ts/webapi-client/gen/index.ts @@ -208,6 +208,8 @@ export type { GenLoadRunsArgs } from './models/GenLoadRunsArgs'; export type { GenLoadRunsResult } from './models/GenLoadRunsResult'; export type { GenLog } from './models/GenLog'; export type { GenLogEntry } from './models/GenLogEntry'; +export type { GetEntityMutationHistoryArgs } from './models/GetEntityMutationHistoryArgs'; +export type { GetEntityMutationHistoryResult } from './models/GetEntityMutationHistoryResult'; export type { GetSummariesArgs } from './models/GetSummariesArgs'; export type { GetSummariesResult } from './models/GetSummariesResult'; export type { Goal } from './models/Goal'; @@ -244,6 +246,7 @@ export type { HabitSuspendArgs } from './models/HabitSuspendArgs'; export type { HabitUnsuspendArgs } from './models/HabitUnsuspendArgs'; export type { HabitUpdateArgs } from './models/HabitUpdateArgs'; export { HeadingBlock } from './models/HeadingBlock'; +export type { HistoryEntry } from './models/HistoryEntry'; export type { HomeConfig } from './models/HomeConfig'; export type { HomeConfigLoadArgs } from './models/HomeConfigLoadArgs'; export type { HomeConfigLoadResult } from './models/HomeConfigLoadResult'; @@ -383,6 +386,7 @@ export type { MilestoneUpdateArgs } from './models/MilestoneUpdateArgs'; export type { MOTD } from './models/MOTD'; export type { MOTDGetForTodayArgs } from './models/MOTDGetForTodayArgs'; export type { MOTDGetForTodayResult } from './models/MOTDGetForTodayResult'; +export type { MutationId } from './models/MutationId'; export { NamedEntityTag } from './models/NamedEntityTag'; export type { NestedResult } from './models/NestedResult'; export type { NestedResultPerSource } from './models/NestedResultPerSource'; @@ -691,6 +695,7 @@ export type { TodoTaskRemoveArgs } from './models/TodoTaskRemoveArgs'; export type { TodoTaskSummary } from './models/TodoTaskSummary'; export type { TodoTaskUpdateArgs } from './models/TodoTaskUpdateArgs'; export type { TodoTaskUpdateResult } from './models/TodoTaskUpdateResult'; +export type { TraceId } from './models/TraceId'; export type { Universe } from './models/Universe'; export type { URL } from './models/URL'; export type { User } from './models/User'; @@ -777,6 +782,7 @@ export { GenService } from './services/GenService'; export { HabitsService } from './services/HabitsService'; export { HomeService } from './services/HomeService'; export { InboxTasksService } from './services/InboxTasksService'; +export { InfraService } from './services/InfraService'; export { JournalsService } from './services/JournalsService'; export { LifePlanService } from './services/LifePlanService'; export { McpKeyService } from './services/McpKeyService'; diff --git a/gen/ts/webapi-client/gen/models/GetEntityMutationHistoryArgs.ts b/gen/ts/webapi-client/gen/models/GetEntityMutationHistoryArgs.ts new file mode 100644 index 000000000..97ff1f1e9 --- /dev/null +++ b/gen/ts/webapi-client/gen/models/GetEntityMutationHistoryArgs.ts @@ -0,0 +1,16 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { EntityId } from './EntityId'; +import type { NamedEntityTag } from './NamedEntityTag'; +/** + * Arguments for the entity mutation history. + */ +export type GetEntityMutationHistoryArgs = { + entity_type: NamedEntityTag; + entity_ref_id: EntityId; + retrieve_offset?: (number | null); + retrieve_limit?: (number | null); +}; + diff --git a/gen/ts/webapi-client/gen/models/GetEntityMutationHistoryResult.ts b/gen/ts/webapi-client/gen/models/GetEntityMutationHistoryResult.ts new file mode 100644 index 000000000..88f8cfd0e --- /dev/null +++ b/gen/ts/webapi-client/gen/models/GetEntityMutationHistoryResult.ts @@ -0,0 +1,16 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { HistoryEntry } from './HistoryEntry'; +import type { User } from './User'; +/** + * Results for the entity mutation history. + */ +export type GetEntityMutationHistoryResult = { + entries: Array; + users: Array; + total_cnt: number; + page_size: number; +}; + diff --git a/gen/ts/webapi-client/gen/models/HistoryEntry.ts b/gen/ts/webapi-client/gen/models/HistoryEntry.ts new file mode 100644 index 000000000..aa9b09e57 --- /dev/null +++ b/gen/ts/webapi-client/gen/models/HistoryEntry.ts @@ -0,0 +1,21 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { EntityId } from './EntityId'; +import type { Timestamp } from './Timestamp'; +/** + * An instance of the history. + */ +export type HistoryEntry = { + entity_name: string; + mutation_name: string; + event_kind: string; + event_name: string; + timestamp: Timestamp; + source: string; + user_ref_id: EntityId; + entity_version: number; + data: string; +}; + diff --git a/gen/ts/webapi-client/gen/models/MutationId.ts b/gen/ts/webapi-client/gen/models/MutationId.ts new file mode 100644 index 000000000..b4f7e07c5 --- /dev/null +++ b/gen/ts/webapi-client/gen/models/MutationId.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * A mutation id for a particular user action. + */ +export type MutationId = string; diff --git a/gen/ts/webapi-client/gen/models/TraceId.ts b/gen/ts/webapi-client/gen/models/TraceId.ts new file mode 100644 index 000000000..349b34c0d --- /dev/null +++ b/gen/ts/webapi-client/gen/models/TraceId.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * A trace id for a particular user action. + */ +export type TraceId = string; diff --git a/gen/ts/webapi-client/gen/services/InfraService.ts b/gen/ts/webapi-client/gen/services/InfraService.ts new file mode 100644 index 000000000..4c26b774d --- /dev/null +++ b/gen/ts/webapi-client/gen/services/InfraService.ts @@ -0,0 +1,37 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { GetEntityMutationHistoryArgs } from '../models/GetEntityMutationHistoryArgs'; +import type { GetEntityMutationHistoryResult } from '../models/GetEntityMutationHistoryResult'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import type { BaseHttpRequest } from '../core/BaseHttpRequest'; +export class InfraService { + constructor(public readonly httpRequest: BaseHttpRequest) {} + /** + * Use case for loading the history of mutations for an entity. + * @param requestBody The input data + * @returns GetEntityMutationHistoryResult Successful response + * @throws ApiError + */ + public getEntityMutationHistory( + requestBody?: GetEntityMutationHistoryArgs, + ): CancelablePromise { + return this.httpRequest.request({ + method: 'POST', + url: '/get-entity-mutation-history', + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Error response for EntityAlreadyExistsError`, + 401: `Error response for ExpiredAuthTokenError`, + 404: `Error response for EntityNotFoundError`, + 406: `Error response for UnavailableGloballyError, UnavailableForComponentError, UnavailableForContextError`, + 409: `Error response for TimePlanExistsForDatePeriodCombinationError, BigPlanMilestoneAlreadyExistsForDateError, JournalExistsForDatePeriodCombinationError, ContactAlreadyExistsError, TagAlreadyExistsError`, + 410: `Error response for UserNotFoundError, WorkspaceNotFoundError`, + 422: `Error response for JSONDecodeError, InputValidationError, MultiInputValidationError, RealmDecodingError, UserAlreadyExistsError, InvalidLoginCredentialsError, InvalidAPIKeyError, AspectInSignificantUseError, ContactInSignificantUseError`, + 426: `Error response for InvalidAuthTokenError`, + }, + }); + } +} diff --git a/itests/package.mise.toml b/itests/package.mise.toml index b8121a642..04db6175b 100644 --- a/itests/package.mise.toml +++ b/itests/package.mise.toml @@ -2,7 +2,7 @@ hide = true run = ''' #!/usr/bin/env bash -sudo playwright install-deps +playwright install-deps playwright install ''' diff --git a/src/alib/py/framework/jupiter/framework/appform/cli/appform.py b/src/alib/py/framework/jupiter/framework/appform/cli/appform.py index c3eea063a..5d33d72bd 100644 --- a/src/alib/py/framework/jupiter/framework/appform/cli/appform.py +++ b/src/alib/py/framework/jupiter/framework/appform/cli/appform.py @@ -557,6 +557,7 @@ def _add_use_case_type( global_properties=self._global_properties, time_provider=self._time_provider, realm_codec_registry=self._realm_codec_registry, + invocation_recorder=self._invocation_recorder, auth_token_stamper=self._auth_token_stamper, ), ) @@ -590,6 +591,7 @@ def _add_use_case_type( global_properties=self._global_properties, time_provider=self._time_provider, realm_codec_registry=self._realm_codec_registry, + invocation_recorder=self._invocation_recorder, auth_token_stamper=self._auth_token_stamper, ), ) diff --git a/src/alib/py/framework/jupiter/framework/appform/webapi/appform.py b/src/alib/py/framework/jupiter/framework/appform/webapi/appform.py index 3459ce740..fa574215f 100644 --- a/src/alib/py/framework/jupiter/framework/appform/webapi/appform.py +++ b/src/alib/py/framework/jupiter/framework/appform/webapi/appform.py @@ -586,6 +586,7 @@ def _add_use_case_type( global_properties=self._global_properties, time_provider=self._request_time_provider, realm_codec_registry=self._realm_codec_registry, + invocation_recorder=self._invocation_recorder, auth_token_stamper=self._auth_token_stamper, ports=self._ports, ) @@ -627,6 +628,7 @@ def _add_use_case_type( global_properties=self._global_properties, time_provider=self._request_time_provider, realm_codec_registry=self._realm_codec_registry, + invocation_recorder=self._invocation_recorder, auth_token_stamper=self._auth_token_stamper, ports=self._ports, ) diff --git a/src/alib/py/framework/jupiter/framework/base/timestamp.py b/src/alib/py/framework/jupiter/framework/base/timestamp.py index 7642b37dd..a617bd4ba 100644 --- a/src/alib/py/framework/jupiter/framework/base/timestamp.py +++ b/src/alib/py/framework/jupiter/framework/base/timestamp.py @@ -53,6 +53,14 @@ def mins_since(self, other: "Timestamp") -> int: """Get the minutes since another timestamp.""" return self.the_ts.diff(other.the_ts).in_minutes() + def add_minutes(self, minutes: int) -> "Timestamp": + """Add these number of minutes to this timestamp.""" + return Timestamp(self.the_ts.add(minutes=minutes)) + + def subtract_minutes(self, minutes: int) -> "Timestamp": + """Subtract these number of minutes from this timestamp.""" + return Timestamp(self.the_ts.subtract(minutes=minutes)) + @property def value(self) -> DateTime: """The value as a time.""" diff --git a/src/alib/py/framework/jupiter/framework/mutation_inovcation/entity_event.py b/src/alib/py/framework/jupiter/framework/mutation_inovcation/entity_event.py new file mode 100644 index 000000000..7902b76ae --- /dev/null +++ b/src/alib/py/framework/jupiter/framework/mutation_inovcation/entity_event.py @@ -0,0 +1,27 @@ +"""Framework level elements for entity events.""" + +from dataclasses import dataclass + +from jupiter.framework.base.entity_id import EntityId +from jupiter.framework.base.mutation_id import MutationId +from jupiter.framework.base.timestamp import Timestamp +from jupiter.framework.base.trace_id import TraceId +from jupiter.framework.event import EventKind + + +@dataclass(frozen=True) +class MutationEntityEvent: + """The record of the modification of an entity.""" + + entity_type: str + entity_ref_id: EntityId + entity_version: int + kind: EventKind + name: str + trace_id: TraceId + mutation_id: MutationId + timestamp: Timestamp + session_index: int + source: str + context_str: str + data: str diff --git a/src/alib/py/framework/jupiter/framework/mutation_inovcation/record.py b/src/alib/py/framework/jupiter/framework/mutation_inovcation/invocation_record.py similarity index 100% rename from src/alib/py/framework/jupiter/framework/mutation_inovcation/record.py rename to src/alib/py/framework/jupiter/framework/mutation_inovcation/invocation_record.py diff --git a/src/alib/py/framework/jupiter/framework/mutation_inovcation/recorder.py b/src/alib/py/framework/jupiter/framework/mutation_inovcation/recorder.py index 96c53eca0..eecab7fe3 100644 --- a/src/alib/py/framework/jupiter/framework/mutation_inovcation/recorder.py +++ b/src/alib/py/framework/jupiter/framework/mutation_inovcation/recorder.py @@ -2,7 +2,13 @@ import abc -from jupiter.framework.mutation_inovcation.record import MutationInvocationRecord +from jupiter.framework.base.entity_id import EntityId +from jupiter.framework.base.mutation_id import MutationId +from jupiter.framework.base.timestamp import Timestamp +from jupiter.framework.mutation_inovcation.entity_event import MutationEntityEvent +from jupiter.framework.mutation_inovcation.invocation_record import ( + MutationInvocationRecord, +) class MutationInvocationRecorder(abc.ABC): @@ -15,6 +21,28 @@ async def record( ) -> None: """Record the invocation of the mutation.""" + @abc.abstractmethod + async def find_all_invocation_records( + self, mutation_ids: list[MutationId] + ) -> list[MutationInvocationRecord]: + """Retrieve all mutation records.""" + + @abc.abstractmethod + async def find_all_entity_events_by_timestamp_desc( + self, entity_type: str, entity_ref_id: EntityId, offset: int, limit: int + ) -> tuple[list[MutationEntityEvent], int]: + """Retrieve all events on an entity in a given range.""" + + @abc.abstractmethod + async def find_all_entity_events_between( + self, + entity_type: str, + entity_ref_id: EntityId, + start: Timestamp, + end: Timestamp, + ) -> list[MutationEntityEvent]: + """Retrieve all events on an entity between two timestamps.""" + @abc.abstractmethod async def clear_all(self, context_str: str) -> None: """Clear all invocation records for a given context.""" diff --git a/src/alib/py/framework/jupiter/framework/mutation_inovcation/recorders/impl/sqlite.py b/src/alib/py/framework/jupiter/framework/mutation_inovcation/recorders/impl/sqlite.py index fe10778f5..c0db8c2f3 100644 --- a/src/alib/py/framework/jupiter/framework/mutation_inovcation/recorders/impl/sqlite.py +++ b/src/alib/py/framework/jupiter/framework/mutation_inovcation/recorders/impl/sqlite.py @@ -5,8 +5,14 @@ from types import TracebackType from typing import Final -from jupiter.framework.mutation_inovcation.record import ( +from jupiter.framework.base.entity_id import EntityId +from jupiter.framework.base.mutation_id import MutationId +from jupiter.framework.base.timestamp import Timestamp +from jupiter.framework.base.trace_id import TraceId +from jupiter.framework.mutation_inovcation.entity_event import MutationEntityEvent +from jupiter.framework.mutation_inovcation.invocation_record import ( MutationInvocationRecord, + MutationInvocationResult, ) from jupiter.framework.mutation_inovcation.recorders.persistent import ( MutationInvocationRecordRepository, @@ -15,6 +21,11 @@ ) from jupiter.framework.realm.realm import RealmCodecRegistry from jupiter.framework.storage.sqlite.connection import SqliteConnection +from jupiter.framework.storage.sqlite.events import ( + build_event_table, + find_entity_events_between, + find_entity_events_by_timestamp_desc, +) from jupiter.framework.storage.sqlite.repository import SqliteRepository from sqlalchemy import ( JSON, @@ -25,6 +36,7 @@ Table, delete, insert, + select, ) from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine @@ -36,6 +48,7 @@ class SqliteMutationInvocationRecordRepository( """A SQlite repository for mutation use cases invocation records.""" _mutation_invocation_record_table: Final[Table] + _mutation_entity_event_table: Final[Table] def __init__( self, @@ -58,6 +71,9 @@ def __init__( Column("error_str", String, nullable=True), keep_existing=True, ) + self._mutation_entity_event_table = build_event_table( + self._mutation_invocation_record_table, metadata + ) async def create( self, @@ -83,6 +99,79 @@ async def create( ), ) + async def find_all( + self, + mutation_ids: list[MutationId], + ) -> list[MutationInvocationRecord]: + """Find all invocation records matching the given mutation ids.""" + query_stmt = select(self._mutation_invocation_record_table).where( + self._mutation_invocation_record_table.c.mutation_id.in_( + [self._realm_codec_registry.db_encode(mid) for mid in mutation_ids] + ) + ) + results = await self._connection.execute(query_stmt) + return [ + MutationInvocationRecord( + trace_id=self._realm_codec_registry.db_decode(TraceId, row.trace_id), + mutation_id=self._realm_codec_registry.db_decode( + MutationId, row.mutation_id + ), + timestamp=self._realm_codec_registry.db_decode( + Timestamp, row.timestamp + ), + context_str=row.context_str, + name=row.name, + args=row.args, + result=MutationInvocationResult(row.result), + error_str=row.error_str, + ) + for row in results + ] + + _MAX_FIND_ALL_LIMIT: int = 200 + + async def find_all_entity_events_by_timestamp_desc( + self, + entity_type: str, + entity_ref_id: EntityId, + offset: int, + limit: int, + ) -> tuple[list[MutationEntityEvent], int]: + """Find all entity events in descending timestamp order with pagination.""" + if offset < 0: + raise ValueError(f"Offset must be non-negative but was {offset}") + if limit <= 0 or limit > self._MAX_FIND_ALL_LIMIT: + raise ValueError( + f"Limit must be between 1 and {self._MAX_FIND_ALL_LIMIT} but was {limit}" + ) + return await find_entity_events_by_timestamp_desc( + self._realm_codec_registry, + self._connection, + self._mutation_entity_event_table, + entity_type, + entity_ref_id, + offset, + limit, + ) + + async def find_all_entity_events_between( + self, + entity_type: str, + entity_ref_id: EntityId, + start: Timestamp, + end: Timestamp, + ) -> list[MutationEntityEvent]: + """Find all entity events between two timestamps.""" + return await find_entity_events_between( + self._realm_codec_registry, + self._connection, + self._mutation_entity_event_table, + entity_type, + entity_ref_id, + start, + end, + ) + async def clear_all(self, context_str: str) -> None: """Clear all entries in the invocation record.""" await self._connection.execute( diff --git a/src/alib/py/framework/jupiter/framework/mutation_inovcation/recorders/logging.py b/src/alib/py/framework/jupiter/framework/mutation_inovcation/recorders/logging.py index ab81f3edb..966066288 100644 --- a/src/alib/py/framework/jupiter/framework/mutation_inovcation/recorders/logging.py +++ b/src/alib/py/framework/jupiter/framework/mutation_inovcation/recorders/logging.py @@ -2,7 +2,11 @@ import logging -from jupiter.framework.mutation_inovcation.record import ( +from jupiter.framework.base.entity_id import EntityId +from jupiter.framework.base.mutation_id import MutationId +from jupiter.framework.base.timestamp import Timestamp +from jupiter.framework.mutation_inovcation.entity_event import MutationEntityEvent +from jupiter.framework.mutation_inovcation.invocation_record import ( MutationInvocationRecord, ) from jupiter.framework.mutation_inovcation.recorder import ( @@ -27,5 +31,27 @@ async def record(self, invocation_record: MutationInvocationRecord) -> None: invocation_record.error_str, ) + async def find_all_invocation_records( + self, mutation_ids: list[MutationId] + ) -> list[MutationInvocationRecord]: + """Retrieve all mutation records.""" + return [] + + async def find_all_entity_events_by_timestamp_desc( + self, entity_type: str, entity_ref_id: EntityId, offset: int, limit: int + ) -> tuple[list[MutationEntityEvent], int]: + """Retrieve all events on an entity in a given range.""" + return [], 0 + + async def find_all_entity_events_between( + self, + entity_type: str, + entity_ref_id: EntityId, + start: Timestamp, + end: Timestamp, + ) -> list[MutationEntityEvent]: + """Retrieve all events on an entity between two timestamps.""" + return [] + async def clear_all(self, context_str: str) -> None: """Clear all invocation records for a given context.""" diff --git a/src/alib/py/framework/jupiter/framework/mutation_inovcation/recorders/noop.py b/src/alib/py/framework/jupiter/framework/mutation_inovcation/recorders/noop.py index 0abbe8350..de3c5ce9d 100644 --- a/src/alib/py/framework/jupiter/framework/mutation_inovcation/recorders/noop.py +++ b/src/alib/py/framework/jupiter/framework/mutation_inovcation/recorders/noop.py @@ -1,6 +1,10 @@ """A noop recorder for mutations.""" -from jupiter.framework.mutation_inovcation.record import ( +from jupiter.framework.base.entity_id import EntityId +from jupiter.framework.base.mutation_id import MutationId +from jupiter.framework.base.timestamp import Timestamp +from jupiter.framework.mutation_inovcation.entity_event import MutationEntityEvent +from jupiter.framework.mutation_inovcation.invocation_record import ( MutationInvocationRecord, ) from jupiter.framework.mutation_inovcation.recorder import ( @@ -14,5 +18,27 @@ class NoopMutationInvocationRecorder(MutationInvocationRecorder): async def record(self, invocation_record: MutationInvocationRecord) -> None: """Record the invocation of the mutation.""" + async def find_all_invocation_records( + self, mutation_ids: list[MutationId] + ) -> list[MutationInvocationRecord]: + """Retrieve all mutation records.""" + return [] + + async def find_all_entity_events_by_timestamp_desc( + self, entity_type: str, entity_ref_id: EntityId, offset: int, limit: int + ) -> tuple[list[MutationEntityEvent], int]: + """Retrieve all events on an entity in a given range.""" + return [], 0 + + async def find_all_entity_events_between( + self, + entity_type: str, + entity_ref_id: EntityId, + start: Timestamp, + end: Timestamp, + ) -> list[MutationEntityEvent]: + """Retrieve all events on an entity between two timestamps.""" + return [] + async def clear_all(self, context_str: str) -> None: """Clear all invocation records for a given context.""" diff --git a/src/alib/py/framework/jupiter/framework/mutation_inovcation/recorders/persistent.py b/src/alib/py/framework/jupiter/framework/mutation_inovcation/recorders/persistent.py index cde4db0b0..ea76839d2 100644 --- a/src/alib/py/framework/jupiter/framework/mutation_inovcation/recorders/persistent.py +++ b/src/alib/py/framework/jupiter/framework/mutation_inovcation/recorders/persistent.py @@ -4,7 +4,11 @@ from contextlib import AbstractAsyncContextManager from typing import Final -from jupiter.framework.mutation_inovcation.record import ( +from jupiter.framework.base.entity_id import EntityId +from jupiter.framework.base.mutation_id import MutationId +from jupiter.framework.base.timestamp import Timestamp +from jupiter.framework.mutation_inovcation.entity_event import MutationEntityEvent +from jupiter.framework.mutation_inovcation.invocation_record import ( MutationInvocationRecord, ) from jupiter.framework.mutation_inovcation.recorder import ( @@ -23,6 +27,33 @@ async def create( ) -> None: """Create a new invocation record.""" + @abc.abstractmethod + async def find_all( + self, + mutation_ids: list[MutationId], + ) -> list[MutationInvocationRecord]: + """Find all invocation records matching the given mutation ids.""" + + @abc.abstractmethod + async def find_all_entity_events_by_timestamp_desc( + self, + entity_type: str, + entity_ref_id: EntityId, + offset: int, + limit: int, + ) -> tuple[list[MutationEntityEvent], int]: + """Find all entity events in descending timestamp order with pagination.""" + + @abc.abstractmethod + async def find_all_entity_events_between( + self, + entity_type: str, + entity_ref_id: EntityId, + start: Timestamp, + end: Timestamp, + ) -> list[MutationEntityEvent]: + """Find all entity events between two timestamps.""" + @abc.abstractmethod async def clear_all(self, context_str: str) -> None: """Clear all invocation record entries.""" @@ -68,6 +99,50 @@ async def record( invocation_record, ) + async def find_all_invocation_records( + self, + mutation_ids: list[MutationId], + ) -> list[MutationInvocationRecord]: + """Retrieve all mutation records.""" + if not mutation_ids: + return [] + async with self._storage_engine.get_unit_of_work() as uow: + return await uow.mutation_invocation_record_repository.find_all( + mutation_ids, + ) + + async def find_all_entity_events_by_timestamp_desc( + self, + entity_type: str, + entity_ref_id: EntityId, + offset: int, + limit: int, + ) -> tuple[list[MutationEntityEvent], int]: + """Retrieve all events on an entity in a given range.""" + async with self._storage_engine.get_unit_of_work() as uow: + return await uow.mutation_invocation_record_repository.find_all_entity_events_by_timestamp_desc( + entity_type, + entity_ref_id, + offset, + limit, + ) + + async def find_all_entity_events_between( + self, + entity_type: str, + entity_ref_id: EntityId, + start: Timestamp, + end: Timestamp, + ) -> list[MutationEntityEvent]: + """Retrieve all events on an entity between two timestamps.""" + async with self._storage_engine.get_unit_of_work() as uow: + return await uow.mutation_invocation_record_repository.find_all_entity_events_between( + entity_type, + entity_ref_id, + start, + end, + ) + async def clear_all(self, context_str: str) -> None: """Clear all invocation records for a given context.""" async with self._storage_engine.get_unit_of_work() as uow: diff --git a/src/alib/py/framework/jupiter/framework/storage/sqlite/events.py b/src/alib/py/framework/jupiter/framework/storage/sqlite/events.py index a01de0509..bc79b990d 100644 --- a/src/alib/py/framework/jupiter/framework/storage/sqlite/events.py +++ b/src/alib/py/framework/jupiter/framework/storage/sqlite/events.py @@ -1,8 +1,13 @@ """Common toolin for SQLite repositories.""" +import json from jupiter.framework.base.entity_id import EntityId +from jupiter.framework.base.mutation_id import MutationId +from jupiter.framework.base.timestamp import Timestamp +from jupiter.framework.base.trace_id import TraceId from jupiter.framework.entity import Entity -from jupiter.framework.event import Event +from jupiter.framework.event import Event, EventKind +from jupiter.framework.mutation_inovcation.entity_event import MutationEntityEvent from jupiter.framework.realm.realm import ( EncoderNotFoundError, EventStoreRealm, @@ -18,7 +23,9 @@ String, Table, delete, + func, insert, + select, ) from sqlalchemy.ext.asyncio import AsyncConnection @@ -99,6 +106,101 @@ def _serialize_event( return serialized_frame_args +async def find_entity_events_by_timestamp_desc( + realm_codec_registry: RealmCodecRegistry, + connection: AsyncConnection, + event_table: Table, + entity_type: str, + entity_ref_id: EntityId, + offset: int, + limit: int, +) -> tuple[list[MutationEntityEvent], int]: + """Find entity events paginated, ordered by timestamp descending.""" + if offset < 0: + raise ValueError("Offset must be non-negative but was {offset}") + if limit <= 0 or limit > 200: + raise ValueError("Limit must be between 1 and 200 but was {limit}") + base_where = [ + event_table.c.entity_type == entity_type, + event_table.c.entity_ref_id == entity_ref_id.as_int(), + ] + + count_stmt = select(func.count()).select_from(event_table).where(*base_where) + total_cnt = (await connection.execute(count_stmt)).scalar_one() + + query_stmt = ( + select(event_table) + .where(*base_where) + .order_by(event_table.c.timestamp.desc(), event_table.c.session_index.desc()) + .offset(offset) + .limit(limit) + ) + results = await connection.execute(query_stmt) + + events = [ + MutationEntityEvent( + entity_type=row.entity_type, + entity_ref_id=EntityId(str(row.entity_ref_id)), + entity_version=row.entity_version, + kind=EventKind(row.kind), + name=row.name, + trace_id=realm_codec_registry.db_decode(TraceId, row.trace_id), + mutation_id=realm_codec_registry.db_decode(MutationId, row.mutation_id), + timestamp=realm_codec_registry.db_decode(Timestamp, row.timestamp), + session_index=row.session_index, + source=row.source, + context_str=row.context_str, + data=json.dumps(row.data, indent=2) if row.data else "{}", + ) + for row in results + ] + + return events, total_cnt + + +async def find_entity_events_between( + realm_codec_registry: RealmCodecRegistry, + connection: AsyncConnection, + event_table: Table, + entity_type: str, + entity_ref_id: EntityId, + start: Timestamp, + end: Timestamp, +) -> list[MutationEntityEvent]: + """Find all entity events between two timestamps, ordered by timestamp descending.""" + if start > end: + raise ValueError("Start timestamp must be before end timestamp") + query_stmt = ( + select(event_table) + .where( + event_table.c.entity_type == entity_type, + event_table.c.entity_ref_id == entity_ref_id.as_int(), + event_table.c.timestamp >= realm_codec_registry.db_encode(start), + event_table.c.timestamp <= realm_codec_registry.db_encode(end), + ) + .order_by(event_table.c.timestamp.desc(), event_table.c.session_index.desc()) + ) + results = await connection.execute(query_stmt) + + return [ + MutationEntityEvent( + entity_type=row.entity_type, + entity_ref_id=EntityId(str(row.entity_ref_id)), + entity_version=row.entity_version, + kind=EventKind(row.kind), + name=row.name, + trace_id=realm_codec_registry.db_decode(TraceId, row.trace_id), + mutation_id=realm_codec_registry.db_decode(MutationId, row.mutation_id), + timestamp=realm_codec_registry.db_decode(Timestamp, row.timestamp), + session_index=row.session_index, + source=row.source, + context_str=row.context_str, + data=json.dumps(row.data, indent=2) if row.data else "{}", + ) + for row in results + ] + + async def remove_events( connection: AsyncConnection, event_table: Table, diff --git a/src/alib/py/framework/jupiter/framework/use_case.py b/src/alib/py/framework/jupiter/framework/use_case.py index 22c2339d4..084624b96 100644 --- a/src/alib/py/framework/jupiter/framework/use_case.py +++ b/src/alib/py/framework/jupiter/framework/use_case.py @@ -33,7 +33,7 @@ GlobalProperties, UnavailableGloballyError, ) -from jupiter.framework.mutation_inovcation.record import ( +from jupiter.framework.mutation_inovcation.invocation_record import ( MutationInvocationRecord, ) from jupiter.framework.mutation_inovcation.recorder import ( @@ -324,16 +324,19 @@ class ReadonlyUseCase( """A command which only does reads.""" _realm_codec_registry: Final[RealmCodecRegistry] + _invocation_recorder: Final[MutationInvocationRecorder] def __init__( self, ports: _PortsT, global_properties: _GlobalPropertiesT, realm_codec_registry: RealmCodecRegistry, + invocation_recorder: MutationInvocationRecorder, ) -> None: """Constructor.""" super().__init__(ports, global_properties) self._realm_codec_registry = realm_codec_registry + self._invocation_recorder = invocation_recorder async def execute( self, @@ -502,10 +505,13 @@ def __init__( global_properties: _GlobalPropertiesT, time_provider: TimeProvider, realm_codec_registry: RealmCodecRegistry, + invocation_recorder: MutationInvocationRecorder, auth_token_stamper: AuthTokenStamper, ) -> None: """Constructor.""" - super().__init__(ports, global_properties, realm_codec_registry) + super().__init__( + ports, global_properties, realm_codec_registry, invocation_recorder + ) self._time_provider = time_provider self._auth_token_stamper = auth_token_stamper @@ -848,10 +854,13 @@ def __init__( global_properties: _GlobalPropertiesT, time_provider: TimeProvider, realm_codec_registry: RealmCodecRegistry, + invocation_recorder: MutationInvocationRecorder, auth_token_stamper: AuthTokenStamper, ) -> None: """Constructor.""" - super().__init__(ports, global_properties, realm_codec_registry) + super().__init__( + ports, global_properties, realm_codec_registry, invocation_recorder + ) self._time_provider = time_provider self._auth_token_stamper = auth_token_stamper diff --git a/src/alib/py/framework/jupiter/framework/utils/generic_support_entity_explorer.py b/src/alib/py/framework/jupiter/framework/utils/generic_support_entity_explorer.py new file mode 100644 index 000000000..817882bf1 --- /dev/null +++ b/src/alib/py/framework/jupiter/framework/utils/generic_support_entity_explorer.py @@ -0,0 +1,40 @@ +"""A generic explorer for linked support entities.""" + +from jupiter.framework.base.entity_id import EntityId +from jupiter.framework.entity import ( + ContainsLink, + CrownEntity, + LeafSupportEntity, + OwnsLink, +) +from jupiter.framework.storage.repository import DomainUnitOfWork + + +async def generic_support_entity_explorer( + uow: DomainUnitOfWork, + entity: CrownEntity, +) -> list[tuple[str, EntityId]]: + """Return all linked LeafSupportEntity class names and ref ids owned or contained by an entity.""" + result: list[tuple[str, EntityId]] = [] + + for field in entity.__class__.__dict__.values(): + if not (isinstance(field, OwnsLink) or isinstance(field, ContainsLink)): + continue + if not issubclass(field.the_type, CrownEntity): + continue + + linked_entities = await uow.get_for(field.the_type).find_all_generic( + parent_ref_id=None, + allow_archived=True, + **field.get_for_entity(entity), + ) + + for linked_entity in linked_entities: + if isinstance(linked_entity, LeafSupportEntity): + result.append((linked_entity.__class__.__name__, linked_entity.ref_id)) + else: + result.extend( + await generic_support_entity_explorer(uow, linked_entity) + ) + + return result diff --git a/src/cli/jupiter/cli/config.py b/src/cli/jupiter/cli/config.py index 0ada5ed6e..d1a7270b9 100644 --- a/src/cli/jupiter/cli/config.py +++ b/src/cli/jupiter/cli/config.py @@ -32,6 +32,7 @@ ) from jupiter.framework.appform.cli.exception import CliExceptionHandler from jupiter.framework.appform.cli.session_storage import SessionInfo +from jupiter.framework.base.trace_id import TraceId from jupiter.framework.service_properties import ServiceProperties from jupiter.framework.use_case_io import UseCaseResultBase @@ -125,6 +126,7 @@ def _build_session( # type: ignore distribution=AppDistribution.MAC_WEB, version=self._global_properties.version, ), + TraceId.new(), session_info.auth_token_ext if session_info else None, ) @@ -153,6 +155,7 @@ def _build_session( # type: ignore distribution=AppDistribution.MAC_WEB, version=self._global_properties.version, ), + TraceId.new(), session_info.auth_token_ext if session_info else None, ) @@ -181,6 +184,7 @@ def _build_session( # type: ignore distribution=AppDistribution.MAC_WEB, version=self._global_properties.version, ), + TraceId.new(), session_info.auth_token_ext, ) @@ -209,6 +213,7 @@ def _build_session( # type: ignore distribution=AppDistribution.MAC_WEB, version=self._global_properties.version, ), + TraceId.new(), session_info.auth_token_ext, ) diff --git a/src/core/jupiter/core/config.py b/src/core/jupiter/core/config.py index 16f64b251..8f2eb56fb 100644 --- a/src/core/jupiter/core/config.py +++ b/src/core/jupiter/core/config.py @@ -28,6 +28,7 @@ from jupiter.core.users.root import User from jupiter.core.workspaces.root import Workspace from jupiter.framework.auth.auth_token import AuthToken +from jupiter.framework.base.entity_id import EntityId, EntityIdDatabaseDecoder from jupiter.framework.component_properties import ComponentProperties from jupiter.framework.context import DomainContext from jupiter.framework.global_properties import GlobalProperties @@ -55,6 +56,8 @@ _UseCaseArgsT = TypeVar("_UseCaseArgsT", bound=UseCaseArgsBase) _UseCaseResultT = TypeVar("_UseCaseResultT", bound=Union[None, UseCaseResultBase]) +_ENTITY_ID_DECODER = EntityIdDatabaseDecoder() + @dataclass(frozen=True) class JupiterPorts(DomainPorts): @@ -303,6 +306,19 @@ def as_str(self) -> str: """The string representation of the context.""" return f"user:{self.user.ref_id}+workspace:{self.workspace.ref_id}" + @staticmethod + def unwrap_str(context_str: str) -> tuple[EntityId, EntityId]: + """Unwrap the context string into a tuple of user and workspace IDs.""" + try: + part_user, part_workspace = context_str.split("+") + _, user_id = part_user.split(":") + _, workspace_id = part_workspace.split(":") + return _ENTITY_ID_DECODER.decode(user_id), _ENTITY_ID_DECODER.decode( + workspace_id + ) + except ValueError as e: + raise Exception("Could not unwrap context str") from e + def allows( self, only_for: list[EnumValue | list[EnumValue]] | None ) -> EnumValue | None: @@ -342,6 +358,19 @@ def as_str(self) -> str: """The string representation of the context.""" return f"user:{self.user.ref_id}+workspace:{self.workspace.ref_id}" + @staticmethod + def unwrap_str(context_str: str) -> tuple[EntityId, EntityId]: + """Unwrap the context string into a tuple of user and workspace IDs.""" + try: + part_user, part_workspace = context_str.split("+") + _, user_id = part_user.split(":") + _, workspace_id = part_workspace.split(":") + return _ENTITY_ID_DECODER.decode(user_id), _ENTITY_ID_DECODER.decode( + workspace_id + ) + except ValueError as e: + raise Exception("Could not unwrap context str") from e + def allows( self, only_for: list[EnumValue | list[EnumValue]] | None ) -> EnumValue | None: diff --git a/src/core/jupiter/core/infra/__init__.py b/src/core/jupiter/core/infra/__init__.py index 11ba43467..4f56f0f45 100644 --- a/src/core/jupiter/core/infra/__init__.py +++ b/src/core/jupiter/core/infra/__init__.py @@ -1 +1,3 @@ """Common infrastructure for the Jupiter core.""" + +SLICE_TAG = "Infra" diff --git a/src/core/jupiter/core/infra/component/layout/branch-panel.tsx b/src/core/jupiter/core/infra/component/layout/branch-panel.tsx index b44ba3f0f..226b050f2 100644 --- a/src/core/jupiter/core/infra/component/layout/branch-panel.tsx +++ b/src/core/jupiter/core/infra/component/layout/branch-panel.tsx @@ -5,6 +5,7 @@ import { Close as CloseIcon, Delete as DeleteIcon, DeleteForever as DeleteForeverIcon, + History as HistoryIcon, } from "@mui/icons-material"; import { Box, @@ -36,6 +37,8 @@ import { import { useBigScreen } from "#/core/infra/component/use-big-screen"; import { useHydrated } from "#/core/infra/component/use-hidrated"; import { useTrunkNeedsToShowLeaf } from "#/core/infra/component/use-nested-entities"; +import type { EntityId, NamedEntityTag } from "@jupiter/webapi-client"; +import { EntityMutationHistoryPanel } from "#/core/infra/component/layout/entity-mutation-history-panel"; const SMALL_SCREEN_ANIMATION_START = "100vw"; const SMALL_SCREEN_ANIMATION_END = "100vw"; @@ -43,6 +46,8 @@ const SMALL_SCREEN_ANIMATION_END = "100vw"; interface BranchPanelProps { createLocation?: string; showArchiveAndRemoveButton?: boolean; + entityType?: NamedEntityTag; + entityRefId?: EntityId; inputsEnabled?: boolean; entityArchived?: boolean; actions?: JSX.Element; @@ -57,6 +62,9 @@ export function BranchPanel(props: PropsWithChildren) { const isHydrated = useHydrated(); const shouldShowALeaf = useTrunkNeedsToShowLeaf(); const [showArchiveDialog, setShowArchiveDialog] = useState(false); + const [showHistory, setShowHistory] = useState(false); + + const hasHistory = props.entityType !== undefined && props.entityRefId !== undefined; // This little function is a hack to get around the fact that Framer Motion // generates a translateX(Xpx) CSS applied to the StyledMotionDrawer element. @@ -189,11 +197,20 @@ export function BranchPanel(props: PropsWithChildren) { {props.actions} + {hasHistory && ( + setShowHistory((h) => !h)} + > + + + )} + {props.showArchiveAndRemoveButton && ( <> setShowArchiveDialog(true)} @@ -238,7 +255,7 @@ export function BranchPanel(props: PropsWithChildren) { ) { )} - - {props.children} - + {showHistory && hasHistory ? ( + + + + ) : ( + + {props.children} + + )} ); } diff --git a/src/core/jupiter/core/infra/component/layout/entity-mutation-history-panel.tsx b/src/core/jupiter/core/infra/component/layout/entity-mutation-history-panel.tsx new file mode 100644 index 000000000..30d930040 --- /dev/null +++ b/src/core/jupiter/core/infra/component/layout/entity-mutation-history-panel.tsx @@ -0,0 +1,249 @@ +import { + ExpandMore as ExpandMoreIcon, +} from "@mui/icons-material"; +import { + Box, + CircularProgress, + Collapse, + IconButton, + Stack, + ToggleButton, + ToggleButtonGroup, + Typography, +} from "@mui/material"; +import type { HistoryEntry, User } from "@jupiter/webapi-client"; +import { useFetcher } from "@remix-run/react"; +import { DateTime } from "luxon"; +import { useEffect, useState } from "react"; + +import type { EntityId, NamedEntityTag } from "@jupiter/webapi-client"; + +interface HistoryFetcherData { + entries: HistoryEntry[]; + users: User[]; + totalCnt: number; + pageSize: number; +} + +interface EntityMutationHistoryPanelProps { + entityType: NamedEntityTag; + entityRefId: EntityId; +} + +export function EntityMutationHistoryPanel( + props: EntityMutationHistoryPanelProps, +) { + const fetcher = useFetcher(); + const [currentPage, setCurrentPage] = useState(0); + + useEffect(() => { + const params = new URLSearchParams({ + entityType: props.entityType, + entityRefId: props.entityRefId, + }); + if (currentPage > 0) { + params.set( + "retrieveOffset", + (currentPage * (fetcher.data?.pageSize ?? 50)).toString(), + ); + } + fetcher.load( + `/app/workspace/infra/entity-mutation-history?${params.toString()}`, + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.entityType, props.entityRefId, currentPage]); + + if (fetcher.state === "loading" && !fetcher.data) { + return ( + + + + ); + } + + if (!fetcher.data) { + return null; + } + + const { entries, users, totalCnt, pageSize } = fetcher.data; + + const usersById = Object.fromEntries(users.map((u) => [u.ref_id, u])); + + return ( + + Entity History + + + + {entries.length === 0 && ( + + No history entries found. + + )} + + {entries.map((entry, idx) => ( + + ))} + + {entries.length > 0 && ( + + )} + + + + ); +} + +function eventKindVerb(kind: string): string { + switch (kind) { + case "Created": + return "created"; + case "Updated": + return "updated"; + case "Archived": + return "archived"; + default: + return kind.toLowerCase(); + } +} + +interface HistoryEntryRowProps { + entry: HistoryEntry; + user: User | undefined; +} + +function stripUseCaseSuffix(name: string): string { + return name.replace(/UseCase$/, ""); +} + +function HistoryEntryRow({ entry, user }: HistoryEntryRowProps) { + const [showData, setShowData] = useState(false); + const formattedTimestamp = DateTime.fromISO(entry.timestamp).toLocaleString( + DateTime.DATETIME_MED, + ); + const mutationName = stripUseCaseSuffix(entry.mutation_name); + const userName = user?.name ?? "Unknown"; + + return ( + + + {userName} {eventKindVerb(entry.event_kind)}{" "} + {(entry as HistoryEntry & { entity_name?: string }).entity_name ?? entry.event_name} + {" "}in mutation {mutationName}{"::"} + {entry.event_name} + + + + {formattedTimestamp} · v{entry.entity_version} ·{" "} + {entry.source} + + setShowData((s) => !s)}> + + + + + + {(entry as HistoryEntry & { data?: string }).data} + + + + ); +} + +interface HistoryPagesProps { + currentPage: number; + totalCnt: number; + pageSize: number; + onPageChange: (page: number) => void; +} + +function HistoryPages(props: HistoryPagesProps) { + const pageCount = Math.ceil(props.totalCnt / props.pageSize); + + if (pageCount <= 1) { + return null; + } + + const shouldShowPage = Array(pageCount).fill(false); + shouldShowPage[0] = true; + shouldShowPage[pageCount - 1] = true; + + for (let delta = -3; delta <= 3; delta++) { + const idx = props.currentPage + delta; + if (idx >= 0 && idx < pageCount) { + shouldShowPage[idx] = true; + } + } + + const buttons = []; + for (let i = 0; i < pageCount; i++) { + if (shouldShowPage[i]) { + buttons.push( + props.onPageChange(i)} + > + {i + 1} + , + ); + } else if (i > 0 && shouldShowPage[i - 1]) { + buttons.push( + + ... + , + ); + } + } + + return ( + + {buttons} + + ); +} diff --git a/src/core/jupiter/core/infra/component/layout/leaf-panel.tsx b/src/core/jupiter/core/infra/component/layout/leaf-panel.tsx index be1bdfbaa..a7d70083d 100644 --- a/src/core/jupiter/core/infra/component/layout/leaf-panel.tsx +++ b/src/core/jupiter/core/infra/component/layout/leaf-panel.tsx @@ -4,6 +4,7 @@ import { ArrowUpward as ArrowUpwardIcon, Delete as DeleteIcon, DeleteForever as DeleteForeverIcon, + History as HistoryIcon, KeyboardDoubleArrowRight as KeyboardDoubleArrowRightIcon, PictureInPictureAlt as PictureInPictureAltIcon, SwitchLeft as SwitchLeftIcon, @@ -36,6 +37,8 @@ import { saveScrollPosition, } from "#/core/infra/scroll-restoration"; import { useBigScreen } from "#/core/infra/component/use-big-screen"; +import { EntityId, NamedEntityTag } from "@jupiter/webapi-client"; +import { EntityMutationHistoryPanel } from "#/core/infra/component/layout/entity-mutation-history-panel"; const BIG_SCREEN_ANIMATION_START = "480px"; const BIG_SCREEN_ANIMATION_END = "480px"; @@ -54,6 +57,8 @@ interface LeafPanelProps { isLeaflet?: boolean; showArchiveButton?: boolean; showArchiveAndRemoveButton?: boolean; + entityType?: NamedEntityTag; + entityRefId?: EntityId; fakeKey: string; inputsEnabled: boolean; entityNotEditable?: boolean; @@ -88,7 +93,9 @@ export function LeafPanel(props: PropsWithChildren) { BIG_SCREEN_WIDTH_FULL_INT, ); const [showArchiveDialog, setShowArchiveDialog] = useState(false); + const [showHistory, setShowHistory] = useState(false); + const hasHistory = props.entityType !== undefined && props.entityRefId !== undefined; const showArchiveButNotRemove = props.showArchiveButton && !props.showArchiveAndRemoveButton; @@ -356,11 +363,20 @@ export function LeafPanel(props: PropsWithChildren) { + {hasHistory && ( + setShowHistory((h) => !h)} + > + + + )} + {(props.showArchiveButton || props.showArchiveAndRemoveButton) && ( <> ) { - {(isBigScreen || !props.shouldShowALeaflet) && ( + {showHistory && hasHistory ? ( - {props.children} - + - )} + ) : ( + <> + {(isBigScreen || !props.shouldShowALeaflet) && ( + + {props.children} + + + )} - {!isBigScreen && props.shouldShowALeaflet && <>{props.children}} + {!isBigScreen && props.shouldShowALeaflet && ( + <>{props.children} + )} + + )} ); @@ -513,3 +546,4 @@ function normalizeExpansionState( return expansionState; } + diff --git a/src/core/jupiter/core/infra/use_case/__init__.py b/src/core/jupiter/core/infra/use_case/__init__.py new file mode 100644 index 000000000..3241d40ba --- /dev/null +++ b/src/core/jupiter/core/infra/use_case/__init__.py @@ -0,0 +1 @@ +"""Use cases for application infrastructure concerns.""" diff --git a/src/core/jupiter/core/infra/use_case/get_entity_mutation_history.py b/src/core/jupiter/core/infra/use_case/get_entity_mutation_history.py new file mode 100644 index 000000000..1fbbb16a3 --- /dev/null +++ b/src/core/jupiter/core/infra/use_case/get_entity_mutation_history.py @@ -0,0 +1,170 @@ +"""Retrieve the history of mutations for a particular entity.""" + +from typing import ClassVar + +from jupiter.core.config import ( + JupiterLoggedInReadonlyContext, + JupiterLoggedInReadonlyUseCase, +) +from jupiter.core.named_entity_tag import NamedEntityTag +from jupiter.core.named_entity_tag_to_cls import NAMED_ENTITY_TAG_TO_CLS +from jupiter.core.users.root import User +from jupiter.framework.base.entity_id import EntityId +from jupiter.framework.base.timestamp import Timestamp +from jupiter.framework.errors import InputValidationError +from jupiter.framework.event import EventKind +from jupiter.framework.mutation_inovcation.entity_event import MutationEntityEvent +from jupiter.framework.mutation_inovcation.invocation_record import ( + MutationInvocationRecord, +) +from jupiter.framework.use_case import readonly_use_case +from jupiter.framework.use_case_io import ( + UseCaseArgsBase, + UseCaseResultBase, + use_case_args, + use_case_result, + use_case_result_part, +) +from jupiter.framework.utils.generic_support_entity_explorer import ( + generic_support_entity_explorer, +) + + +@use_case_args +class GetEntityMutationHistoryArgs(UseCaseArgsBase): + """Arguments for the entity mutation history.""" + + entity_type: NamedEntityTag + entity_ref_id: EntityId + retrieve_offset: int | None + retrieve_limit: int | None + + +@use_case_result_part +class HistoryEntry(UseCaseResultBase): + """An instance of the history.""" + + # Which entity + entity_name: str + # What + mutation_name: str + event_kind: str + event_name: str + # When + timestamp: Timestamp + # Who + source: str + user_ref_id: EntityId + # Data + entity_version: int + data: str + + +@use_case_result +class GetEntityMutationHistoryResult(UseCaseResultBase): + """Results for the entity mutation history.""" + + entries: list[HistoryEntry] + users: list[User] + total_cnt: int + page_size: int + + +@readonly_use_case() +class GetEntityMutationHistoryUseCase( + JupiterLoggedInReadonlyUseCase[ + GetEntityMutationHistoryArgs, GetEntityMutationHistoryResult + ] +): + """Use case for loading the history of mutations for an entity.""" + + _DEFAULT_OFFSET: ClassVar[int] = 0 + _DEFAULT_LIMIT: ClassVar[int] = 4 + _MAX_LIMIT: ClassVar[int] = 100 + + async def _execute( + self, + context: JupiterLoggedInReadonlyContext, + args: GetEntityMutationHistoryArgs, + ) -> GetEntityMutationHistoryResult: + """Execute the command's action.""" + retrieve_offset = args.retrieve_offset or self._DEFAULT_OFFSET + retrieve_limit = args.retrieve_limit or self._DEFAULT_LIMIT + if retrieve_offset < 0: + raise InputValidationError( + f"Retrieve limit needs to be positive but was {retrieve_offset}" + ) + if retrieve_limit <= 0 or retrieve_limit > self._MAX_LIMIT: + raise InputValidationError( + f"Retrieve limit needs to be between 0 and {self._MAX_LIMIT} but was {retrieve_limit}" + ) + + main_events, total_cnt = ( + await self._invocation_recorder.find_all_entity_events_by_timestamp_desc( + args.entity_type.value, + args.entity_ref_id, + retrieve_offset, + retrieve_limit, + ) + ) + + linked_events: list[MutationEntityEvent] = [] + if main_events: + earliest = min(e.timestamp for e in main_events).subtract_minutes(10) + latest = max(e.timestamp for e in main_events).add_minutes(30) + + entity_cls = NAMED_ENTITY_TAG_TO_CLS.get(args.entity_type) + if entity_cls is not None: + async with self._ports.domain_storage_engine.get_unit_of_work() as uow: + entity = await uow.get_for(entity_cls).load_by_id( + args.entity_ref_id, allow_archived=True, + ) + linked_refs = await generic_support_entity_explorer(uow, entity) + + for linked_type_name, linked_ref_id in linked_refs: + events = await self._invocation_recorder.find_all_entity_events_between( + linked_type_name, + linked_ref_id, + earliest, + latest, + ) + linked_events.extend(events) + + all_events = main_events + linked_events + all_events.sort(key=lambda e: e.timestamp, reverse=True) + + all_mutations: list[MutationInvocationRecord] = ( + await self._invocation_recorder.find_all_invocation_records( + list({m.mutation_id for m in all_events}) + ) + ) + all_mutations_by_ref_id = {m.mutation_id: m for m in all_mutations} + + async with self._ports.domain_storage_engine.get_unit_of_work() as uow: + all_users = await uow.get_for(User).find_all( + allow_archived=True, + filter_ref_ids=[ + JupiterLoggedInReadonlyContext.unwrap_str(e.context_str)[0] + for e in all_events + ], + ) + + return GetEntityMutationHistoryResult( + entries=[ + HistoryEntry( + entity_name=e.entity_type, + mutation_name=all_mutations_by_ref_id[e.mutation_id].name, + event_kind=e.kind.value, + event_name=e.name, + timestamp=e.timestamp, + source=e.source, + user_ref_id=JupiterLoggedInReadonlyContext.unwrap_str(e.context_str)[0], + entity_version=e.entity_version, + data=e.data, + ) + for e in all_events + ], + users=all_users, + total_cnt=total_cnt, + page_size=self._DEFAULT_LIMIT, + ) diff --git a/src/core/jupiter/core/named_entity_tag_to_cls.py b/src/core/jupiter/core/named_entity_tag_to_cls.py new file mode 100644 index 000000000..a35a9ee6d --- /dev/null +++ b/src/core/jupiter/core/named_entity_tag_to_cls.py @@ -0,0 +1,71 @@ +"""Mapping from NamedEntityTag to the corresponding entity class.""" + +from jupiter.core.big_plans.root import BigPlan +from jupiter.core.big_plans.sub.milestones.root import BigPlanMilestone +from jupiter.core.chores.root import Chore +from jupiter.core.docs.root import Doc +from jupiter.core.gamification.score_log_entry import ScoreLogEntry +from jupiter.core.habits.root import Habit +from jupiter.core.home.sub.tab.root import HomeTab +from jupiter.core.home.sub.widget.root import HomeWidget +from jupiter.core.journals.root import Journal +from jupiter.core.life_plan.sub.aspects.root import Aspect +from jupiter.core.life_plan.sub.chapters.root import Chapter +from jupiter.core.life_plan.sub.goals.root import Goal +from jupiter.core.life_plan.sub.milestones.root import Milestone +from jupiter.core.life_plan.sub.visions.root import Vision +from jupiter.core.metrics.root import Metric +from jupiter.core.metrics.sub.entry.root import MetricEntry +from jupiter.core.named_entity_tag import NamedEntityTag +from jupiter.core.prm.sub.circle.root import Circle +from jupiter.core.prm.sub.person.root import Person +from jupiter.core.prm.sub.person.sub.occasion.root import Occasion +from jupiter.core.push_integrations.sub.email.task import EmailTask +from jupiter.core.push_integrations.sub.slack.task import SlackTask +from jupiter.core.schedule.sub.event_full_days.root import ScheduleEventFullDays +from jupiter.core.schedule.sub.event_in_day.root import ScheduleEventInDay +from jupiter.core.schedule.sub.export.root import ScheduleExport +from jupiter.core.schedule.sub.external_sync_log.root import ScheduleExternalSyncLog +from jupiter.core.schedule.sub.stream.root import ScheduleStream +from jupiter.core.smart_lists.root import SmartList +from jupiter.core.smart_lists.sub.item.root import SmartListItem +from jupiter.core.time_plans.root import TimePlan +from jupiter.core.time_plans.sub.activity.root import TimePlanActivity +from jupiter.core.todo.root import TodoTask +from jupiter.core.vacations.root import Vacation +from jupiter.framework.entity import CrownEntity + +NAMED_ENTITY_TAG_TO_CLS: dict[NamedEntityTag, type[CrownEntity]] = { + NamedEntityTag.SCORE_LOG_ENTRY: ScoreLogEntry, + NamedEntityTag.HOME_TAB: HomeTab, + NamedEntityTag.HOME_WIDGET: HomeWidget, + NamedEntityTag.TODO_TASK: TodoTask, + NamedEntityTag.TIME_PLAN: TimePlan, + NamedEntityTag.TIME_PLAN_ACTIVITY: TimePlanActivity, + NamedEntityTag.SCHEDULE_STREAM: ScheduleStream, + NamedEntityTag.SCHEDULE_EXPORT: ScheduleExport, + NamedEntityTag.SCHEDULE_EVENT_IN_DAY: ScheduleEventInDay, + NamedEntityTag.SCHEDULE_EVENT_FULL_DAYS_BLOCK: ScheduleEventFullDays, + NamedEntityTag.SCHEDULE_EXTERNAL_SYNC_LOG: ScheduleExternalSyncLog, + NamedEntityTag.HABIT: Habit, + NamedEntityTag.CHORE: Chore, + NamedEntityTag.BIG_PLAN: BigPlan, + NamedEntityTag.BIG_PLAN_MILESTONE: BigPlanMilestone, + NamedEntityTag.DOC: Doc, + NamedEntityTag.JOURNAL: Journal, + NamedEntityTag.CHAPTER: Chapter, + NamedEntityTag.GOAL: Goal, + NamedEntityTag.MILESTONE: Milestone, + NamedEntityTag.VISION: Vision, + NamedEntityTag.VACATION: Vacation, + NamedEntityTag.ASPECT: Aspect, + NamedEntityTag.SMART_LIST: SmartList, + NamedEntityTag.SMART_LIST_ITEM: SmartListItem, + NamedEntityTag.METRIC: Metric, + NamedEntityTag.METRIC_ENTRY: MetricEntry, + NamedEntityTag.PERSON: Person, + NamedEntityTag.OCCASION: Occasion, + NamedEntityTag.CIRCLE: Circle, + NamedEntityTag.SLACK_TASK: SlackTask, + NamedEntityTag.EMAIL_TASK: EmailTask, +} diff --git a/src/core/jupiter/core/time_plans/root.py b/src/core/jupiter/core/time_plans/root.py index 8f3bdfeef..3db33f90b 100644 --- a/src/core/jupiter/core/time_plans/root.py +++ b/src/core/jupiter/core/time_plans/root.py @@ -11,6 +11,7 @@ from jupiter.core.common.sub.tags.namespace import TagNamespace from jupiter.core.common.sub.tags.sub.link.root import TagLink from jupiter.core.common.timeline import infer_timeline +from jupiter.core.time_plans.life_plan_links import TimePlanAspectLink, TimePlanChapterLink, TimePlanGoalLink from jupiter.core.time_plans.source import TimePlanSource from jupiter.core.time_plans.sub.activity.root import TimePlanActivity from jupiter.framework.base.adate import ADate @@ -28,6 +29,7 @@ entity, update_entity_action, ) +from jupiter.framework.record import ContainsManyRecords from jupiter.framework.storage.repository import ( EntityAlreadyExistsError, LeafEntityRepository, @@ -57,6 +59,9 @@ class TimePlan(LeafEntity): end_date: ADate activities = ContainsMany(TimePlanActivity, time_plan_ref_id=IsRefId()) + time_plan_aspect_links = ContainsManyRecords(TimePlanAspectLink, time_plan_ref_id=IsRefId()) + time_plan_chapter_links = ContainsManyRecords(TimePlanChapterLink, time_plan_ref_id=IsRefId()) + time_plan_goal_links = ContainsManyRecords(TimePlanGoalLink, time_plan_ref_id=IsRefId()) note = OwnsOne( Note, namespace=NoteNamespace.TIME_PLAN, source_entity_ref_id=IsRefId() ) @@ -66,6 +71,7 @@ class TimePlan(LeafEntity): planning_task = OwnsAtMostOne( InboxTask, source=InboxTaskSource.TIME_PLAN, source_entity_ref_id=IsRefId() ) + @staticmethod @create_entity_action diff --git a/src/core/jupiter/core/todo/components/properties-editor.tsx b/src/core/jupiter/core/todo/components/properties-editor.tsx index db883d5e9..b70fe897c 100644 --- a/src/core/jupiter/core/todo/components/properties-editor.tsx +++ b/src/core/jupiter/core/todo/components/properties-editor.tsx @@ -55,6 +55,7 @@ import { InboxTaskStatusBigTag } from "#/core/common/sub/inbox_tasks/component/s import { lifePlanBirthdayDate } from "#/core/life_plan/root"; import { LifePlanAssociations } from "#/core/life_plan/components/life-plan-associations"; import { isWorkspaceFeatureAvailable } from "#/core/workspaces/root"; +import { useBigScreen } from "#/core/infra/component/use-big-screen"; interface TodoTaskPropertiesEditorProps { title: string; @@ -84,6 +85,7 @@ export function TodoTaskPropertiesEditor(props: TodoTaskPropertiesEditorProps) { const [selectedAspectRefId, setSelectedAspectRefId] = useState( props.todoTask.aspect_ref_id, ); + const isBigScreen = useBigScreen(); return ( - + parseInt(s, 10)) + .optional(), +}); + +export async function loader({ request }: LoaderFunctionArgs) { + const apiClient = await getLoggedInApiClient(request); + const query = parseQuery(request, QuerySchema); + + const result = await apiClient.infra.getEntityMutationHistory({ + entity_type: query.entityType as any, + entity_ref_id: query.entityRefId, + retrieve_offset: query.retrieveOffset, + }); + + return json({ + entries: result.entries, + users: result.users, + totalCnt: result.total_cnt, + pageSize: result.page_size, + }); +} diff --git a/src/webui/app/routes/app/workspace/journals/$id.tsx b/src/webui/app/routes/app/workspace/journals/$id.tsx index a3dc870fe..81ae7d9f6 100644 --- a/src/webui/app/routes/app/workspace/journals/$id.tsx +++ b/src/webui/app/routes/app/workspace/journals/$id.tsx @@ -1,5 +1,6 @@ import { ApiError, + NamedEntityTag, RecurringTaskPeriod, TagNamespace, WorkspaceFeature, @@ -209,6 +210,8 @@ export default function Journal() { return ( ( [], ); + const [selectedTimeFilter, setSelectedTimeFilter] = useState< + "all" | "week" | "month" | "quarter" | "year" + >("all"); + const [timeOffset, setTimeOffset] = useState(0); const tagsByMetricEntryRefId = new Map(); for (const et of loaderData.metricEntryTags) { @@ -181,16 +190,55 @@ export default function Metric() { return -compareADate(e1.collection_time, e2.collection_time); }); + const today = aDateToDate(topLevelInfo.today); + + function getTimeBounds() { + if (selectedTimeFilter === "all") return null; + const o = timeOffset; + switch (selectedTimeFilter) { + case "week": + return { + start: today.minus({ weeks: o + 1 }), + end: today.minus({ weeks: o }), + }; + case "month": + return { + start: today.minus({ months: o + 1 }), + end: today.minus({ months: o }), + }; + case "quarter": + return { + start: today.minus({ months: (o + 1) * 3 }), + end: today.minus({ months: o * 3 }), + }; + case "year": + return { + start: today.minus({ years: o + 1 }), + end: today.minus({ years: o }), + }; + } + } + + const timeBounds = getTimeBounds(); + const timeFilteredEntries = allEntriesSorted.filter((entry) => { + if (!timeBounds) return true; + const entryMs = aDateToDate(entry.collection_time).toMillis(); + return ( + entryMs >= timeBounds.start.toMillis() && + entryMs <= timeBounds.end.toMillis() + ); + }); + // Build a lookup from ref_id to the previous entry (older, one position later in sorted array) const previousEntryByRefId = new Map(); - for (let i = 0; i < allEntriesSorted.length - 1; i++) { + for (let i = 0; i < timeFilteredEntries.length - 1; i++) { previousEntryByRefId.set( - allEntriesSorted[i].ref_id, - allEntriesSorted[i + 1], + timeFilteredEntries[i].ref_id, + timeFilteredEntries[i + 1], ); } - const sortedEntries = allEntriesSorted.filter((entry) => { + const sortedEntries = timeFilteredEntries.filter((entry) => { const tags = tagsByMetricEntryRefId.get(entry.ref_id) || []; const tagsOk = selectedTagsRefId.length === 0 || @@ -239,6 +287,8 @@ export default function Metric() { inputsEnabled={inputsEnabled} entityArchived={loaderData.metric.archived} key={`metric-${loaderData.metric.ref_id}`} + entityType={NamedEntityTag.METRIC} + entityRefId={loaderData.metric.ref_id} createLocation={`/app/workspace/metrics/${loaderData.metric.ref_id}/entries/new`} returnLocation="/app/workspace/metrics" actions={ @@ -252,6 +302,38 @@ export default function Metric() { icon: , link: `/app/workspace/metrics/${loaderData.metric.ref_id}/details`, }), + FilterFewOptionsCompact( + "Time", + "all", + [ + { value: "all", text: "All" }, + { value: "week", text: "Week" }, + { value: "month", text: "Month" }, + { value: "quarter", text: "Quarter" }, + { value: "year", text: "Year" }, + ], + (selected) => { + setSelectedTimeFilter( + selected as "all" | "week" | "month" | "quarter" | "year", + ); + setTimeOffset(0); + }, + ), + ...(selectedTimeFilter !== "all" + ? [ + ButtonSingle({ + text: "Prev", + icon: , + onClick: () => setTimeOffset((o) => o + 1), + }), + ButtonSingle({ + text: "Next", + icon: , + disabled: timeOffset === 0, + onClick: () => setTimeOffset((o) => Math.max(0, o - 1)), + }), + ] + : []), FilterManyOptions( "Tags", loaderData.allTags.map((tag) => ({ diff --git a/src/webui/app/routes/app/workspace/metrics/$id/details.tsx b/src/webui/app/routes/app/workspace/metrics/$id/details.tsx index c6b5a67a9..f8fdbb5df 100644 --- a/src/webui/app/routes/app/workspace/metrics/$id/details.tsx +++ b/src/webui/app/routes/app/workspace/metrics/$id/details.tsx @@ -1,4 +1,5 @@ import type { InboxTask } from "@jupiter/webapi-client"; +import { NamedEntityTag } from "@jupiter/webapi-client"; import { ApiError, Difficulty, @@ -332,6 +333,8 @@ export default function MetricDetails() { return ( - + - +