Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Support for List Operation Audit Logging #358

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2023 - 2024 ANSYS, Inc. and/or its affiliates.
Copyright (c) 2023 - 2025 ANSYS, Inc. and/or its affiliates.

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
Expand Down
1 change: 1 addition & 0 deletions doc/changelog.d/358.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add Support for List Operation Audit Logging
1,081 changes: 550 additions & 531 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ packages = [
python = ">=3.10,<4.0"
ansys-openapi-common = "^2.0.0"
# Ensure PIP_INDEX_URL is not set in CI when reverting to a public version of the package.
ansys-grantami-serverapi-openapi = "^4.0.0"
ansys-grantami-serverapi-openapi = { version = "4.0.0.dev368", allow-prereleases = true, source = "private-pypi" }
requests = "^2.26"

# Optional documentation dependencies
Expand Down
7 changes: 6 additions & 1 deletion src/ansys/grantami/recordlists/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.
# Copyright (C) 2023 - 2025 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
Expand Down Expand Up @@ -26,6 +26,9 @@

from ._connection import Connection, RecordListsApiClient
from ._models import (
AuditLogAction,
AuditLogItem,
AuditLogSearchCriterion,
BooleanCriterion,
RecordList,
RecordListItem,
Expand All @@ -36,6 +39,8 @@
)

__all__ = [
"AuditLogAction",
"AuditLogSearchCriterion" "AuditLogItem",
"Connection",
"RecordListsApiClient",
"BooleanCriterion",
Expand Down
59 changes: 57 additions & 2 deletions src/ansys/grantami/recordlists/_connection.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.
# Copyright (C) 2023 - 2025 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
Expand Down Expand Up @@ -36,7 +36,15 @@
import requests # type: ignore[import]

from ._logger import logger
from ._models import BooleanCriterion, RecordList, RecordListItem, SearchCriterion, SearchResult
from ._models import (
AuditLogItem,
AuditLogSearchCriterion,
BooleanCriterion,
RecordList,
RecordListItem,
SearchCriterion,
SearchResult,
)

PROXY_PATH = "/proxy/v1.svc/mi"
AUTH_PATH = "/Health/v2.svc"
Expand Down Expand Up @@ -98,6 +106,7 @@ def __init__(
self.list_management_api = api.ListManagementApi(self)
self.list_item_api = api.ListItemApi(self)
self.list_permissions_api = api.ListPermissionsApi(self)
self.list_audit_log_api = api.ListAuditLogApi(self)

def __repr__(self) -> str:
"""Printable representation of the object."""
Expand Down Expand Up @@ -601,6 +610,52 @@ def unsubscribe_from_list(self, record_list: RecordList) -> None:
list_identifier=record_list.identifier,
)

def get_all_audit_log_entries(self) -> List[AuditLogItem]:
"""
Fetch all audit log entries that are visible to the current user.

Performs an HTTP request against the Granta MI Server API.

.. versionadded:: 1.2

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.. versionadded:: 1.2
.. versionadded:: 1.4

Returns
-------
list of :class:`.AuditLogItem`
Audit log entries.
"""
criterion = AuditLogSearchCriterion()
return self.search_for_audit_log_entries(criterion=criterion)

def search_for_audit_log_entries(
self, criterion: AuditLogSearchCriterion
) -> List[AuditLogItem]:
"""
Fetch audit log entries, filtered by a search criterion.

Performs an HTTP request against the Granta MI Server API.

.. versionadded:: 1.4

Parameters
----------
criterion : AuditLogSearchCriterion
Criterion by which to filter audit log entries.

Returns
-------
list of :class:`.AuditLogItem`
Audit log entries.
"""
response = self.list_audit_log_api.run_list_audit_log_search(body=criterion._to_model())
result_id = response.search_result_identifier
print(result_id)

search_result = self.list_audit_log_api.get_list_audit_log_search_results(
result_resource_identifier=result_id
)
results = [AuditLogItem._from_model(item) for item in search_result]
return sorted(results, key=lambda item: item.timestamp)


class _ItemResolver:
_max_requests = 5
Expand Down
2 changes: 1 addition & 1 deletion src/ansys/grantami/recordlists/_logger.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.
# Copyright (C) 2023 - 2025 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
Expand Down
200 changes: 198 additions & 2 deletions src/ansys/grantami/recordlists/_models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.
# Copyright (C) 2023 - 2025 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
Expand All @@ -24,7 +24,7 @@

from datetime import datetime
from enum import Enum
from typing import List, Optional, Union
from typing import List, Optional, Set, Union

from ansys.grantami.serverapi_openapi import models # type: ignore[import]
from ansys.openapi.common import Unset # type: ignore[import]
Expand Down Expand Up @@ -762,3 +762,199 @@ def _from_model(
record_list=RecordList._from_model(model.header),
items=items,
)


class AuditLogAction(str, Enum):
"""Action logged involving a specific record list.

Can be used in :attr:`AuditLogSearchCriterion.filter_actions`.
"""

LISTCREATED = models.GsaListAction.LISTCREATED.value
LISTDELETED = models.GsaListAction.LISTDELETED.value
ITEMADDED = models.GsaListAction.ITEMADDED.value
ITEMREMOVED = models.GsaListAction.ITEMREMOVED.value
LISTNAMECHANGED = models.GsaListAction.LISTNAMECHANGED.value
LISTDESCRIPTIONCHANGED = models.GsaListAction.LISTDESCRIPTIONCHANGED.value
LISTNOTESCHANGED = models.GsaListAction.LISTNOTESCHANGED.value
LISTSETTOAWAITINGAPPROVAL = models.GsaListAction.LISTSETTOAWAITINGAPPROVAL.value
LISTAWAITINGAPPROVALREMOVED = models.GsaListAction.LISTAWAITINGAPPROVALREMOVED.value
LISTPUBLISHED = models.GsaListAction.LISTPUBLISHED.value
LISTUNPUBLISHED = models.GsaListAction.LISTUNPUBLISHED.value
LISTREVISIONCREATED = models.GsaListAction.LISTREVISIONCREATED.value
USERSUBSCRIBED = models.GsaListAction.USERSUBSCRIBED.value
USERUNSUBSCRIBED = models.GsaListAction.USERUNSUBSCRIBED.value
LISTCURATORADDED = models.GsaListAction.LISTCURATORADDED.value
LISTCURATORREMOVED = models.GsaListAction.LISTCURATORREMOVED.value
LISTADMINADDED = models.GsaListAction.LISTADMINADDED.value
LISTADMINREMOVED = models.GsaListAction.LISTADMINREMOVED.value
LISTPUBLISHERADDED = models.GsaListAction.LISTPUBLISHERADDED.value
LISTPUBLISHERREMOVED = models.GsaListAction.LISTPUBLISHERREMOVED.value
LISTMADEINTERNAL = models.GsaListAction.LISTMADEINTERNAL.value
LISTMADENOTINTERNAL = models.GsaListAction.LISTMADENOTINTERNAL.value


class AuditLogSearchCriterion:
"""
Search criterion to use in a search operation :meth:`~.RecordListsApiClient.search_for_lists`.

Examples
--------
Search audit log entries for a given record list.

>>> criterion = AuditLogSearchCriterion(
... filter_record_lists = []
... )

Search audit log entries for all record lists published or made not internal.

>>> criterion = AuditLogSearchCriterion(
... filter_actions = {AuditLogAction.LISTPUBLISHED, AuditLogAction.LISTMADENOTINTERNAL}
... )
"""

def __init__(
self,
filter_record_lists: Optional[List[str]] = None,
filter_actions: Optional[Set[AuditLogAction]] = None,
):
self._filter_record_lists = filter_record_lists
self._filter_actions = filter_actions

@property
def filter_record_lists(self) -> Optional[List[str]]:
"""Filter audit log entries for only the specified record lists.

If None then log entries for all record lists will be included.
"""
return self._filter_record_lists

@filter_record_lists.setter
def filter_record_lists(self, filter_record_lists: Optional[List[str]]) -> None:
self._filter_record_lists = filter_record_lists

@property
def filter_actions(self) -> Optional[Set[AuditLogAction]]:
"""Filter audit log entries for only the specified actions.

If None then log entries for all actions will be included.
"""
return self._filter_actions

@filter_actions.setter
def filter_actions(self, filter_actions: Optional[Set[AuditLogAction]]) -> None:
self._filter_actions = filter_actions

def __repr__(self) -> str:
"""Printable representation of the object."""
return f"<{self.__class__.__name__} ...>"

def _to_model(self) -> models.GsaListAuditLogSearchRequest:
"""Generate the DTO for use with the auto-generated client code."""
logger.debug("Serializing AuditLogSearchCriterion to API model")
model = models.GsaListAuditLogSearchRequest(
list_actions_to_include=(
[item.value for item in self.filter_actions] if self.filter_actions else None
),
list_identifiers=self.filter_record_lists,
)
logger.debug(model.to_str())
return model


class AuditLogItem:
"""
A log entry representing a single action affecting a record list.

Read-only - do not directly instantiate or modify instances of this class.
"""

def __init__(
self,
list_identifier: str,
initiating_user: UserOrGroup,
user_or_group_affected: Optional[UserOrGroup],
list_item_affected: Optional[RecordListItem],
action: AuditLogAction,
timestamp: datetime,
) -> None:
self._list_identifier = list_identifier
self._initiating_user = initiating_user
self._user_or_group_affected = user_or_group_affected
self._list_item_affected = list_item_affected
self._action = action
self._timestamp = timestamp

@property
def list_identifier(self) -> str:
"""Identifier of the record list affected by the action that triggered this audit log entry."""
return self._list_identifier

@property
def initiating_user(self) -> UserOrGroup:
"""User or Group that initiated the action that triggered this audit log entry."""
return self._initiating_user

@property
def user_or_group_affected(self) -> Optional[UserOrGroup]:
"""User or group affected by the action that triggered this audit log entry, if applicable."""
return self._user_or_group_affected

@property
def list_item_affected(self) -> Optional[RecordListItem]:
"""Record list item affected by the action that triggered this audit log entry, if applicable."""
return self._list_item_affected

@property
def action(self) -> AuditLogAction:
"""Type of action that triggered this audit log entry."""
return self._action

@property
def timestamp(self) -> datetime:
"""Timestamp of the event that triggered this audit log entry."""
return self._timestamp

def __repr__(self) -> str:
"""Printable representation of the object."""
renderable_properties = ("list_identifier", "action")
formatted_properties = ", ".join(
f"{property_name}={getattr(self, property_name)}"
for property_name in renderable_properties
)
return f"<{self.__class__.__name__} {formatted_properties}>"

@classmethod
def _from_model(
cls,
model: models.GsaListAuditLogItem,
) -> "AuditLogItem":
"""
Instantiate from a model defined in the auto-generated client code.

Parameters
----------
model:
DTO object to parse
"""
logger.debug("Deserializing AuditLogItem from API response")
logger.debug(model.to_str())

assert model.timestamp, "GsaListAuditLogItem must have populated timestamp attribute"

return cls(
list_identifier=model.list_identifier,
initiating_user=UserOrGroup._from_model(model.initiating_user),
user_or_group_affected=(
None
if not model.user_or_group_affected
else UserOrGroup._from_model(model.user_or_group_affected)
),
list_item_affected=(
None
if not model.list_item_affected
else RecordListItem._from_model(model.list_item_affected)
),
action=AuditLogAction(model.action.value),
timestamp=model.timestamp,
)
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.
# Copyright (C) 2023 - 2025 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
Expand Down
2 changes: 1 addition & 1 deletion tests/inputs/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.
# Copyright (C) 2023 - 2025 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
Expand Down
3 changes: 2 additions & 1 deletion tests/inputs/examples.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.
# Copyright (C) 2023 - 2025 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
Expand All @@ -23,4 +23,5 @@
examples_as_strings = {
"test_get_all_lists": r"""{"lists":[{"identifier": "bffba6ef-b85a-4b26-932b-00875b74ca2e", "metadata": {}, "createdTimestamp": "2023-01-12T11:17:46.88+00:00", "createdUser": {"identifier": "7134b26c-42df-4e86-b801-317f05b8c399", "displayName": "DOMAIN\\mi_user", "name": "DOMAIN\\mi_user"}, "lastModifiedTimestamp": "2023-02-02T13:05:54.717+00:00", "lastModifiedUser": {"identifier": "7134b26c-42df-4e86-b801-317f05b8c399", "displayName": "DOMAIN\\mi_user", "name": "DOMAIN\\mi_user"}, "publishedTimestamp": "2023-01-27T14:36:29.967+00:00", "publishedUser": {"identifier": "7134b26c-42df-4e86-b801-317f05b8c399", "displayName": "DOMAIN\\mi_user", "name": "DOMAIN\\mi_user"}, "isRevision": false, "name": "Test List", "description": "Test List - Description", "notes": "Test List - Notes", "published": false, "awaitingApproval": false, "internalUse": false}, {"identifier": "5ca1d3f6-9afd-427c-ad09-03e2b71bfd75", "metadata": {}, "createdTimestamp": "2023-02-03T12:31:17.507+00:00", "createdUser": {"identifier": "7134b26c-42df-4e86-b801-317f05b8c399", "displayName": "DOMAIN\\mi_user", "name": "DOMAIN\\mi_user"}, "lastModifiedTimestamp": "2023-02-03T12:31:17.507+00:00", "lastModifiedUser": {"identifier": "7134b26c-42df-4e86-b801-317f05b8c399", "displayName": "DOMAIN\\mi_user", "name": "DOMAIN\\mi_user"}, "isRevision": false, "name": "CreateTest", "published": false, "awaitingApproval": false, "internalUse": false}]}""", # noqa: E501
"test_get_single_list": r"""{"identifier": "bffba6ef-b85a-4b26-932b-00875b74ca2e", "metadata": {}, "createdTimestamp": "2023-01-12T11:17:46.88+00:00", "createdUser": {"identifier": "7134b26c-42df-4e86-b801-317f05b8c399", "displayName": "DOMAIN\\mi_user", "name": "DOMAIN\\mi_user"}, "lastModifiedTimestamp": "2023-02-02T13:05:54.717+00:00", "lastModifiedUser": {"identifier": "7134b26c-42df-4e86-b801-317f05b8c399", "displayName": "DOMAIN\\mi_user", "name": "DOMAIN\\mi_user"}, "publishedTimestamp": "2023-01-27T14:36:29.967+00:00", "publishedUser": {"identifier": "7134b26c-42df-4e86-b801-317f05b8c399", "displayName": "DOMAIN\\mi_user", "name": "DOMAIN\\mi_user"}, "isRevision": false, "name": "Test List", "description": "Test List - Description", "notes": "Test List - Notes", "published": false, "awaitingApproval": false, "internalUse": false}""", # noqa: E501
"test_get_all_audit_log_entries": r"""[{"listIdentifier":"f235a25c-4deb-45cf-b6fd-c4fbaca3cbd0","initiatingUser":{"identifier":"7134b26c-42df-4e86-b801-317f05b8c399","displayName":"DOMAIN\\mi_user","name":"DOMAIN\\mi_user"},"action":"ListSetToAwaitingApproval","timestamp":"2025-01-08T11:15:18.1168375+00:00"},{"listIdentifier":"f235a25c-4deb-45cf-b6fd-c4fbaca3cbd0","initiatingUser":{"identifier":"7134b26c-42df-4e86-b801-317f05b8c399","displayName":"DOMAIN\\mi_user","name":"DOMAIN\\mi_user"},"action":"ListCreated","timestamp":"2025-01-08T11:15:17.8512115+00:00"}]""",
}
2 changes: 1 addition & 1 deletion tests/integration/common.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.
# Copyright (C) 2023 - 2025 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.
# Copyright (C) 2023 - 2025 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/test_basic_user.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.
# Copyright (C) 2023 - 2025 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
Expand Down
Loading
Loading