diff --git a/src/ahriman/core/database/migrations/m014_auditlog.py b/src/ahriman/core/database/migrations/m014_auditlog.py
new file mode 100644
index 00000000..94463573
--- /dev/null
+++ b/src/ahriman/core/database/migrations/m014_auditlog.py
@@ -0,0 +1,38 @@
+#
+# Copyright (c) 2021-2024 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+__all__ = ["steps"]
+
+
+steps = [
+ """
+ create table auditlog (
+ created integer not null,
+ repository text not null,
+ event text not null,
+ object_id text not null,
+ message text,
+ data json
+ )
+ """,
+ """
+ create index auditlog_created_repository_event_object_id
+ on auditlog (created, repository, event, object_id)
+ """,
+]
diff --git a/src/ahriman/core/database/operations/__init__.py b/src/ahriman/core/database/operations/__init__.py
index d89ef710..acabbf6d 100644
--- a/src/ahriman/core/database/operations/__init__.py
+++ b/src/ahriman/core/database/operations/__init__.py
@@ -21,6 +21,7 @@ from ahriman.core.database.operations.auth_operations import AuthOperations
from ahriman.core.database.operations.build_operations import BuildOperations
from ahriman.core.database.operations.changes_operations import ChangesOperations
from ahriman.core.database.operations.dependencies_operations import DependenciesOperations
+from ahriman.core.database.operations.event_operations import EventOperations
from ahriman.core.database.operations.logs_operations import LogsOperations
from ahriman.core.database.operations.package_operations import PackageOperations
from ahriman.core.database.operations.patch_operations import PatchOperations
diff --git a/src/ahriman/core/database/operations/dependencies_operations.py b/src/ahriman/core/database/operations/dependencies_operations.py
index 1f2a7780..c485eca9 100644
--- a/src/ahriman/core/database/operations/dependencies_operations.py
+++ b/src/ahriman/core/database/operations/dependencies_operations.py
@@ -39,7 +39,7 @@ class DependenciesOperations(Operations):
repository_id(RepositoryId, optional): repository unique identifier override (Default value = None)
Returns:
- Dependencies: changes for the package base if available
+ Dependencies: dependencies for the package base if available
"""
repository_id = repository_id or self._repository_id
diff --git a/src/ahriman/core/database/operations/event_operations.py b/src/ahriman/core/database/operations/event_operations.py
new file mode 100644
index 00000000..8d51899d
--- /dev/null
+++ b/src/ahriman/core/database/operations/event_operations.py
@@ -0,0 +1,104 @@
+#
+# Copyright (c) 2021-2024 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+from sqlite3 import Connection
+
+from ahriman.core.database.operations.operations import Operations
+from ahriman.models.event import Event, EventType
+from ahriman.models.repository_id import RepositoryId
+
+
+class EventOperations(Operations):
+ """
+ operations for audit log table
+ """
+
+ def event_get(self, event: str | EventType | None = None, object_id: str | None = None,
+ limit: int = -1, offset: int = 0, repository_id: RepositoryId | None = None) -> list[Event]:
+ """
+ get list of events with filters applied
+
+ Args:
+ event(str | EventType | None, optional): filter by event type (Default value = None)
+ object_id(str | None, optional): filter by event object (Default value = None)
+ limit(int, optional): limit records to the specified count, -1 means unlimited (Default value = -1)
+ offset(int, optional): records offset (Default value = 0)
+ repository_id(RepositoryId, optional): repository unique identifier override (Default value = None)
+
+ Returns:
+ list[Event]: list of audit log events
+ """
+ repository_id = repository_id or self._repository_id
+
+ def run(connection: Connection) -> list[Event]:
+ return [
+ Event(
+ event=row["event"],
+ object_id=row["object_id"],
+ message=row["message"],
+ data=row["data"],
+ created=row["created"],
+ ) for row in connection.execute(
+ """
+ select created, event, object_id, message, data from auditlog
+ where (:event is null or event = :event)
+ and (:object_id is null or object_id = :object_id)
+ and repository = :repository
+ order by created limit :limit offset :offset
+ """,
+ {
+ "event": event,
+ "object_id": object_id,
+ "repository": repository_id.id,
+ "limit": limit,
+ "offset": offset,
+ }
+ )
+ ]
+
+ return self.with_connection(run)
+
+ def event_insert(self, event: Event, repository_id: RepositoryId | None = None) -> None:
+ """
+ insert audit log event
+
+ Args:
+ event(Event): event to insert
+ repository_id(RepositoryId, optional): repository unique identifier override (Default value = None)
+ """
+ repository_id = repository_id or self._repository_id
+
+ def run(connection: Connection) -> None:
+ connection.execute(
+ """
+ insert into auditlog
+ (created, repository, event, object_id, message, data)
+ values
+ (:created, :repository, :event, :object_id, :message, :data)
+ """,
+ {
+ "created": event.created,
+ "repository": repository_id.id,
+ "event": event.event,
+ "object_id": event.object_id,
+ "message": event.message,
+ "data": event.data,
+ })
+
+ return self.with_connection(run, commit=True)
diff --git a/src/ahriman/core/database/sqlite.py b/src/ahriman/core/database/sqlite.py
index e6d26d7e..c659a10a 100644
--- a/src/ahriman/core/database/sqlite.py
+++ b/src/ahriman/core/database/sqlite.py
@@ -26,7 +26,7 @@ from typing import Self
from ahriman.core.configuration import Configuration
from ahriman.core.database.migrations import Migrations
from ahriman.core.database.operations import AuthOperations, BuildOperations, ChangesOperations, \
- DependenciesOperations, LogsOperations, PackageOperations, PatchOperations
+ DependenciesOperations, EventOperations, LogsOperations, PackageOperations, PatchOperations
# pylint: disable=too-many-ancestors
@@ -35,6 +35,7 @@ class SQLite(
BuildOperations,
ChangesOperations,
DependenciesOperations,
+ EventOperations,
LogsOperations,
PackageOperations,
PatchOperations):
diff --git a/src/ahriman/core/status/client.py b/src/ahriman/core/status/client.py
index c8a3398e..67a6401d 100644
--- a/src/ahriman/core/status/client.py
+++ b/src/ahriman/core/status/client.py
@@ -25,6 +25,7 @@ from ahriman.core.database import SQLite
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies
+from ahriman.models.event import Event, EventType
from ahriman.models.internal_status import InternalStatus
from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package
@@ -79,6 +80,37 @@ class Client:
return make_local_client()
+ def event_add(self, event: Event) -> None:
+ """
+ create new event
+
+ Args:
+ event(Event): audit log event
+
+ Raises:
+ NotImplementedError: not implemented method
+ """
+ raise NotImplementedError
+
+ def event_get(self, event: str | EventType | None, object_id: str | None,
+ limit: int = -1, offset: int = 0) -> list[Event]:
+ """
+ retrieve list of events
+
+ Args:
+ event(str | EventType | None): filter by event type
+ object_id(str | None): filter by event object
+ limit(int, optional): limit records to the specified count, -1 means unlimited (Default value = -1)
+ offset(int, optional): records offset (Default value = 0)
+
+ Returns:
+ list[Event]: list of audit log events
+
+ Raises:
+ NotImplementedError: not implemented method
+ """
+ raise NotImplementedError
+
def package_changes_get(self, package_base: str) -> Changes:
"""
get package changes
diff --git a/src/ahriman/core/status/local_client.py b/src/ahriman/core/status/local_client.py
index e0900805..3229f0e2 100644
--- a/src/ahriman/core/status/local_client.py
+++ b/src/ahriman/core/status/local_client.py
@@ -22,6 +22,7 @@ from ahriman.core.status import Client
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies
+from ahriman.models.event import Event, EventType
from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch
@@ -48,6 +49,31 @@ class LocalClient(Client):
self.database = database
self.repository_id = repository_id
+ def event_add(self, event: Event) -> None:
+ """
+ create new event
+
+ Args:
+ event(Event): audit log event
+ """
+ self.database.event_insert(event, self.repository_id)
+
+ def event_get(self, event: str | EventType | None, object_id: str | None,
+ limit: int = -1, offset: int = 0) -> list[Event]:
+ """
+ retrieve list of events
+
+ Args:
+ event(str | EventType | None): filter by event type
+ object_id(str | None): filter by event object
+ limit(int, optional): limit records to the specified count, -1 means unlimited (Default value = -1)
+ offset(int, optional): records offset (Default value = 0)
+
+ Returns:
+ list[Event]: list of audit log events
+ """
+ return self.database.event_get(event, object_id, limit, offset, self.repository_id)
+
def package_changes_get(self, package_base: str) -> Changes:
"""
get package changes
diff --git a/src/ahriman/core/status/watcher.py b/src/ahriman/core/status/watcher.py
index a3d704ff..e3389a21 100644
--- a/src/ahriman/core/status/watcher.py
+++ b/src/ahriman/core/status/watcher.py
@@ -27,6 +27,7 @@ from ahriman.core.status import Client
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies
+from ahriman.models.event import Event, EventType
from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch
@@ -68,6 +69,10 @@ class Watcher(LazyLogging):
with self._lock:
return list(self._known.values())
+ event_add: Callable[[Event], None]
+
+ event_get: Callable[[str | EventType | None, str | None, int, int], list[Event]]
+
def load(self) -> None:
"""
load packages from local database
diff --git a/src/ahriman/core/status/web_client.py b/src/ahriman/core/status/web_client.py
index 24b4ccd3..d80b47ae 100644
--- a/src/ahriman/core/status/web_client.py
+++ b/src/ahriman/core/status/web_client.py
@@ -27,6 +27,7 @@ from ahriman.core.status import Client
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies
+from ahriman.models.event import Event, EventType
from ahriman.models.internal_status import InternalStatus
from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package
@@ -109,6 +110,15 @@ class WebClient(Client, SyncAhrimanClient):
"""
return f"{self.address}/api/v1/packages/{urlencode(package_base)}/dependencies"
+ def _events_url(self) -> str:
+ """
+ get url for the events api
+
+ Returns:
+ str: full url for web service for events
+ """
+ return f"{self.address}/api/v1/events"
+
def _logs_url(self, package_base: str) -> str:
"""
get url for the logs api
@@ -157,6 +167,44 @@ class WebClient(Client, SyncAhrimanClient):
"""
return f"{self.address}/api/v1/status"
+ def event_add(self, event: Event) -> None:
+ """
+ create new event
+
+ Args:
+ event(Event): audit log event
+ """
+ with contextlib.suppress(Exception):
+ self.make_request("POST", self._events_url(), params=self.repository_id.query(), json=event.view())
+
+ def event_get(self, event: str | EventType | None, object_id: str | None,
+ limit: int = -1, offset: int = 0) -> list[Event]:
+ """
+ retrieve list of events
+
+ Args:
+ event(str | EventType | None): filter by event type
+ object_id(str | None): filter by event object
+ limit(int, optional): limit records to the specified count, -1 means unlimited (Default value = -1)
+ offset(int, optional): records offset (Default value = 0)
+
+ Returns:
+ list[Event]: list of audit log events
+ """
+ query = self.repository_id.query() + [("limit", str(limit)), ("offset", str(offset))]
+ if event is not None:
+ query.append(("event", str(event)))
+ if object_id is not None:
+ query.append(("object_id", object_id))
+
+ with contextlib.suppress(Exception):
+ response = self.make_request("GET", self._events_url(), params=query)
+ response_json = response.json()
+
+ return [Event.from_json(event) for event in response_json]
+
+ return []
+
def package_changes_get(self, package_base: str) -> Changes:
"""
get package changes
@@ -274,8 +322,9 @@ class WebClient(Client, SyncAhrimanClient):
Returns:
list[tuple[float, str]]: package logs
"""
+ query = self.repository_id.query() + [("limit", str(limit)), ("offset", str(offset))]
+
with contextlib.suppress(Exception):
- query = self.repository_id.query() + [("limit", str(limit)), ("offset", str(offset))]
response = self.make_request("GET", self._logs_url(package_base), params=query)
response_json = response.json()
@@ -291,10 +340,11 @@ class WebClient(Client, SyncAhrimanClient):
package_base(str): package base
version(str | None): package version to remove logs. If None set, all logs will be removed
"""
+ query = self.repository_id.query()
+ if version is not None:
+ query += [("version", version)]
+
with contextlib.suppress(Exception):
- query = self.repository_id.query()
- if version is not None:
- query += [("version", version)]
self.make_request("DELETE", self._logs_url(package_base), params=query)
def package_patches_get(self, package_base: str, variable: str | None) -> list[PkgbuildPatch]:
@@ -361,6 +411,7 @@ class WebClient(Client, SyncAhrimanClient):
NotImplementedError: not implemented method
"""
payload = {"status": status.value}
+
with contextlib.suppress(Exception):
self.make_request("POST", self._package_url(package_base),
params=self.repository_id.query(), json=payload)
@@ -380,6 +431,7 @@ class WebClient(Client, SyncAhrimanClient):
"status": status.value,
"package": package.view(),
}
+
with contextlib.suppress(Exception):
self.make_request("POST", self._package_url(package.base),
params=self.repository_id.query(), json=payload)
@@ -407,5 +459,6 @@ class WebClient(Client, SyncAhrimanClient):
status(BuildStatusEnum): current ahriman status
"""
payload = {"status": status.value}
+
with contextlib.suppress(Exception):
self.make_request("POST", self._status_url(), params=self.repository_id.query(), json=payload)
diff --git a/src/ahriman/models/event.py b/src/ahriman/models/event.py
new file mode 100644
index 00000000..7b5e970b
--- /dev/null
+++ b/src/ahriman/models/event.py
@@ -0,0 +1,89 @@
+#
+# Copyright (c) 2021-2024 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+from dataclasses import dataclass, field, fields
+from enum import StrEnum
+from typing import Any, Self
+
+from ahriman.core.utils import dataclass_view, filter_json, utcnow
+
+
+class EventType(StrEnum):
+ """
+ predefined event types
+
+ Attributes:
+ PackageRemoved(EventType): (class attribute) package has been removed
+ PackageUpdated(EventType): (class attribute) package has been updated
+ """
+
+ PackageRemoved = "package-removed"
+ PackageUpdated = "package-updated"
+
+
+@dataclass(frozen=True)
+class Event:
+ """
+ audit log event
+
+ Attributes:
+ created(int): event timestamp
+ data(dict[str, Any]): event metadata
+ event(str | EventType): event type
+ message(str | None): event message if available
+ object_id(str): object identifier
+ """
+
+ event: str | EventType
+ object_id: str
+ message: str | None = None
+ data: dict[str, Any] = field(default_factory=dict)
+ created: int = field(default_factory=lambda: int(utcnow().timestamp()))
+
+ def __post_init__(self) -> None:
+ """
+ replace null data to empty dictionary
+ """
+ if self.event in EventType:
+ object.__setattr__(self, "event", EventType(self.event))
+ object.__setattr__(self, "data", self.data or {})
+
+ @classmethod
+ def from_json(cls, dump: dict[str, Any]) -> Self:
+ """
+ construct event from the json dump
+
+ Args:
+ dump(dict[str, Any]): json dump body
+
+ Returns:
+ Self: dependencies object
+ """
+ # filter to only known fields
+ known_fields = [pair.name for pair in fields(cls)]
+ return cls(**filter_json(dump, known_fields))
+
+ def view(self) -> dict[str, Any]:
+ """
+ generate json event view
+
+ Returns:
+ dict[str, Any]: json-friendly dictionary
+ """
+ return dataclass_view(self)
diff --git a/src/ahriman/web/schemas/__init__.py b/src/ahriman/web/schemas/__init__.py
index aac967f8..8a2e8108 100644
--- a/src/ahriman/web/schemas/__init__.py
+++ b/src/ahriman/web/schemas/__init__.py
@@ -24,6 +24,8 @@ from ahriman.web.schemas.changes_schema import ChangesSchema
from ahriman.web.schemas.counters_schema import CountersSchema
from ahriman.web.schemas.dependencies_schema import DependenciesSchema
from ahriman.web.schemas.error_schema import ErrorSchema
+from ahriman.web.schemas.event_schema import EventSchema
+from ahriman.web.schemas.event_search_schema import EventSearchSchema
from ahriman.web.schemas.file_schema import FileSchema
from ahriman.web.schemas.info_schema import InfoSchema
from ahriman.web.schemas.internal_status_schema import InternalStatusSchema
diff --git a/src/ahriman/web/schemas/event_schema.py b/src/ahriman/web/schemas/event_schema.py
new file mode 100644
index 00000000..d9647d0c
--- /dev/null
+++ b/src/ahriman/web/schemas/event_schema.py
@@ -0,0 +1,47 @@
+#
+# Copyright (c) 2021-2024 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+from marshmallow import Schema, fields
+
+from ahriman.models.event import EventType
+
+
+class EventSchema(Schema):
+ """
+ request/response event schema
+ """
+
+ created = fields.Integer(required=True, metadata={
+ "description": "Event creation timestamp",
+ "example": 1680537091,
+ })
+ event = fields.String(required=True, metadata={
+ "description": "Event type",
+ "example": EventType.PackageUpdated,
+ })
+ object_id = fields.String(required=True, metadata={
+ "description": "Event object identifier",
+ "example": "ahriman",
+ })
+ message = fields.String(metadata={
+ "description": "Event message if available",
+ })
+ data = fields.Dict(keys=fields.String(), metadata={
+ "description": "Event metadata if available",
+ })
diff --git a/src/ahriman/web/schemas/event_search_schema.py b/src/ahriman/web/schemas/event_search_schema.py
new file mode 100644
index 00000000..3fd65c30
--- /dev/null
+++ b/src/ahriman/web/schemas/event_search_schema.py
@@ -0,0 +1,38 @@
+#
+# Copyright (c) 2021-2024 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+from marshmallow import fields
+
+from ahriman.models.event import EventType
+from ahriman.web.schemas.pagination_schema import PaginationSchema
+
+
+class EventSearchSchema(PaginationSchema):
+ """
+ request event search schema
+ """
+
+ event = fields.String(metadata={
+ "description": "Event type",
+ "example": EventType.PackageUpdated,
+ })
+ object_id = fields.String(metadata={
+ "description": "Event object identifier",
+ "example": "ahriman",
+ })
diff --git a/src/ahriman/web/views/v1/auditlog/__init__.py b/src/ahriman/web/views/v1/auditlog/__init__.py
new file mode 100644
index 00000000..78e01321
--- /dev/null
+++ b/src/ahriman/web/views/v1/auditlog/__init__.py
@@ -0,0 +1,19 @@
+#
+# Copyright (c) 2021-2024 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
diff --git a/src/ahriman/web/views/v1/auditlog/events.py b/src/ahriman/web/views/v1/auditlog/events.py
new file mode 100644
index 00000000..21847aa5
--- /dev/null
+++ b/src/ahriman/web/views/v1/auditlog/events.py
@@ -0,0 +1,104 @@
+#
+# Copyright (c) 2021-2024 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+import aiohttp_apispec # type: ignore[import-untyped]
+
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
+
+from ahriman.models.event import Event
+from ahriman.models.user_access import UserAccess
+from ahriman.web.schemas import AuthSchema, ErrorSchema, EventSchema, EventSearchSchema
+from ahriman.web.views.base import BaseView
+
+
+class EventsView(BaseView):
+ """
+ audit log view
+
+ Attributes:
+ GET_PERMISSION(UserAccess): (class attribute) get permissions of self
+ POST_PERMISSION(UserAccess): (class attribute) post permissions of self
+ """
+
+ GET_PERMISSION = POST_PERMISSION = UserAccess.Full
+ ROUTES = ["/api/v1/events"]
+
+ @aiohttp_apispec.docs(
+ tags=["Audit log"],
+ summary="Get events",
+ description="Retrieve events from audit log",
+ responses={
+ 200: {"description": "Success response", "schema": EventSchema(many=True)},
+ 400: {"description": "Bad data is supplied", "schema": ErrorSchema},
+ 401: {"description": "Authorization required", "schema": ErrorSchema},
+ 403: {"description": "Access is forbidden", "schema": ErrorSchema},
+ 500: {"description": "Internal server error", "schema": ErrorSchema},
+ },
+ security=[{"token": [GET_PERMISSION]}],
+ )
+ @aiohttp_apispec.cookies_schema(AuthSchema)
+ @aiohttp_apispec.querystring_schema(EventSearchSchema)
+ async def get(self) -> Response:
+ """
+ get events list
+
+ Returns:
+ Response: 200 with workers list on success
+ """
+ limit, offset = self.page()
+ event = self.request.query.get("event") or None
+ object_id = self.request.query.get("object_id") or None
+
+ events = self.service().event_get(event, object_id, limit, offset)
+ response = [event.view() for event in events]
+
+ return json_response(response)
+
+ @aiohttp_apispec.docs(
+ tags=["Audit log"],
+ summary="Create event",
+ description="Add new event to the audit log",
+ responses={
+ 204: {"description": "Success response"},
+ 400: {"description": "Bad data is supplied", "schema": ErrorSchema},
+ 401: {"description": "Authorization required", "schema": ErrorSchema},
+ 403: {"description": "Access is forbidden", "schema": ErrorSchema},
+ 500: {"description": "Internal server error", "schema": ErrorSchema},
+ },
+ security=[{"token": [POST_PERMISSION]}],
+ )
+ @aiohttp_apispec.cookies_schema(AuthSchema)
+ @aiohttp_apispec.json_schema(EventSchema)
+ async def post(self) -> None:
+ """
+ add new audit log event
+
+ Raises:
+ HTTPBadRequest: if bad data is supplied
+ HTTPNoContent: in case of success response
+ """
+ try:
+ data = await self.request.json()
+ event = Event.from_json(data)
+ except Exception as ex:
+ raise HTTPBadRequest(reason=str(ex))
+
+ self.service().event_add(event)
+
+ raise HTTPNoContent
diff --git a/tests/ahriman/core/database/migrations/test_m014_auditlog.py b/tests/ahriman/core/database/migrations/test_m014_auditlog.py
new file mode 100644
index 00000000..63bca2da
--- /dev/null
+++ b/tests/ahriman/core/database/migrations/test_m014_auditlog.py
@@ -0,0 +1,8 @@
+from ahriman.core.database.migrations.m014_auditlog import steps
+
+
+def test_migration_auditlog() -> None:
+ """
+ migration must not be empty
+ """
+ assert steps
diff --git a/tests/ahriman/core/database/operations/test_event_operations.py b/tests/ahriman/core/database/operations/test_event_operations.py
new file mode 100644
index 00000000..4a66d392
--- /dev/null
+++ b/tests/ahriman/core/database/operations/test_event_operations.py
@@ -0,0 +1,40 @@
+from ahriman.core.database import SQLite
+from ahriman.models.event import Event, EventType
+from ahriman.models.package import Package
+from ahriman.models.repository_id import RepositoryId
+
+
+def test_event_insert_get(database: SQLite, package_ahriman: Package) -> None:
+ """
+ must insert and get event
+ """
+ event = Event(EventType.PackageUpdated, package_ahriman.base, "Updated", {"key": "value"})
+ database.event_insert(event)
+ assert database.event_get() == [event]
+
+ event2 = Event("event", "object")
+ database.event_insert(event2, RepositoryId("i686", database._repository_id.name))
+ assert database.event_get() == [event]
+ assert database.event_get(repository_id=RepositoryId("i686", database._repository_id.name)) == [event2]
+
+
+def test_event_insert_get_filter(database: SQLite) -> None:
+ """
+ must insert and get events with filter
+ """
+ database.event_insert(Event("event 1", "object 1", created=1))
+ database.event_insert(Event("event 2", "object 2"))
+ database.event_insert(Event(EventType.PackageUpdated, "package"))
+
+ assert database.event_get(event="event 1") == [Event("event 1", "object 1", created=1)]
+ assert database.event_get(object_id="object 1") == [Event("event 1", "object 1", created=1)]
+ assert all(event.event == EventType.PackageUpdated for event in database.event_get(event=EventType.PackageUpdated))
+
+
+def test_event_insert_get_pagination(database: SQLite) -> None:
+ """
+ must insert and get events with pagination
+ """
+ database.event_insert(Event("1", "1"))
+ database.event_insert(Event("2", "2"))
+ assert all(event.event == "2" for event in database.event_get(limit=1, offset=1))
diff --git a/tests/ahriman/core/status/test_client.py b/tests/ahriman/core/status/test_client.py
index 51b151f3..477c4553 100644
--- a/tests/ahriman/core/status/test_client.py
+++ b/tests/ahriman/core/status/test_client.py
@@ -11,6 +11,7 @@ from ahriman.core.status.web_client import WebClient
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies
+from ahriman.models.event import Event
from ahriman.models.internal_status import InternalStatus
from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package
@@ -94,6 +95,22 @@ def test_load_web_client_from_legacy_unix_socket(configuration: Configuration, d
assert isinstance(Client.load(repository_id, configuration, database, report=True), WebClient)
+def test_event_add(client: Client) -> None:
+ """
+ must raise not implemented on event insertion
+ """
+ with pytest.raises(NotImplementedError):
+ client.event_add(Event("", ""))
+
+
+def test_event_get(client: Client) -> None:
+ """
+ must raise not implemented on events request
+ """
+ with pytest.raises(NotImplementedError):
+ client.event_get(None, None)
+
+
def test_package_changes_get(client: Client, package_ahriman: Package) -> None:
"""
must raise not implemented on package changes request
diff --git a/tests/ahriman/core/status/test_local_client.py b/tests/ahriman/core/status/test_local_client.py
index 8e3db4f4..c43f8956 100644
--- a/tests/ahriman/core/status/test_local_client.py
+++ b/tests/ahriman/core/status/test_local_client.py
@@ -7,11 +7,32 @@ from ahriman.core.status.local_client import LocalClient
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies
+from ahriman.models.event import Event, EventType
from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch
+def test_event_add(local_client: LocalClient, package_ahriman: Package, mocker: MockerFixture) -> None:
+ """
+ must add new event
+ """
+ event_mock = mocker.patch("ahriman.core.database.SQLite.event_insert")
+ event = Event(EventType.PackageUpdated, package_ahriman.base)
+
+ local_client.event_add(event)
+ event_mock.assert_called_once_with(event, local_client.repository_id)
+
+
+def test_event_get(local_client: LocalClient, package_ahriman: Package, mocker: MockerFixture) -> None:
+ """
+ must retrieve events
+ """
+ event_mock = mocker.patch("ahriman.core.database.SQLite.event_get")
+ local_client.event_get(EventType.PackageUpdated, package_ahriman.base, 1, 2)
+ event_mock.assert_called_once_with(EventType.PackageUpdated, package_ahriman.base, 1, 2, local_client.repository_id)
+
+
def test_package_changes_get(local_client: LocalClient, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must retrieve package changes
diff --git a/tests/ahriman/core/status/test_web_client.py b/tests/ahriman/core/status/test_web_client.py
index 3b75fb95..5bf9843e 100644
--- a/tests/ahriman/core/status/test_web_client.py
+++ b/tests/ahriman/core/status/test_web_client.py
@@ -10,6 +10,7 @@ from ahriman.core.status.web_client import WebClient
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies
+from ahriman.models.event import Event, EventType
from ahriman.models.internal_status import InternalStatus
from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package
@@ -54,18 +55,12 @@ def test_dependencies_url(web_client: WebClient, package_ahriman: Package) -> No
"/api/v1/packages/some%2Fpackage%25name/dependencies")
-def test__patches_url(web_client: WebClient, package_ahriman: Package) -> None:
+def test_event_url(web_client: WebClient) -> None:
"""
- must generate changes url correctly
+ must generate audit log url correctly
"""
- assert web_client._patches_url(package_ahriman.base).startswith(web_client.address)
- assert web_client._patches_url(package_ahriman.base).endswith(f"/api/v1/packages/{package_ahriman.base}/patches")
- assert web_client._patches_url("some/package%name").endswith("/api/v1/packages/some%2Fpackage%25name/patches")
-
- assert web_client._patches_url(package_ahriman.base, "var").endswith(
- f"/api/v1/packages/{package_ahriman.base}/patches/var")
- assert web_client._patches_url(package_ahriman.base, "some/variable%name").endswith(
- f"/api/v1/packages/{package_ahriman.base}/patches/some%2Fvariable%25name")
+ assert web_client._events_url().startswith(web_client.address)
+ assert web_client._events_url().endswith("/api/v1/events")
def test_logs_url(web_client: WebClient, package_ahriman: Package) -> None:
@@ -89,6 +84,20 @@ def test_package_url(web_client: WebClient, package_ahriman: Package) -> None:
assert web_client._package_url("some/package%name").endswith("/api/v1/packages/some%2Fpackage%25name")
+def test_patches_url(web_client: WebClient, package_ahriman: Package) -> None:
+ """
+ must generate changes url correctly
+ """
+ assert web_client._patches_url(package_ahriman.base).startswith(web_client.address)
+ assert web_client._patches_url(package_ahriman.base).endswith(f"/api/v1/packages/{package_ahriman.base}/patches")
+ assert web_client._patches_url("some/package%name").endswith("/api/v1/packages/some%2Fpackage%25name/patches")
+
+ assert web_client._patches_url(package_ahriman.base, "var").endswith(
+ f"/api/v1/packages/{package_ahriman.base}/patches/var")
+ assert web_client._patches_url(package_ahriman.base, "some/variable%name").endswith(
+ f"/api/v1/packages/{package_ahriman.base}/patches/some%2Fvariable%25name")
+
+
def test_status_url(web_client: WebClient) -> None:
"""
must generate package status url correctly
@@ -97,6 +106,135 @@ def test_status_url(web_client: WebClient) -> None:
assert web_client._status_url().endswith("/api/v1/status")
+def test_event_add(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None:
+ """
+ must create event
+ """
+ event = Event(EventType.PackageUpdated, package_ahriman.base)
+ requests_mock = mocker.patch("ahriman.core.status.web_client.WebClient.make_request")
+
+ web_client.event_add(event)
+ requests_mock.assert_called_once_with("POST", pytest.helpers.anyvar(str, True),
+ params=web_client.repository_id.query(), json=event.view())
+
+
+def test_event_add_failed(web_client: WebClient, mocker: MockerFixture) -> None:
+ """
+ must suppress any exception happened during events creation
+ """
+ mocker.patch("requests.Session.request", side_effect=Exception())
+ web_client.event_add(Event("", ""))
+
+
+def test_event_add_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None:
+ """
+ must suppress HTTP exception happened during events creation
+ """
+ mocker.patch("requests.Session.request", side_effect=requests.HTTPError())
+ web_client.event_add(Event("", ""))
+
+
+def test_event_add_failed_suppress(web_client: WebClient, mocker: MockerFixture) -> None:
+ """
+ must suppress any exception happened during events creaton and don't log
+ """
+ web_client.suppress_errors = True
+ mocker.patch("requests.Session.request", side_effect=Exception())
+ logging_mock = mocker.patch("logging.exception")
+
+ web_client.event_add(Event("", ""))
+ logging_mock.assert_not_called()
+
+
+def test_event_add_failed_http_error_suppress(web_client: WebClient, mocker: MockerFixture) -> None:
+ """
+ must suppress HTTP exception happened during events creation and don't log
+ """
+ web_client.suppress_errors = True
+ mocker.patch("requests.Session.request", side_effect=requests.HTTPError())
+ logging_mock = mocker.patch("logging.exception")
+
+ web_client.event_add(Event("", ""))
+ logging_mock.assert_not_called()
+
+
+def test_event_get(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None:
+ """
+ must get events
+ """
+ event = Event(EventType.PackageUpdated, package_ahriman.base)
+ response_obj = requests.Response()
+ response_obj._content = json.dumps([event.view()]).encode("utf8")
+ response_obj.status_code = 200
+
+ requests_mock = mocker.patch("ahriman.core.status.web_client.WebClient.make_request", return_value=response_obj)
+
+ result = web_client.event_get(None, None)
+ requests_mock.assert_called_once_with("GET", pytest.helpers.anyvar(str, True),
+ params=web_client.repository_id.query() + [("limit", "-1"), ("offset", "0")])
+ assert result == [event]
+
+
+def test_event_get_filter(web_client: WebClient, mocker: MockerFixture) -> None:
+ """
+ must get events with filter
+ """
+ response_obj = requests.Response()
+ response_obj._content = json.dumps(Event("", "").view()).encode("utf8")
+ response_obj.status_code = 200
+
+ requests_mock = mocker.patch("ahriman.core.status.web_client.WebClient.make_request", return_value=response_obj)
+
+ web_client.event_get("event", "object", 1, 2)
+ requests_mock.assert_called_once_with("GET", pytest.helpers.anyvar(str, True),
+ params=web_client.repository_id.query() + [
+ ("limit", "1"),
+ ("offset", "2"),
+ ("event", "event"),
+ ("object_id", "object"),
+ ])
+
+
+def test_event_get_failed(web_client: WebClient, mocker: MockerFixture) -> None:
+ """
+ must suppress any exception happened during events fetch
+ """
+ mocker.patch("requests.Session.request", side_effect=Exception())
+ web_client.event_get(None, None)
+
+
+def test_event_get_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None:
+ """
+ must suppress HTTP exception happened during events fetch
+ """
+ mocker.patch("requests.Session.request", side_effect=requests.HTTPError())
+ web_client.event_get(None, None)
+
+
+def test_event_get_failed_suppress(web_client: WebClient, mocker: MockerFixture) -> None:
+ """
+ must suppress any exception happened during events fetch and don't log
+ """
+ web_client.suppress_errors = True
+ mocker.patch("requests.Session.request", side_effect=Exception())
+ logging_mock = mocker.patch("logging.exception")
+
+ web_client.event_get(None, None)
+ logging_mock.assert_not_called()
+
+
+def test_event_get_failed_http_error_suppress(web_client: WebClient, mocker: MockerFixture) -> None:
+ """
+ must suppress HTTP exception happened during events fetch and don't log
+ """
+ web_client.suppress_errors = True
+ mocker.patch("requests.Session.request", side_effect=requests.HTTPError())
+ logging_mock = mocker.patch("logging.exception")
+
+ web_client.event_get(None, None)
+ logging_mock.assert_not_called()
+
+
def test_package_changes_get(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must get changes
diff --git a/tests/ahriman/models/test_event.py b/tests/ahriman/models/test_event.py
new file mode 100644
index 00000000..e9f4969c
--- /dev/null
+++ b/tests/ahriman/models/test_event.py
@@ -0,0 +1,17 @@
+from ahriman.models.event import Event, EventType
+
+
+def test_post_init() -> None:
+ """
+ must remove replace empty dictionary
+ """
+ assert Event("", "").data == {}
+ assert isinstance(Event(str(EventType.PackageUpdated), "").event, EventType)
+
+
+def test_from_json_view() -> None:
+ """
+ must construct and serialize event to json
+ """
+ event = Event("event", "object", "message", {"key": "value"})
+ assert Event.from_json(event.view()) == event
diff --git a/tests/ahriman/web/schemas/test_event_schema.py b/tests/ahriman/web/schemas/test_event_schema.py
new file mode 100644
index 00000000..1982fb6b
--- /dev/null
+++ b/tests/ahriman/web/schemas/test_event_schema.py
@@ -0,0 +1 @@
+# schema testing goes in view class tests
diff --git a/tests/ahriman/web/schemas/test_event_search_schema.py b/tests/ahriman/web/schemas/test_event_search_schema.py
new file mode 100644
index 00000000..1982fb6b
--- /dev/null
+++ b/tests/ahriman/web/schemas/test_event_search_schema.py
@@ -0,0 +1 @@
+# schema testing goes in view class tests
diff --git a/tests/ahriman/web/views/v1/auditlog/test_view_v1_auditlog_events.py b/tests/ahriman/web/views/v1/auditlog/test_view_v1_auditlog_events.py
new file mode 100644
index 00000000..cf534ee6
--- /dev/null
+++ b/tests/ahriman/web/views/v1/auditlog/test_view_v1_auditlog_events.py
@@ -0,0 +1,104 @@
+import pytest
+
+from aiohttp.test_utils import TestClient
+
+from ahriman.models.event import Event
+from ahriman.models.user_access import UserAccess
+from ahriman.web.views.v1.auditlog.events import EventsView
+
+
+async def test_get_permission() -> None:
+ """
+ must return correct permission for the request
+ """
+ for method in ("GET", "POST"):
+ request = pytest.helpers.request("", "", method)
+ assert await EventsView.get_permission(request) == UserAccess.Full
+
+
+def test_routes() -> None:
+ """
+ must return correct routes
+ """
+ assert EventsView.ROUTES == ["/api/v1/events"]
+
+
+async def test_get(client: TestClient) -> None:
+ """
+ must return all events
+ """
+ event1 = Event("event1", "object1", "message", {"key": "value"})
+ event2 = Event("event2", "object2")
+ await client.post("/api/v1/events", json=event1.view())
+ await client.post("/api/v1/events", json=event2.view())
+ response_schema = pytest.helpers.schema_response(EventsView.get)
+
+ response = await client.get("/api/v1/events")
+ assert response.ok
+ json = await response.json()
+ assert not response_schema.validate(json, many=True)
+
+ events = [Event.from_json(event) for event in json]
+ assert events == [event1, event2]
+
+
+async def test_get_with_pagination(client: TestClient) -> None:
+ """
+ must get events with pagination
+ """
+ event1 = Event("event1", "object1", "message", {"key": "value"})
+ event2 = Event("event2", "object2")
+ await client.post("/api/v1/events", json=event1.view())
+ await client.post("/api/v1/events", json=event2.view())
+ request_schema = pytest.helpers.schema_request(EventsView.get, location="querystring")
+ response_schema = pytest.helpers.schema_response(EventsView.get)
+
+ payload = {"limit": 1, "offset": 1}
+ assert not request_schema.validate(payload)
+ response = await client.get("/api/v1/events", params=payload)
+ assert response.status == 200
+
+ json = await response.json()
+ assert not response_schema.validate(json, many=True)
+
+ assert [Event.from_json(event) for event in json] == [event2]
+
+
+async def test_get_bad_request(client: TestClient) -> None:
+ """
+ must return bad request for invalid query parameters
+ """
+ response_schema = pytest.helpers.schema_response(EventsView.get, code=400)
+
+ response = await client.get("/api/v1/events", params={"limit": "limit"})
+ assert response.status == 400
+ assert not response_schema.validate(await response.json())
+
+ response = await client.get("/api/v1/events", params={"offset": "offset"})
+ assert response.status == 400
+ assert not response_schema.validate(await response.json())
+
+
+async def test_post(client: TestClient) -> None:
+ """
+ must create event
+ """
+ event = Event("event1", "object1", "message", {"key": "value"})
+ request_schema = pytest.helpers.schema_request(EventsView.post)
+
+ payload = event.view()
+ assert not request_schema.validate(payload)
+
+ response = await client.post("/api/v1/events", json=payload)
+ assert response.status == 204
+
+
+async def test_post_exception(client: TestClient) -> None:
+ """
+ must raise exception on invalid payload
+ """
+ response_schema = pytest.helpers.schema_response(EventsView.post, code=400)
+
+ response = await client.post("/api/v1/events", json={})
+ assert response.status == 400
+ assert not response_schema.validate(await response.json())