From 11969289349b08d2295366947e0a14940392944c Mon Sep 17 00:00:00 2001 From: sinisaos Date: Sun, 26 Jun 2022 14:36:47 +0200 Subject: [PATCH 1/4] initial commit for audit logs --- docs/source/audit_logs/commands.rst | 16 ++ docs/source/audit_logs/tables.rst | 63 +++++++ docs/source/index.rst | 7 + piccolo_api/audit_logs/__init__.py | 0 piccolo_api/audit_logs/commands.py | 10 ++ piccolo_api/audit_logs/piccolo_app.py | 24 +++ .../2022-06-25T17-11-22-238052.py | 109 ++++++++++++ piccolo_api/audit_logs/tables.py | 111 ++++++++++++ piccolo_api/crud/endpoints.py | 19 ++ tests/audit_logs/test_audit_logs.py | 166 ++++++++++++++++++ 10 files changed, 525 insertions(+) create mode 100644 docs/source/audit_logs/commands.rst create mode 100644 docs/source/audit_logs/tables.rst create mode 100644 piccolo_api/audit_logs/__init__.py create mode 100644 piccolo_api/audit_logs/commands.py create mode 100644 piccolo_api/audit_logs/piccolo_app.py create mode 100644 piccolo_api/audit_logs/piccolo_migrations/2022-06-25T17-11-22-238052.py create mode 100644 piccolo_api/audit_logs/tables.py create mode 100644 tests/audit_logs/test_audit_logs.py diff --git a/docs/source/audit_logs/commands.rst b/docs/source/audit_logs/commands.rst new file mode 100644 index 00000000..2bc34951 --- /dev/null +++ b/docs/source/audit_logs/commands.rst @@ -0,0 +1,16 @@ +Commands +======== + +If you've registered the ``audit_logs`` app in your ``piccolo_conf.py`` file +(see the :ref:`migrations docs `), it gives you access to a +custom command. + +clean +----- + +If you run the following on the command line, it will delete all logs +from the database. + +.. code-block:: bash + + piccolo audit_logs clean diff --git a/docs/source/audit_logs/tables.rst b/docs/source/audit_logs/tables.rst new file mode 100644 index 00000000..db54feb5 --- /dev/null +++ b/docs/source/audit_logs/tables.rst @@ -0,0 +1,63 @@ +Tables +====== + +``audit_logs`` is a ``Piccolo`` app that records changes made by users to database tables. +We store the audit logs in :class:`AuditLog `. + +------------------------------------------------------------------------------- + +.. _AuditLogMigrations: + +Migrations +---------- + +We recommend creating ``audit_logs`` tables using migrations. + +You can add ``piccolo_api.audit_logs.piccolo_app`` to the ``apps`` arguments +of the :class:`AppRegistry ` in ``piccolo_conf.py``. + +.. code-block:: bash + + APP_REGISTRY = AppRegistry( + apps=[ + ... + "piccolo_api.audit_logs.piccolo_app", + ... + ] + ) + +To learn more about Piccolo apps, see the `Piccolo docs `_. + +To run the migrations and create the table, run: + +.. code-block:: bash + + piccolo migrations forwards audit_logs + +------------------------------------------------------------------------------- + +Creating them manually +---------------------- + +If you prefer not to use migrations, and want to create them manually, you can +do this instead: + +.. code-block:: python + + from piccolo_api.audit_logs.tables import AuditLog + from piccolo.tables import create_tables + + create_tables(AuditLog, if_not_exists=True) + +------------------------------------------------------------------------------- + +Source +------ + +AuditLog +~~~~~~~~~~~~ + +.. currentmodule:: piccolo_api.audit_logs.tables + +.. autoclass:: AuditLog + :members: diff --git a/docs/source/index.rst b/docs/source/index.rst index c9d234ad..ee8199eb 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -40,6 +40,13 @@ ASGI app, covering authentication, security, and more. ./change_password/index ./advanced_auth/index +.. toctree:: + :caption: Audit logs + :maxdepth: 1 + + ./audit_logs/tables.rst + ./audit_logs/commands.rst + .. toctree:: :caption: Contributing :maxdepth: 1 diff --git a/piccolo_api/audit_logs/__init__.py b/piccolo_api/audit_logs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/piccolo_api/audit_logs/commands.py b/piccolo_api/audit_logs/commands.py new file mode 100644 index 00000000..22a0ac90 --- /dev/null +++ b/piccolo_api/audit_logs/commands.py @@ -0,0 +1,10 @@ +from .tables import AuditLog + + +async def clean(): + """ + Removes all audit logs. + """ + print("Removing audit logs ...") + await AuditLog.delete(force=True).run() + print("Successfully removed audit logs") diff --git a/piccolo_api/audit_logs/piccolo_app.py b/piccolo_api/audit_logs/piccolo_app.py new file mode 100644 index 00000000..8de05477 --- /dev/null +++ b/piccolo_api/audit_logs/piccolo_app.py @@ -0,0 +1,24 @@ +""" +Import all of the Tables subclasses in your app here, and register them with +the APP_CONFIG. +""" + +import os + +from piccolo.conf.apps import AppConfig + +from .commands import clean +from .tables import AuditLog + +CURRENT_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) + + +APP_CONFIG = AppConfig( + app_name="audit_logs", + migrations_folder_path=os.path.join( + CURRENT_DIRECTORY, "piccolo_migrations" + ), + table_classes=[AuditLog], + migration_dependencies=[], + commands=[clean], +) diff --git a/piccolo_api/audit_logs/piccolo_migrations/2022-06-25T17-11-22-238052.py b/piccolo_api/audit_logs/piccolo_migrations/2022-06-25T17-11-22-238052.py new file mode 100644 index 00000000..81fbf75b --- /dev/null +++ b/piccolo_api/audit_logs/piccolo_migrations/2022-06-25T17-11-22-238052.py @@ -0,0 +1,109 @@ +from enum import Enum + +from piccolo.apps.migrations.auto.migration_manager import MigrationManager +from piccolo.columns.column_types import Text, Timestamp, Varchar +from piccolo.columns.defaults.timestamp import TimestampNow +from piccolo.columns.indexes import IndexMethod + +ID = "2022-06-25T17:11:22:238052" +VERSION = "0.80.0" +DESCRIPTION = "" + + +async def forwards(): + manager = MigrationManager( + migration_id=ID, app_name="audit_logs", description=DESCRIPTION + ) + + manager.add_table("AuditLog", tablename="audit_log") + + manager.add_column( + table_class_name="AuditLog", + tablename="audit_log", + column_name="action_time", + db_column_name="action_time", + column_class_name="Timestamp", + column_class=Timestamp, + params={ + "default": TimestampNow(), + "null": False, + "primary_key": False, + "unique": False, + "index": False, + "index_method": IndexMethod.btree, + "choices": None, + "db_column_name": None, + "secret": False, + }, + ) + + manager.add_column( + table_class_name="AuditLog", + tablename="audit_log", + column_name="action_type", + db_column_name="action_type", + column_class_name="Varchar", + column_class=Varchar, + params={ + "length": 255, + "default": "", + "null": False, + "primary_key": False, + "unique": False, + "index": False, + "index_method": IndexMethod.btree, + "choices": Enum( + "ActionType", + { + "CREATING": "CREATING", + "UPDATING": "UPDATING", + "DELETING": "DELETING", + }, + ), + "db_column_name": None, + "secret": False, + }, + ) + + manager.add_column( + table_class_name="AuditLog", + tablename="audit_log", + column_name="action_user", + db_column_name="action_user", + column_class_name="Varchar", + column_class=Varchar, + params={ + "length": 255, + "default": "", + "null": False, + "primary_key": False, + "unique": False, + "index": False, + "index_method": IndexMethod.btree, + "choices": None, + "db_column_name": None, + "secret": False, + }, + ) + + manager.add_column( + table_class_name="AuditLog", + tablename="audit_log", + column_name="change_message", + db_column_name="change_message", + column_class_name="Text", + column_class=Text, + params={ + "default": "", + "null": False, + "primary_key": False, + "unique": False, + "index": False, + "index_method": IndexMethod.btree, + "choices": None, + "db_column_name": None, + "secret": False, + }, + ) + + return manager diff --git a/piccolo_api/audit_logs/tables.py b/piccolo_api/audit_logs/tables.py new file mode 100644 index 00000000..c93fb5ee --- /dev/null +++ b/piccolo_api/audit_logs/tables.py @@ -0,0 +1,111 @@ +import typing as t +import uuid +from datetime import datetime +from enum import Enum + +from piccolo.apps.user.tables import BaseUser +from piccolo.columns import Text, Timestamp, Varchar +from piccolo.table import Table + + +class AuditLog(Table): + class ActionType(str, Enum): + """An enumeration of AuditLog table actions type.""" + + CREATING = "CREATING" + UPDATING = "UPDATING" + DELETING = "DELETING" + + action_time = Timestamp() + action_type = Varchar(choices=ActionType) + action_user = Varchar() + change_message = Text() + + @classmethod + async def post_save_action(cls, table: t.Type[Table], user_id: int): + """ + A method for tracking creating record actions. + + :param table: + A table for which we monitor activities. + :param user_id: + The ``primary key`` of authenticated user. + """ + result = cls( + action_time=datetime.now(), + action_type=cls.ActionType.CREATING, + action_user=cls.get_user_username(user_id), + change_message=f"User {cls.get_user_username(user_id)} " + f"create new row in {table._meta.tablename.title()} table", + ) + await result.save().run() + + @classmethod + async def post_patch_action( + cls, + table: t.Type[Table], + row_id: t.Union[str, uuid.UUID, int], + user_id: int, + ): + """ + A method for tracking updating record actions. + + :param table: + A table for which we monitor activities. + :param row_id: + The ``primary key`` of the table for which we + monitor activities. + :param user_id: + The ``primary key`` of authenticated user. + """ + result = cls( + action_time=datetime.now(), + action_type=cls.ActionType.UPDATING, + action_user=cls.get_user_username(user_id), + change_message=f"User {cls.get_user_username(user_id)} update row " + f"{row_id} in {table._meta.tablename.title()} table", + ) + await result.save().run() + + @classmethod + async def post_delete_action( + cls, + table: t.Type[Table], + row_id: t.Union[str, uuid.UUID, int], + user_id: int, + ): + """ + A method for tracking deletion record actions. + + :param table: + A table for which we monitor activities. + :param row_id: + The ``primary key`` of the table for which we + monitor activities. + :param user_id: + The ``primary key`` of authenticated user. + """ + result = cls( + action_time=datetime.now(), + action_type=cls.ActionType.DELETING, + action_user=cls.get_user_username(user_id), + change_message=f"User {cls.get_user_username(user_id)} delete row " + f"{row_id} in {table._meta.tablename.title()} table", + ) + await result.save().run() + + @classmethod + def get_user_username(cls, user_id: int) -> str: + """ + Returns the username of authenticated user. + + :param user_id: + The ``primary key`` of authenticated user. + """ + user = ( + BaseUser.select(BaseUser.username) + .where(BaseUser._meta.primary_key == user_id) + .first() + .run_sync() + ) + return user["username"] diff --git a/piccolo_api/crud/endpoints.py b/piccolo_api/crud/endpoints.py index cdf8e6a2..d6d534e7 100644 --- a/piccolo_api/crud/endpoints.py +++ b/piccolo_api/crud/endpoints.py @@ -27,6 +27,7 @@ from starlette.responses import JSONResponse, Response from starlette.routing import Route, Router +from piccolo_api.audit_logs.tables import AuditLog from piccolo_api.crud.hooks import ( Hook, HookType, @@ -812,6 +813,12 @@ async def post_single( row = await execute_post_hooks( hooks=self._hook_map, hook_type=HookType.pre_save, row=row ) + try: + await AuditLog.post_save_action( + self.table, user_id=request.user.user_id + ) + except AssertionError: + pass response = await row.save().run() json = dump_json(response) # Returns the id of the inserted row. @@ -1060,6 +1067,12 @@ async def patch_single( await cls.update(values).where( cls._meta.primary_key == row_id ).run() + try: + await AuditLog.post_patch_action( + cls, row_id=row_id, user_id=request.user.user_id + ) + except AssertionError: + pass new_row = ( await cls.select(exclude_secrets=self.exclude_secrets) .where(cls._meta.primary_key == row_id) @@ -1089,6 +1102,12 @@ async def delete_single( await self.table.delete().where( self.table._meta.primary_key == row_id ).run() + try: + await AuditLog.post_delete_action( + self.table, row_id=row_id, user_id=request.user.user_id + ) + except AssertionError: + pass return Response(status_code=204) except ValueError: return Response("Unable to delete the resource.", status_code=500) diff --git a/tests/audit_logs/test_audit_logs.py b/tests/audit_logs/test_audit_logs.py new file mode 100644 index 00000000..b35a4c48 --- /dev/null +++ b/tests/audit_logs/test_audit_logs.py @@ -0,0 +1,166 @@ +from unittest import TestCase + +from piccolo.apps.user.tables import BaseUser +from piccolo.columns import Integer, Varchar +from piccolo.table import Table +from piccolo.utils.sync import run_sync +from starlette.testclient import TestClient + +from piccolo_api.audit_logs.commands import clean +from piccolo_api.audit_logs.tables import AuditLog +from piccolo_api.crud.endpoints import PiccoloCRUD + + +class Movie(Table): + name = Varchar(length=100, required=True) + rating = Integer() + + +class TestSaveAuditLogs(TestCase): + def setUp(self): + BaseUser.create_table(if_not_exists=True).run_sync() + AuditLog.create_table(if_not_exists=True).run_sync() + Movie.create_table(if_not_exists=True).run_sync() + + def tearDown(self): + BaseUser.alter().drop_table().run_sync() + AuditLog.alter().drop_table().run_sync() + Movie.alter().drop_table().run_sync() + + def test_save_audit_logs(self): + """ + Make sure a AuditLog post_save_action works. + """ + user = run_sync( + BaseUser.create_user(username="admin", password="admin123") + ) + client = TestClient(PiccoloCRUD(table=Movie, read_only=False)) + + json = {"name": "Star Wars", "rating": 93} + + response = client.post("/", json=json) + run_sync(AuditLog.post_save_action(Movie, user_id=user.id)) + self.assertEqual(response.status_code, 201) + + audit_log = AuditLog.select(AuditLog.action_type).first().run_sync() + self.assertEqual(audit_log["action_type"], "CREATING") + self.assertEqual(len(audit_log), 1) + + +class TestPatchAuditLogs(TestCase): + def setUp(self): + BaseUser.create_table(if_not_exists=True).run_sync() + AuditLog.create_table(if_not_exists=True).run_sync() + Movie.create_table(if_not_exists=True).run_sync() + + def tearDown(self): + BaseUser.alter().drop_table().run_sync() + AuditLog.alter().drop_table().run_sync() + Movie.alter().drop_table().run_sync() + + def test_patch_audit_logs(self): + """ + Make sure a AuditLog post_patch_action works. + """ + user = run_sync( + BaseUser.create_user(username="admin", password="admin123") + ) + + client = TestClient(PiccoloCRUD(table=Movie, read_only=False)) + + rating = 93 + movie = Movie(name="Star Wars", rating=rating) + movie.save().run_sync() + + new_name = "Star Wars: A New Hope" + + response = client.patch(f"/{movie.id}/", json={"name": new_name}) + run_sync( + AuditLog.post_patch_action(Movie, row_id=movie.id, user_id=user.id) + ) + self.assertEqual(response.status_code, 200) + + audit_log = AuditLog.select(AuditLog.action_type).first().run_sync() + self.assertEqual(audit_log["action_type"], "UPDATING") + self.assertEqual(len(audit_log), 1) + + +class TestDeleteAuditLogs(TestCase): + def setUp(self): + BaseUser.create_table(if_not_exists=True).run_sync() + AuditLog.create_table(if_not_exists=True).run_sync() + Movie.create_table(if_not_exists=True).run_sync() + + def tearDown(self): + BaseUser.alter().drop_table().run_sync() + AuditLog.alter().drop_table().run_sync() + Movie.alter().drop_table().run_sync() + + def test_delete_audit_logs(self): + """ + Make sure a AuditLog post_delete_action works. + """ + user = run_sync( + BaseUser.create_user(username="admin", password="admin123") + ) + client = TestClient(PiccoloCRUD(table=Movie, read_only=False)) + + movie = Movie(name="Star Wars", rating=93) + movie.save().run_sync() + + response = client.delete(f"/{movie.id}/") + run_sync( + AuditLog.post_delete_action( + Movie, row_id=movie.id, user_id=user.id + ) + ) + self.assertTrue(response.status_code == 204) + + audit_log = AuditLog.select(AuditLog.action_type).first().run_sync() + self.assertEqual(audit_log["action_type"], "DELETING") + self.assertEqual(len(audit_log), 1) + + +class TestCleanAuditLogs(TestCase): + def setUp(self): + BaseUser.create_table(if_not_exists=True).run_sync() + AuditLog.create_table(if_not_exists=True).run_sync() + Movie.create_table(if_not_exists=True).run_sync() + + def tearDown(self): + BaseUser.alter().drop_table().run_sync() + AuditLog.alter().drop_table().run_sync() + Movie.alter().drop_table().run_sync() + + def test_clean_audit_logs(self): + """ + Make sure a AuditLog clean() method works. + """ + user = run_sync( + BaseUser.create_user(username="admin", password="admin123") + ) + client = TestClient(PiccoloCRUD(table=Movie, read_only=False)) + + json = {"name": "Star Wars", "rating": 93} + + response = client.post("/", json=json) + run_sync(AuditLog.post_save_action(Movie, user_id=user.id)) + self.assertEqual(response.status_code, 201) + + movie = Movie.select().first().run_sync() + + response = client.delete(f"/{movie['id']}/") + run_sync( + AuditLog.post_delete_action( + Movie, row_id=movie["id"], user_id=user.id + ) + ) + self.assertTrue(response.status_code == 204) + + audit_log = AuditLog.select().run_sync() + self.assertEqual(len(audit_log), 2) + + run_sync(clean()) + + audit_log = AuditLog.select().run_sync() + self.assertEqual(len(audit_log), 0) From de5a5f39b38fc23a0591477bc1dfa0c3b82710d1 Mon Sep 17 00:00:00 2001 From: sinisaos Date: Mon, 27 Jun 2022 08:10:27 +0200 Subject: [PATCH 2/4] made the proposed changes --- docs/source/audit_logs/tables.rst | 8 +-- .../2022-06-25T17-11-22-238052.py | 24 ++++++- piccolo_api/audit_logs/tables.py | 25 ++++--- piccolo_api/crud/endpoints.py | 70 +++++++++++++------ tests/audit_logs/test_audit_logs.py | 24 +++++-- 5 files changed, 109 insertions(+), 42 deletions(-) diff --git a/docs/source/audit_logs/tables.rst b/docs/source/audit_logs/tables.rst index db54feb5..84259d1d 100644 --- a/docs/source/audit_logs/tables.rst +++ b/docs/source/audit_logs/tables.rst @@ -11,7 +11,7 @@ We store the audit logs in :class:`AuditLog ` in ``piccolo_conf.py``. @@ -45,9 +45,9 @@ do this instead: .. code-block:: python from piccolo_api.audit_logs.tables import AuditLog - from piccolo.tables import create_tables + from piccolo.tables import create_db_tables_sync - create_tables(AuditLog, if_not_exists=True) + create_db_tables_sync(AuditLog, if_not_exists=True) ------------------------------------------------------------------------------- @@ -55,7 +55,7 @@ Source ------ AuditLog -~~~~~~~~~~~~ +~~~~~~~~ .. currentmodule:: piccolo_api.audit_logs.tables diff --git a/piccolo_api/audit_logs/piccolo_migrations/2022-06-25T17-11-22-238052.py b/piccolo_api/audit_logs/piccolo_migrations/2022-06-25T17-11-22-238052.py index 81fbf75b..d1af307f 100644 --- a/piccolo_api/audit_logs/piccolo_migrations/2022-06-25T17-11-22-238052.py +++ b/piccolo_api/audit_logs/piccolo_migrations/2022-06-25T17-11-22-238052.py @@ -1,11 +1,11 @@ from enum import Enum from piccolo.apps.migrations.auto.migration_manager import MigrationManager -from piccolo.columns.column_types import Text, Timestamp, Varchar +from piccolo.columns.column_types import JSON, Text, Timestamp, Varchar from piccolo.columns.defaults.timestamp import TimestampNow from piccolo.columns.indexes import IndexMethod -ID = "2022-06-25T17:11:22:238052" +ID = "2022-06-26T22:48:09:433352" VERSION = "0.80.0" DESCRIPTION = "" @@ -106,4 +106,24 @@ async def forwards(): }, ) + manager.add_column( + table_class_name="AuditLog", + tablename="audit_log", + column_name="changes_in_row", + db_column_name="changes_in_row", + column_class_name="JSON", + column_class=JSON, + params={ + "default": "{}", + "null": False, + "primary_key": False, + "unique": False, + "index": False, + "index_method": IndexMethod.btree, + "choices": None, + "db_column_name": None, + "secret": False, + }, + ) + return manager diff --git a/piccolo_api/audit_logs/tables.py b/piccolo_api/audit_logs/tables.py index c93fb5ee..fbd0fa22 100644 --- a/piccolo_api/audit_logs/tables.py +++ b/piccolo_api/audit_logs/tables.py @@ -1,10 +1,9 @@ import typing as t import uuid -from datetime import datetime from enum import Enum from piccolo.apps.user.tables import BaseUser -from piccolo.columns import Text, Timestamp, Varchar +from piccolo.columns import JSON, Text, Timestamp, Varchar from piccolo.table import Table @@ -20,9 +19,15 @@ class ActionType(str, Enum): action_type = Varchar(choices=ActionType) action_user = Varchar() change_message = Text() + changes_in_row = JSON() @classmethod - async def post_save_action(cls, table: t.Type[Table], user_id: int): + async def record_save_action( + cls, + table: t.Type[Table], + user_id: int, + new_row_id=t.Union[str, uuid.UUID, int], + ): """ A method for tracking creating record actions. @@ -32,20 +37,21 @@ async def post_save_action(cls, table: t.Type[Table], user_id: int): The ``primary key`` of authenticated user. """ result = cls( - action_time=datetime.now(), action_type=cls.ActionType.CREATING, action_user=cls.get_user_username(user_id), change_message=f"User {cls.get_user_username(user_id)} " - f"create new row in {table._meta.tablename.title()} table", + f"create row {new_row_id} in {table._meta.tablename.title()} " + f"table", ) await result.save().run() @classmethod - async def post_patch_action( + async def record_patch_action( cls, table: t.Type[Table], row_id: t.Union[str, uuid.UUID, int], user_id: int, + changes_in_row: t.Dict[str, t.Any], ): """ A method for tracking updating record actions. @@ -57,18 +63,20 @@ async def post_patch_action( monitor activities. :param user_id: The ``primary key`` of authenticated user. + :param changes_in_row: + JSON with all changed columns in the existing row. """ result = cls( - action_time=datetime.now(), action_type=cls.ActionType.UPDATING, action_user=cls.get_user_username(user_id), change_message=f"User {cls.get_user_username(user_id)} update row " f"{row_id} in {table._meta.tablename.title()} table", + changes_in_row=changes_in_row, ) await result.save().run() @classmethod - async def post_delete_action( + async def record_delete_action( cls, table: t.Type[Table], row_id: t.Union[str, uuid.UUID, int], @@ -86,7 +94,6 @@ async def post_delete_action( The ``primary key`` of authenticated user. """ result = cls( - action_time=datetime.now(), action_type=cls.ActionType.DELETING, action_user=cls.get_user_username(user_id), change_message=f"User {cls.get_user_username(user_id)} delete row " diff --git a/piccolo_api/crud/endpoints.py b/piccolo_api/crud/endpoints.py index d6d534e7..9088dd07 100644 --- a/piccolo_api/crud/endpoints.py +++ b/piccolo_api/crud/endpoints.py @@ -149,6 +149,7 @@ def __init__( schema_extra: t.Optional[t.Dict[str, t.Any]] = None, max_joins: int = 0, hooks: t.Optional[t.List[Hook]] = None, + audit_log_table: t.Optional[t.Type[AuditLog]] = None, ) -> None: """ :param table: @@ -218,6 +219,7 @@ def __init__( } else: self._hook_map = None # type: ignore + self.audit_log_table = audit_log_table or AuditLog schema_extra = schema_extra if isinstance(schema_extra, dict) else {} self.visible_fields_options = get_visible_fields_options( @@ -335,6 +337,22 @@ def pydantic_model_plural( rows=(t.List[base_model], None), ) + def get_single_row( + self, + table: t.Type[Table], + row_id: t.Union[str, uuid.UUID, int], + ) -> t.Dict[str, t.Any]: + """ + Return a single row. + """ + row = ( + self.table.select(exclude_secrets=self.exclude_secrets) + .where(self.table._meta.primary_key == row_id) + .first() + .run_sync() + ) + return row + @apply_validators async def get_schema(self, request: Request) -> JSONResponse: """ @@ -813,13 +831,17 @@ async def post_single( row = await execute_post_hooks( hooks=self._hook_map, hook_type=HookType.pre_save, row=row ) - try: - await AuditLog.post_save_action( - self.table, user_id=request.user.user_id - ) - except AssertionError: - pass response = await row.save().run() + new_row_id = list(response[0].values()) + try: + if self.audit_log_table: + await self.audit_log_table.record_save_action( + self.table, + user_id=request.user.user_id, + new_row_id=new_row_id[0], + ) + except Exception as exception: + logger.log(msg=f"{exception}", level=logging.WARNING) json = dump_json(response) # Returns the id of the inserted row. return CustomJSONResponse(json, status_code=201) @@ -1064,21 +1086,24 @@ async def patch_single( ) try: + old_row = self.get_single_row(cls, row_id) await cls.update(values).where( cls._meta.primary_key == row_id ).run() + new_row = self.get_single_row(cls, row_id) + changes_in_row = { + k: v for k, v in new_row.items() - old_row.items() + } try: - await AuditLog.post_patch_action( - cls, row_id=row_id, user_id=request.user.user_id - ) - except AssertionError: - pass - new_row = ( - await cls.select(exclude_secrets=self.exclude_secrets) - .where(cls._meta.primary_key == row_id) - .first() - .run() - ) + if self.audit_log_table: + await self.audit_log_table.record_patch_action( + cls, + row_id=row_id, + user_id=request.user.user_id, + changes_in_row=changes_in_row, + ) + except Exception as exception: + logger.log(msg=f"{exception}", level=logging.WARNING) return CustomJSONResponse(self.pydantic_model(**new_row).json()) except ValueError: return Response("Unable to save the resource.", status_code=500) @@ -1103,11 +1128,12 @@ async def delete_single( self.table._meta.primary_key == row_id ).run() try: - await AuditLog.post_delete_action( - self.table, row_id=row_id, user_id=request.user.user_id - ) - except AssertionError: - pass + if self.audit_log_table: + await self.audit_log_table.record_delete_action( + self.table, row_id=row_id, user_id=request.user.user_id + ) + except Exception as exception: + logger.log(msg=f"{exception}", level=logging.WARNING) return Response(status_code=204) except ValueError: return Response("Unable to delete the resource.", status_code=500) diff --git a/tests/audit_logs/test_audit_logs.py b/tests/audit_logs/test_audit_logs.py index b35a4c48..e798ad0a 100644 --- a/tests/audit_logs/test_audit_logs.py +++ b/tests/audit_logs/test_audit_logs.py @@ -39,7 +39,11 @@ def test_save_audit_logs(self): json = {"name": "Star Wars", "rating": 93} response = client.post("/", json=json) - run_sync(AuditLog.post_save_action(Movie, user_id=user.id)) + run_sync( + AuditLog.record_save_action( + Movie, user_id=user.id, new_row_id=response.json()[0]["id"] + ) + ) self.assertEqual(response.status_code, 201) audit_log = AuditLog.select(AuditLog.action_type).first().run_sync() @@ -76,7 +80,12 @@ def test_patch_audit_logs(self): response = client.patch(f"/{movie.id}/", json={"name": new_name}) run_sync( - AuditLog.post_patch_action(Movie, row_id=movie.id, user_id=user.id) + AuditLog.record_patch_action( + Movie, + row_id=movie.id, + user_id=user.id, + changes_in_row={"name": new_name}, + ) ) self.assertEqual(response.status_code, 200) @@ -110,7 +119,7 @@ def test_delete_audit_logs(self): response = client.delete(f"/{movie.id}/") run_sync( - AuditLog.post_delete_action( + AuditLog.record_delete_action( Movie, row_id=movie.id, user_id=user.id ) ) @@ -144,14 +153,19 @@ def test_clean_audit_logs(self): json = {"name": "Star Wars", "rating": 93} response = client.post("/", json=json) - run_sync(AuditLog.post_save_action(Movie, user_id=user.id)) + + run_sync( + AuditLog.record_save_action( + Movie, user_id=user.id, new_row_id=response.json()[0]["id"] + ) + ) self.assertEqual(response.status_code, 201) movie = Movie.select().first().run_sync() response = client.delete(f"/{movie['id']}/") run_sync( - AuditLog.post_delete_action( + AuditLog.record_delete_action( Movie, row_id=movie["id"], user_id=user.id ) ) From 7757c01c7fb0a078f5cd36995491c086d877cc77 Mon Sep 17 00:00:00 2001 From: sinisaos Date: Mon, 27 Jun 2022 20:32:54 +0200 Subject: [PATCH 3/4] AuditLog default to None in crud endpoints --- piccolo_api/crud/endpoints.py | 2 +- tests/audit_logs/test_audit_logs.py | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/piccolo_api/crud/endpoints.py b/piccolo_api/crud/endpoints.py index 9088dd07..b0a42ae9 100644 --- a/piccolo_api/crud/endpoints.py +++ b/piccolo_api/crud/endpoints.py @@ -219,7 +219,7 @@ def __init__( } else: self._hook_map = None # type: ignore - self.audit_log_table = audit_log_table or AuditLog + self.audit_log_table = audit_log_table schema_extra = schema_extra if isinstance(schema_extra, dict) else {} self.visible_fields_options = get_visible_fields_options( diff --git a/tests/audit_logs/test_audit_logs.py b/tests/audit_logs/test_audit_logs.py index e798ad0a..2bf0cca4 100644 --- a/tests/audit_logs/test_audit_logs.py +++ b/tests/audit_logs/test_audit_logs.py @@ -34,7 +34,9 @@ def test_save_audit_logs(self): user = run_sync( BaseUser.create_user(username="admin", password="admin123") ) - client = TestClient(PiccoloCRUD(table=Movie, read_only=False)) + client = TestClient( + PiccoloCRUD(table=Movie, read_only=False, audit_log_table=AuditLog) + ) json = {"name": "Star Wars", "rating": 93} @@ -70,7 +72,9 @@ def test_patch_audit_logs(self): BaseUser.create_user(username="admin", password="admin123") ) - client = TestClient(PiccoloCRUD(table=Movie, read_only=False)) + client = TestClient( + PiccoloCRUD(table=Movie, read_only=False, audit_log_table=AuditLog) + ) rating = 93 movie = Movie(name="Star Wars", rating=rating) @@ -112,7 +116,9 @@ def test_delete_audit_logs(self): user = run_sync( BaseUser.create_user(username="admin", password="admin123") ) - client = TestClient(PiccoloCRUD(table=Movie, read_only=False)) + client = TestClient( + PiccoloCRUD(table=Movie, read_only=False, audit_log_table=AuditLog) + ) movie = Movie(name="Star Wars", rating=93) movie.save().run_sync() From 96173db7dc363ec3b8fb238ef138cc211af4b331 Mon Sep 17 00:00:00 2001 From: sinisaos Date: Tue, 28 Jun 2022 19:22:55 +0200 Subject: [PATCH 4/4] lowercase enum --- ...38052.py => 2022-06-28T18-57-05-840638.py} | 29 ++++++++++++++++--- piccolo_api/audit_logs/tables.py | 18 ++++++++---- tests/audit_logs/test_audit_logs.py | 6 ++-- 3 files changed, 40 insertions(+), 13 deletions(-) rename piccolo_api/audit_logs/piccolo_migrations/{2022-06-25T17-11-22-238052.py => 2022-06-28T18-57-05-840638.py} (82%) diff --git a/piccolo_api/audit_logs/piccolo_migrations/2022-06-25T17-11-22-238052.py b/piccolo_api/audit_logs/piccolo_migrations/2022-06-28T18-57-05-840638.py similarity index 82% rename from piccolo_api/audit_logs/piccolo_migrations/2022-06-25T17-11-22-238052.py rename to piccolo_api/audit_logs/piccolo_migrations/2022-06-28T18-57-05-840638.py index d1af307f..49d37a6b 100644 --- a/piccolo_api/audit_logs/piccolo_migrations/2022-06-25T17-11-22-238052.py +++ b/piccolo_api/audit_logs/piccolo_migrations/2022-06-28T18-57-05-840638.py @@ -5,7 +5,7 @@ from piccolo.columns.defaults.timestamp import TimestampNow from piccolo.columns.indexes import IndexMethod -ID = "2022-06-26T22:48:09:433352" +ID = "2022-06-28T18:57:05:840638" VERSION = "0.80.0" DESCRIPTION = "" @@ -55,9 +55,9 @@ async def forwards(): "choices": Enum( "ActionType", { - "CREATING": "CREATING", - "UPDATING": "UPDATING", - "DELETING": "DELETING", + "creating": "creating", + "updating": "updating", + "deleting": "deleting", }, ), "db_column_name": None, @@ -86,6 +86,27 @@ async def forwards(): }, ) + manager.add_column( + table_class_name="AuditLog", + tablename="audit_log", + column_name="table_name", + db_column_name="table_name", + column_class_name="Varchar", + column_class=Varchar, + params={ + "length": 255, + "default": "", + "null": False, + "primary_key": False, + "unique": False, + "index": False, + "index_method": IndexMethod.btree, + "choices": None, + "db_column_name": None, + "secret": False, + }, + ) + manager.add_column( table_class_name="AuditLog", tablename="audit_log", diff --git a/piccolo_api/audit_logs/tables.py b/piccolo_api/audit_logs/tables.py index fbd0fa22..071faf73 100644 --- a/piccolo_api/audit_logs/tables.py +++ b/piccolo_api/audit_logs/tables.py @@ -11,13 +11,14 @@ class AuditLog(Table): class ActionType(str, Enum): """An enumeration of AuditLog table actions type.""" - CREATING = "CREATING" - UPDATING = "UPDATING" - DELETING = "DELETING" + creating = "creating" + updating = "updating" + deleting = "deleting" action_time = Timestamp() action_type = Varchar(choices=ActionType) action_user = Varchar() + table_name = Varchar() change_message = Text() changes_in_row = JSON() @@ -35,10 +36,13 @@ async def record_save_action( A table for which we monitor activities. :param user_id: The ``primary key`` of authenticated user. + :param new_row_id: + The ``primary key`` of the newly created record. """ result = cls( - action_type=cls.ActionType.CREATING, + action_type=cls.ActionType.creating, action_user=cls.get_user_username(user_id), + table_name=table._meta.tablename.title(), change_message=f"User {cls.get_user_username(user_id)} " f"create row {new_row_id} in {table._meta.tablename.title()} " f"table", @@ -67,8 +71,9 @@ async def record_patch_action( JSON with all changed columns in the existing row. """ result = cls( - action_type=cls.ActionType.UPDATING, + action_type=cls.ActionType.updating, action_user=cls.get_user_username(user_id), + table_name=table._meta.tablename.title(), change_message=f"User {cls.get_user_username(user_id)} update row " f"{row_id} in {table._meta.tablename.title()} table", changes_in_row=changes_in_row, @@ -94,8 +99,9 @@ async def record_delete_action( The ``primary key`` of authenticated user. """ result = cls( - action_type=cls.ActionType.DELETING, + action_type=cls.ActionType.deleting, action_user=cls.get_user_username(user_id), + table_name=table._meta.tablename.title(), change_message=f"User {cls.get_user_username(user_id)} delete row " f"{row_id} in {table._meta.tablename.title()} table", ) diff --git a/tests/audit_logs/test_audit_logs.py b/tests/audit_logs/test_audit_logs.py index 2bf0cca4..25cbe255 100644 --- a/tests/audit_logs/test_audit_logs.py +++ b/tests/audit_logs/test_audit_logs.py @@ -49,7 +49,7 @@ def test_save_audit_logs(self): self.assertEqual(response.status_code, 201) audit_log = AuditLog.select(AuditLog.action_type).first().run_sync() - self.assertEqual(audit_log["action_type"], "CREATING") + self.assertEqual(audit_log["action_type"], "creating") self.assertEqual(len(audit_log), 1) @@ -94,7 +94,7 @@ def test_patch_audit_logs(self): self.assertEqual(response.status_code, 200) audit_log = AuditLog.select(AuditLog.action_type).first().run_sync() - self.assertEqual(audit_log["action_type"], "UPDATING") + self.assertEqual(audit_log["action_type"], "updating") self.assertEqual(len(audit_log), 1) @@ -132,7 +132,7 @@ def test_delete_audit_logs(self): self.assertTrue(response.status_code == 204) audit_log = AuditLog.select(AuditLog.action_type).first().run_sync() - self.assertEqual(audit_log["action_type"], "DELETING") + self.assertEqual(audit_log["action_type"], "deleting") self.assertEqual(len(audit_log), 1)