keepup-audit 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: keepup-audit
3
+ Version: 0.4.0
4
+ Summary: Incoming calls, the application's events, the administrator's trail and their retention.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: keepup-db>=0.4.0
8
+
9
+ # keepup-audit
10
+
11
+ Incoming calls, the application's events, the administrator's trail and their retention.
12
+
13
+ One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
14
+ panels: the base is installed as `keepup-admin` and imported as `keepup`, and
15
+ this distribution carries one part of it. It is installed on its own
16
+ (`pip install keepup-audit`) or together with the base (`pip install "keepup-admin[panel]"`),
17
+ and it declares itself to the kernel through its
18
+ `[project.entry-points."keepup.plugins"]` entry point.
@@ -0,0 +1,10 @@
1
+ # keepup-audit
2
+
3
+ Incoming calls, the application's events, the administrator's trail and their retention.
4
+
5
+ One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
6
+ panels: the base is installed as `keepup-admin` and imported as `keepup`, and
7
+ this distribution carries one part of it. It is installed on its own
8
+ (`pip install keepup-audit`) or together with the base (`pip install "keepup-admin[panel]"`),
9
+ and it declares itself to the kernel through its
10
+ `[project.entry-points."keepup.plugins"]` entry point.
@@ -0,0 +1,12 @@
1
+ """Incoming calls, the application's events, the administrator's trail and their retention.
2
+
3
+ This distribution provides `audit, events` and declares one plugin,
4
+ `audit`, in the `keepup.plugins` group. It depends on `keepup-admin`
5
+ for the constructor -- the descriptor, the catalogue, the service registry and
6
+ the lifecycle -- and on nothing of another capability.
7
+
8
+ The code moves here in keepup-124: until then the plugin lives in the framework's
9
+ own `builtin/audit.py` and this package is the home it is moving to.
10
+ """
11
+
12
+ __version__ = "0.4.0"
@@ -0,0 +1,496 @@
1
+ """The application's event log.
2
+
3
+ Not the request audit (`keepup/audit.py`) and not the application log
4
+ (`keepup/logging_setup.py`): this records things that happened in the product's
5
+ own terms -- somebody signed in, an order was accepted, a job failed -- so that
6
+ they can be read back by type and by actor long after the log files rotated.
7
+
8
+ Which types exist is the application's business and arrives through
9
+ KeepupSettings: the framework holds the table, the API and the retention, and
10
+ knows none of the names. See `doc/event_manager.md`.
11
+
12
+ This module is the journal -- the table, writing, reading, retention. Its HTTP
13
+ routes are `keepup/events_api.py` (keepup-23): the journal is written from
14
+ everywhere in an application, the routes are one reader of it among others,
15
+ and a module that only emits events has no reason to load FastAPI.
16
+ """
17
+
18
+ import asyncio
19
+ import logging
20
+ import json
21
+ from datetime import datetime, timedelta
22
+ from typing import Optional, List, Dict, Any
23
+ from sqlalchemy import Index
24
+
25
+ from keepup import retention
26
+ from keepup_db import tables
27
+ from keepup_db import DatabaseManagerV2
28
+ from keepup.instance import get_instance_id, get_instance_name
29
+
30
+ #: What an application may import from this module. Everything else is
31
+ #: internal and may change without notice -- see doc/keepup.md.
32
+ __all__ = [
33
+ "DEFAULT_EVENTS_RETENTION_DAYS",
34
+ "emit_event",
35
+ "events_retention_background",
36
+ "init_event_manager",
37
+ ]
38
+
39
+ # Declared by the audit capability, which keeps the events (keepup-124).
40
+ from keepup_audit.tables import APP_EVENTS # noqa: E402
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ # Newest first, as the listing pages; the type-and-date pair was only ever
45
+ # created on PostgreSQL.
46
+ Index("idx_app_events_created_at", APP_EVENTS.c.created_at.desc())
47
+ Index("idx_app_events_type_date", APP_EVENTS.c.event_type,
48
+ APP_EVENTS.c.created_at.desc()).ddl_if(dialect="postgresql")
49
+
50
+
51
+ class EventManager:
52
+ """Application event manager."""
53
+
54
+ def __init__(self):
55
+ self.instance_id = get_instance_id()
56
+ self.instance_name = get_instance_name()
57
+ self._initialized = False
58
+
59
+ def init_table(self):
60
+ """Create the app_events table."""
61
+ try:
62
+ tables.ensure_tables(APP_EVENTS)
63
+ self._initialized = True
64
+ logger.info(f"App events table initialized successfully (instance: {self.instance_name})")
65
+
66
+ except Exception as e:
67
+ logger.error(f"Error initializing app events table: {str(e)}")
68
+ raise
69
+
70
+ async def create_event(
71
+ self,
72
+ event_type: str,
73
+ event_text: str,
74
+ event_data: Optional[Dict[str, Any]] = None,
75
+ instance_id: Optional[str] = None,
76
+ instance_name: Optional[str] = None
77
+ ) -> int:
78
+ """Create an event.
79
+
80
+ Args:
81
+ event_type: event type
82
+ event_text: event text
83
+ event_data: optional extra payload
84
+ instance_id: instance id, defaults to the current instance
85
+ instance_name: instance name, defaults to the current instance
86
+
87
+ Returns:
88
+ int: id of the created event.
89
+ """
90
+ if not self._initialized:
91
+ await asyncio.to_thread(self.init_table)
92
+
93
+ try:
94
+ event_instance_id = instance_id or self.instance_id
95
+ event_instance_name = instance_name or self.instance_name
96
+
97
+ event_data_json = json.dumps(event_data, ensure_ascii=False) if event_data else None
98
+
99
+ row = await DatabaseManagerV2.execute_commit_returning_async('''
100
+ INSERT INTO app_events (event_type, event_text, event_data, instance_id, instance_name)
101
+ VALUES (:event_type, :event_text, :event_data, :instance_id, :instance_name)
102
+ ''', {"event_type": event_type, "event_text": event_text,
103
+ "event_data": event_data_json, "instance_id": event_instance_id,
104
+ "instance_name": event_instance_name}, "id")
105
+ logger.debug(f"Event created: {event_type} - {event_text[:50]} (instance: {event_instance_name})")
106
+ return row["id"] if row else None
107
+
108
+ except Exception as e:
109
+ logger.error(f"Error creating event: {str(e)}")
110
+ raise
111
+
112
+ async def get_events(
113
+ self,
114
+ event_type: Optional[str] = None,
115
+ instance_id: Optional[str] = None,
116
+ instance_name: Optional[str] = None,
117
+ start_date: Optional[datetime] = None,
118
+ end_date: Optional[datetime] = None,
119
+ page: int = 1,
120
+ page_size: int = 50
121
+ ) -> Dict[str, Any]:
122
+ """Return events, filtered and paginated.
123
+
124
+ Args:
125
+ event_type: filter by event type
126
+ instance_id: filter by instance id
127
+ instance_name: filter by instance name
128
+ start_date: lower date bound
129
+ end_date: upper date bound
130
+ page: page number, starting at 1
131
+ page_size: page size
132
+
133
+ Returns:
134
+ Dict with the events and their metadata.
135
+ """
136
+ if not self._initialized:
137
+ await asyncio.to_thread(self.init_table)
138
+
139
+ try:
140
+ conditions, params = [], {}
141
+ for column, operator, name, value in (
142
+ ("event_type", "=", "event_type", event_type),
143
+ ("instance_id", "=", "instance_id", instance_id),
144
+ ("instance_name", "=", "instance_name", instance_name),
145
+ ("created_at", ">=", "start_date", start_date),
146
+ ("created_at", "<=", "end_date", end_date)):
147
+ if value:
148
+ conditions.append(f"{column} {operator} :{name}")
149
+ params[name] = value
150
+
151
+ where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
152
+
153
+ total_result = await DatabaseManagerV2.execute_one_async(
154
+ f"SELECT COUNT(*) as total FROM app_events {where_clause}", params)
155
+ total = total_result['total'] if total_result else 0
156
+
157
+ offset = (page - 1) * page_size
158
+ rows = await DatabaseManagerV2.execute_async(f'''
159
+ SELECT id, event_type, event_text, event_data, instance_id, instance_name, created_at
160
+ FROM app_events
161
+ {where_clause}
162
+ ORDER BY created_at DESC
163
+ LIMIT :limit OFFSET :offset
164
+ ''', {**params, "limit": page_size, "offset": offset})
165
+
166
+ events = []
167
+ for row in rows:
168
+ event = {
169
+ 'id': row['id'],
170
+ 'event_type': row['event_type'],
171
+ 'event_text': row['event_text'],
172
+ 'instance_id': row['instance_id'],
173
+ 'instance_name': row['instance_name'] if len(row) > 5 else None,
174
+ 'created_at': row['created_at'].isoformat() if len(row) > 6 and hasattr(row['created_at'], 'isoformat') else str(
175
+ row['created_at']) if len(row) > 6 else None
176
+ }
177
+
178
+ # Deprecated: `event_data` has always carried the *text*, parsed
179
+ # as JSON when it happened to be JSON, and the event's own data
180
+ # was not in the answer at all (keepup-57). Kept as it was for
181
+ # whoever reads it; goes in the next major release.
182
+ if len(row) > 3 and row['event_text']:
183
+ try:
184
+ if isinstance(row['event_text'], str):
185
+ event['event_data'] = json.loads(row['event_text'])
186
+ else:
187
+ event['event_data'] = row['event_text']
188
+ except (TypeError, ValueError):
189
+ event['event_data'] = row['event_text']
190
+
191
+ event['data'] = _stored_data(row['event_data'])
192
+
193
+ events.append(event)
194
+
195
+ total_pages = (total + page_size - 1) // page_size
196
+
197
+ return {
198
+ 'events': events,
199
+ 'total': total,
200
+ 'page': page,
201
+ 'page_size': page_size,
202
+ 'total_pages': total_pages
203
+ }
204
+
205
+ except Exception as e:
206
+ logger.error(f"Error getting events: {str(e)}")
207
+ raise
208
+
209
+ async def get_event_types(self) -> List[str]:
210
+ """Return every distinct event type.
211
+
212
+ Returns:
213
+ List[str]: the event types.
214
+ """
215
+ if not self._initialized:
216
+ await asyncio.to_thread(self.init_table)
217
+
218
+ try:
219
+ rows = await DatabaseManagerV2.execute_async(
220
+ "SELECT DISTINCT event_type FROM app_events ORDER BY event_type")
221
+ return [row['event_type'] for row in rows]
222
+
223
+ except Exception as e:
224
+ logger.error(f"Error getting event types: {str(e)}")
225
+ return []
226
+
227
+ async def get_instances(self) -> List[Dict[str, str]]:
228
+ """Return every instance that has produced events.
229
+
230
+ Returns:
231
+ List[Dict]: instances with their id and name.
232
+ """
233
+ if not self._initialized:
234
+ await asyncio.to_thread(self.init_table)
235
+
236
+ try:
237
+ rows = await DatabaseManagerV2.execute_async('''
238
+ SELECT DISTINCT instance_id, instance_name
239
+ FROM app_events
240
+ WHERE instance_name IS NOT NULL
241
+ ORDER BY instance_name
242
+ ''')
243
+ return [{'instance_id': row['instance_id'], 'instance_name': row['instance_name']}
244
+ for row in rows]
245
+
246
+ except Exception as e:
247
+ logger.error(f"Error getting instances: {str(e)}")
248
+ return []
249
+
250
+ async def delete_old_events(self, days: int = 30) -> int:
251
+ """Delete events older than the given number of days.
252
+
253
+ Args:
254
+ days: age threshold in days, 30 by default
255
+
256
+ Returns:
257
+ int: number of deleted events.
258
+ """
259
+ if not self._initialized:
260
+ await asyncio.to_thread(self.init_table)
261
+
262
+ try:
263
+ # The boundary is computed here rather than written into the
264
+ # statement. It used to be `INTERVAL '%s days'` -- the placeholder
265
+ # inside a string literal, where the driver's quoting does not
266
+ # reach: a string argument would have left the literal. Nothing
267
+ # exploited it only because the one caller passes an int from a
268
+ # bounded query parameter, and this is a public method (keepup-15).
269
+ cutoff = datetime.utcnow() - timedelta(days=int(days))
270
+ deleted_count = await DatabaseManagerV2.execute_commit_async(
271
+ "DELETE FROM app_events WHERE created_at < :cutoff", {"cutoff": cutoff})
272
+ if deleted_count > 0:
273
+ logger.info(f"Deleted {deleted_count} old events (older than {days} days)")
274
+ return deleted_count
275
+
276
+ except Exception as e:
277
+ logger.error(f"Error deleting old events: {str(e)}")
278
+ return 0
279
+
280
+ async def get_events_stats(self) -> Dict[str, Any]:
281
+ """Return event statistics grouped by instance.
282
+
283
+ Returns:
284
+ Dict: the statistics.
285
+ """
286
+ if not self._initialized:
287
+ await asyncio.to_thread(self.init_table)
288
+
289
+ try:
290
+ # One statement for both dialects; rows are read by column name,
291
+ # which is what a PostgreSQL row is (it has no positions).
292
+ type_rows = await DatabaseManagerV2.execute_async('''
293
+ SELECT event_type, COUNT(*) as count,
294
+ MIN(created_at) as first_event, MAX(created_at) as last_event
295
+ FROM app_events
296
+ GROUP BY event_type
297
+ ORDER BY count DESC
298
+ ''')
299
+ instance_rows = await DatabaseManagerV2.execute_async('''
300
+ SELECT instance_id, instance_name, COUNT(*) as event_count,
301
+ MIN(created_at) as first_event, MAX(created_at) as last_event
302
+ FROM app_events
303
+ GROUP BY instance_id, instance_name
304
+ ORDER BY event_count DESC
305
+ ''')
306
+
307
+ def moment(value):
308
+ return value.isoformat() if hasattr(value, 'isoformat') else str(value)
309
+
310
+ type_stats = [{
311
+ 'event_type': row['event_type'],
312
+ 'count': row['count'],
313
+ 'first_event': moment(row['first_event']),
314
+ 'last_event': moment(row['last_event']),
315
+ } for row in type_rows]
316
+ instance_stats = [{
317
+ 'instance_id': row['instance_id'],
318
+ 'instance_name': row['instance_name'],
319
+ 'event_count': row['event_count'],
320
+ 'first_event': moment(row['first_event']),
321
+ 'last_event': moment(row['last_event']),
322
+ } for row in instance_rows]
323
+
324
+ return {
325
+ "total_events": sum(stat['count'] for stat in type_stats),
326
+ "unique_event_types": len(type_stats),
327
+ "unique_instances": len(instance_stats),
328
+ "event_types": type_stats,
329
+ "instances": instance_stats,
330
+ "current_instance": {
331
+ "id": self.instance_id,
332
+ "name": self.instance_name
333
+ }
334
+ }
335
+
336
+ except Exception as e:
337
+ logger.error(f"Error getting events stats: {str(e)}")
338
+ return {
339
+ "total_events": 0,
340
+ "unique_event_types": 0,
341
+ "unique_instances": 0,
342
+ "event_types": [],
343
+ "instances": [],
344
+ "current_instance": {
345
+ "id": self.instance_id,
346
+ "name": self.instance_name
347
+ }
348
+ }
349
+
350
+
351
+ event_manager = EventManager()
352
+
353
+
354
+ # --- how long an event lives --------------------------------------------------
355
+
356
+ #: How many days an event is kept (``EVENTS_RETENTION_DAYS``). The log was never
357
+ #: swept on its own -- only a manual route deleted old events -- so it grew for as
358
+ #: long as the deployment ran. Longer than the request audit: an event is the
359
+ #: product's own record of what happened (a sign-in, a blocked account), read
360
+ #: back by an administrator weeks later.
361
+ DEFAULT_EVENTS_RETENTION_DAYS = 90
362
+ #: How often the sweep runs, and how much one pass may delete.
363
+ EVENTS_SWEEP_INTERVAL_SECONDS = 3600
364
+ EVENTS_DELETE_CHUNK_ROWS = 5000
365
+ EVENTS_MAX_CHUNKS_PER_PASS = 20
366
+ #: How long to wait after a failed pass.
367
+ EVENTS_ERROR_BACKOFF_SECONDS = 60
368
+
369
+ EVENTS_RETENTION_LOCK = "app_events_retention"
370
+
371
+
372
+ def _stored_data(value):
373
+ """The event's data as it was written: a mapping, or None when there is none.
374
+
375
+ Stored as JSON text; a value that does not parse is handed back as it is
376
+ rather than dropped, so nothing written is lost from the answer.
377
+ """
378
+ if value is None or value == "":
379
+ return None
380
+ if isinstance(value, str):
381
+ try:
382
+ return json.loads(value)
383
+ except ValueError:
384
+ return value
385
+ return value
386
+
387
+
388
+ def events_retention_days() -> int:
389
+ """How many days events are kept (``EVENTS_RETENTION_DAYS``, 90 by default)."""
390
+ return retention.retention_days("EVENTS_RETENTION_DAYS", DEFAULT_EVENTS_RETENTION_DAYS)
391
+
392
+
393
+ def purge_old_events(days: Optional[int] = None, now: Optional[datetime] = None) -> int:
394
+ """Delete events older than the retention period, in chunks; how many went."""
395
+ return retention.purge_older_than(
396
+ "app_events", "created_at",
397
+ days if days is not None else events_retention_days(),
398
+ EVENTS_DELETE_CHUNK_ROWS, EVENTS_MAX_CHUNKS_PER_PASS, now=now)
399
+
400
+
401
+ async def events_retention_background():
402
+ """Sweep the event log once per interval, under a distributed lock.
403
+
404
+ Locked because the work is the deployment's, not the replica's. A failed pass
405
+ costs nothing: writing events runs on its own path.
406
+ """
407
+ from keepup.locks import distributed_lock
408
+
409
+ while True:
410
+ try:
411
+ async with distributed_lock(EVENTS_RETENTION_LOCK, timeout=5,
412
+ max_lock_time=EVENTS_SWEEP_INTERVAL_SECONDS):
413
+ removed = await asyncio.to_thread(purge_old_events)
414
+ if removed:
415
+ logger.info("Events expired: %s", removed)
416
+ await asyncio.sleep(EVENTS_SWEEP_INTERVAL_SECONDS)
417
+ except asyncio.CancelledError:
418
+ raise
419
+ except Exception as error:
420
+ logger.error("Error in the event log retention sweep: %s", error)
421
+ await asyncio.sleep(EVENTS_ERROR_BACKOFF_SECONDS)
422
+
423
+ async def emit_event(
424
+ event_type: str,
425
+ event_text: str,
426
+ event_data: Optional[Dict[str, Any]] = None,
427
+ instance_id: Optional[str] = None,
428
+ instance_name: Optional[str] = None
429
+ ) -> int:
430
+ """Emit an event from anywhere in the application.
431
+
432
+ Args:
433
+ event_type: event type
434
+ event_text: event text
435
+ event_data: optional extra payload
436
+ instance_id: instance id
437
+ instance_name: instance name
438
+
439
+ Returns:
440
+ int: id of the created event.
441
+ """
442
+ return await event_manager.create_event(
443
+ event_type=event_type,
444
+ event_text=event_text,
445
+ event_data=event_data,
446
+ instance_id=instance_id,
447
+ instance_name=instance_name
448
+ )
449
+
450
+
451
+ async def get_events_paginated(
452
+ event_type: Optional[str] = None,
453
+ instance_id: Optional[str] = None,
454
+ instance_name: Optional[str] = None,
455
+ start_date: Optional[datetime] = None,
456
+ end_date: Optional[datetime] = None,
457
+ page: int = 1,
458
+ page_size: int = 50
459
+ ) -> Dict[str, Any]:
460
+ """Return events with pagination."""
461
+ return await event_manager.get_events(
462
+ event_type=event_type,
463
+ instance_id=instance_id,
464
+ instance_name=instance_name,
465
+ start_date=start_date,
466
+ end_date=end_date,
467
+ page=page,
468
+ page_size=page_size
469
+ )
470
+
471
+
472
+ def init_event_manager():
473
+ """Initialise the event manager at application start."""
474
+ try:
475
+ event_manager.init_table()
476
+ logger.info(f"Event manager initialized successfully (instance: {event_manager.instance_name})")
477
+ return event_manager
478
+ except Exception as e:
479
+ logger.error(f"Failed to initialize event manager: {str(e)}")
480
+ return None
481
+
482
+
483
+ #: Names that moved to keepup/events_api.py (keepup-23). Still answered here for
484
+ #: code written against 0.1, with a warning naming the new place; looked up on
485
+ #: access, so the journal does not import its API.
486
+ _MOVED_TO_API = {"register_event_api_routes", "AppEvent", "EventCreate", "EventListResponse"}
487
+
488
+
489
+ def __getattr__(name):
490
+ if name in _MOVED_TO_API:
491
+ import warnings
492
+ from keepup import events_api
493
+ warnings.warn(f"keepup.events.{name} moved to keepup.events_api.{name}",
494
+ DeprecationWarning, stacklevel=2)
495
+ return getattr(events_api, name)
496
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,57 @@
1
+ """The tables of the audit capability, declared where it lives.
2
+
3
+ Task keepup-124. `keepup.audit` and `keepup.events` re-export them for one
4
+ release, so the path that predates the catalogue still creates them and every
5
+ reader keeps working; the declarations belong to the capability that keeps the
6
+ rows -- the calls that came in and the events the application decided about.
7
+ """
8
+
9
+ from sqlalchemy import (CheckConstraint, Column, DateTime, Index, Integer,
10
+ String, Text, UniqueConstraint)
11
+ from sqlalchemy.dialects import postgresql
12
+
13
+ from keepup_db import tables
14
+
15
+ __all__ = ["APP_EVENTS", "INCOMING_REQUESTS"]
16
+
17
+ INCOMING_REQUESTS = tables.table(
18
+ "incoming_requests",
19
+ tables.auto_id(),
20
+ Column("instance_id", Text, nullable=False),
21
+ Column("method", Text, nullable=False),
22
+ Column("endpoint", Text, nullable=False),
23
+ Column("host", Text, nullable=False),
24
+ Column("request_data", postgresql.JSONB().with_variant(Text(), "sqlite")),
25
+ Column("request_start_at", DateTime, nullable=False),
26
+ Column("request_end_at", DateTime),
27
+ Column("duration_ms", Integer),
28
+ Column("http_status", Integer),
29
+ Column("response_data", postgresql.JSONB().with_variant(Text(), "sqlite")),
30
+ Column("error_message", Text),
31
+ Column("created_at", DateTime, server_default=tables.NOW),
32
+ UniqueConstraint("instance_id", "method", "endpoint", "request_start_at",
33
+ name="incoming_requests_instance_method_endpoint_unique").ddl_if(dialect="postgresql"),
34
+ CheckConstraint("duration_ms >= 0",
35
+ name="incoming_requests_check_duration").ddl_if(dialect="postgresql"),
36
+ CheckConstraint("(http_status IS NULL) OR (http_status >= 100 AND http_status <= 599)",
37
+ name="incoming_requests_check_http_status").ddl_if(dialect="postgresql"),
38
+ Index("incoming_requests_instance_method_endpoint_unique",
39
+ "instance_id", "method", "endpoint", "request_start_at", unique=True).ddl_if(dialect="sqlite"),
40
+ Index("idx_incoming_requests_instance_id", "instance_id"),
41
+ Index("idx_incoming_requests_endpoint", "endpoint"),
42
+ Index("idx_incoming_requests_created_at", "created_at"),
43
+ )
44
+
45
+ APP_EVENTS = tables.table(
46
+ "app_events",
47
+ tables.big_auto_id(),
48
+ Column("event_type", String(100), nullable=False),
49
+ Column("event_text", Text, nullable=False),
50
+ Column("event_data", postgresql.JSONB().with_variant(Text(), "sqlite")),
51
+ Column("instance_id", String(100), nullable=False),
52
+ Column("instance_name", String(100)),
53
+ Column("created_at", DateTime, server_default=tables.NOW),
54
+ Index("idx_app_events_event_type", "event_type"),
55
+ Index("idx_app_events_instance_id", "instance_id"),
56
+ Index("idx_app_events_instance_name", "instance_name"),
57
+ )
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: keepup-audit
3
+ Version: 0.4.0
4
+ Summary: Incoming calls, the application's events, the administrator's trail and their retention.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: keepup-db>=0.4.0
8
+
9
+ # keepup-audit
10
+
11
+ Incoming calls, the application's events, the administrator's trail and their retention.
12
+
13
+ One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
14
+ panels: the base is installed as `keepup-admin` and imported as `keepup`, and
15
+ this distribution carries one part of it. It is installed on its own
16
+ (`pip install keepup-audit`) or together with the base (`pip install "keepup-admin[panel]"`),
17
+ and it declares itself to the kernel through its
18
+ `[project.entry-points."keepup.plugins"]` entry point.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ keepup_audit/__init__.py
4
+ keepup_audit/events.py
5
+ keepup_audit/tables.py
6
+ keepup_audit.egg-info/PKG-INFO
7
+ keepup_audit.egg-info/SOURCES.txt
8
+ keepup_audit.egg-info/dependency_links.txt
9
+ keepup_audit.egg-info/entry_points.txt
10
+ keepup_audit.egg-info/requires.txt
11
+ keepup_audit.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [keepup.plugins]
2
+ audit = keepup_audit:AuditPlugin
@@ -0,0 +1 @@
1
+ keepup-db>=0.4.0
@@ -0,0 +1 @@
1
+ keepup_audit
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "keepup-audit"
7
+ version = "0.4.0"
8
+ description = "Incoming calls, the application's events, the administrator's trail and their retention."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "keepup-db>=0.4.0",
13
+ ]
14
+
15
+ [project.entry-points."keepup.plugins"]
16
+ audit = "keepup_audit:AuditPlugin"
17
+
18
+ [tool.setuptools]
19
+ packages = ["keepup_audit"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+