flask-postgresql-audit 1.1.0__tar.gz → 1.1.1__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.
Files changed (47) hide show
  1. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/.gitignore +1 -0
  2. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/CHANGES.md +57 -0
  3. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/PKG-INFO +129 -23
  4. flask_postgresql_audit-1.1.1/README.md +276 -0
  5. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/__init__.py +1 -1
  6. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/alembic/schema.py +3 -3
  7. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/alembic/setup.py +29 -4
  8. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/core.py +13 -3
  9. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/typing.py +1 -1
  10. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/pyproject.toml +1 -1
  11. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/tests/test_defaults.py +1 -1
  12. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/tests/utils.py +1 -1
  13. flask_postgresql_audit-1.1.0/README.md +0 -170
  14. flask_postgresql_audit-1.1.0/flask_postgresql_audit/descriptors.py +0 -38
  15. flask_postgresql_audit-1.1.0/flask_postgresql_audit/templates/create_schema.sql +0 -2
  16. flask_postgresql_audit-1.1.0/flask_postgresql_audit/templates/drop_schema.sql +0 -1
  17. flask_postgresql_audit-1.1.0/flask_postgresql_audit/templates/row_audit_trigger.sql +0 -4
  18. flask_postgresql_audit-1.1.0/flask_postgresql_audit/templates/row_create_activity.sql +0 -48
  19. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/LICENSE +0 -0
  20. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/alembic/__init__.py +0 -0
  21. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/alembic/entities.py +0 -0
  22. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/base.py +0 -0
  23. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/extensions/__init__.py +0 -0
  24. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/extensions/alembic_utils/__init__.py +0 -0
  25. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/extensions/alembic_utils/pg_aggregate.py +0 -0
  26. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/extensions/document_staging/__init__.py +0 -0
  27. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/extensions/document_staging/enum.py +0 -0
  28. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/extensions/document_staging/listener.py +0 -0
  29. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/extensions/document_staging/model.py +0 -0
  30. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/models.py +0 -0
  31. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/templates/audit_trigger_delete.sql +0 -0
  32. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/templates/audit_trigger_insert.sql +0 -0
  33. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/templates/audit_trigger_update.sql +0 -0
  34. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/templates/create_activity.sql +0 -0
  35. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/templates/get_pk_values.sql +0 -0
  36. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/templates/get_setting.sql +0 -0
  37. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/templates/get_table_relid.sql +0 -0
  38. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/templates/jsonb_subtract.sql +0 -0
  39. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/flask_postgresql_audit/utils.py +0 -0
  40. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/tests/__init__.py +0 -0
  41. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/tests/app.py +0 -0
  42. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/tests/conftest.py +0 -0
  43. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/tests/migrations/README +0 -0
  44. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/tests/migrations/alembic.ini +0 -0
  45. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/tests/migrations/env.py +0 -0
  46. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/tests/migrations/script.py.mako +0 -0
  47. {flask_postgresql_audit-1.1.0 → flask_postgresql_audit-1.1.1}/tests/test_operators.py +0 -0
@@ -22,6 +22,7 @@ var/
22
22
  *.egg-info/
23
23
  .installed.cfg
24
24
  *.egg
25
+ .env*
25
26
 
26
27
  # PyInstaller
27
28
  # Usually these files are written by a python script from a template
@@ -3,6 +3,63 @@
3
3
  Here you can see the full list of changes between each Flask-PostgreSQL-Audit
4
4
  release.
5
5
 
6
+ ## 1.1.1 (2026-08-08)
7
+
8
+ - Fix test teardown downgrade error in migration listener.
9
+ - Fix `pg_audit_enabled` state thread-safety using `ContextVar`.
10
+ - Quote schema names and parameterize schema existence queries in Alembic operations.
11
+ - Remove unused templates and unused `hybridmethod` descriptor.
12
+ - Document `DocumentStaging` and `PGAggregate` extensions in `README.md`.
13
+
14
+ ## 1.1.0 (2026-08-07)
15
+
16
+ - Bump `alembic` dependency to `>=1.18.0`.
17
+ - Refactor Alembic integration to use the official Plugin API and DispatchPriority system.
18
+ - Add multi-schema test matrix and schema-aware support to the test suite.
19
+ - Modernize Python type annotations, imports, string formatting, and clean up code using Ruff.
20
+ - Simplify context managers and type-hint fixtures in tests.
21
+
22
+ ## 1.0.9 (2026-08-07)
23
+
24
+ - Change `fetch_activity` join clause to `outerjoin`.
25
+ - Resolve type checking errors.
26
+
27
+ ## 1.0.8 (2026-03-30)
28
+
29
+ - Update `Audit` mixin class and refactor `fetch_activity` method, removing rowkey filter on class scope.
30
+ - Add PostgreSQL function `get_table_relid`.
31
+ - Fix `get_pk_values` returns wrong jsonb type if primary key columns are null.
32
+
33
+ ## 1.0.7 (2025-12-15)
34
+
35
+ - Fix cartesian join when providing a table model to `fetch_activity()`.
36
+
37
+ ## 1.0.6 (2025-12-15)
38
+
39
+ - Add `fetch_activity()` statement constructor with optional `verb` filtering, ordered by ID descending.
40
+ - Add `hybridmethod` descriptor.
41
+ - Add extension `document_staging`.
42
+
43
+ ## 1.0.5 (2025-11-27)
44
+
45
+ - Add `alembic_utils` extension `pg_aggregate`.
46
+
47
+ ## 1.0.4 (2025-11-17)
48
+
49
+ - Fix: use explicit schema prefix to call `get_pk_values()` in `create_activity`.
50
+
51
+ ## 1.0.3 (2025-11-14)
52
+
53
+ - Fix: use schema prefix when calling `jsonb_subtract()` in audit trigger functions.
54
+
55
+ ## 1.0.2 (2025-11-14)
56
+
57
+ - Support optional verbose value comparison in `jsonb_subtract()`.
58
+
59
+ ## 1.0.1 (2025-10-30)
60
+
61
+ - Update authors in `pyproject.toml` and update `README.md`.
62
+
6
63
  ## 1.0.0 (2025-10-30)
7
64
 
8
65
  - Forked from PostgreSQL-Audit.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
- Name: flask-postgresql_audit
3
- Version: 1.1.0
2
+ Name: flask-postgresql-audit
3
+ Version: 1.1.1
4
4
  Summary: Versioning and auditing extension for PostgreSQL and SQLAlchemy.
5
5
  Project-URL: Homepage, https://github.com/higamigu/flask-postgresql-audit
6
6
  Author-email: Higamigu <higamigu@gmail.com>, Konsta Vesterinen <konsta@fastmonkeys.com>
@@ -52,7 +52,7 @@ uv add flask-postgresql-audit
52
52
  ```
53
53
  or install directly from this repo
54
54
  ```
55
- uv add git+https://github.com/higamigu/flask-postgresql-audit --tag v1.0.0
55
+ uv add git+https://github.com/higamigu/flask-postgresql-audit --tag v1.1.0
56
56
  ```
57
57
 
58
58
  ## Usage
@@ -66,16 +66,18 @@ from my_app import app # your flask app
66
66
  db = SQLAlchemy()
67
67
  audit = PostgreSQLAudit()
68
68
 
69
+
69
70
  class Article(db.Model, Audit):
70
- __tablename__ = 'article'
71
+ __tablename__ = "article"
71
72
 
72
73
  id = db.Column(db.Integer, primary_key=True)
73
74
  name = db.Column(db.String)
74
75
 
76
+
75
77
  db.init_app(app)
76
78
  audit.init_app(app, db)
77
79
 
78
- article = Article(name='Some article')
80
+ article = Article(name="Some article")
79
81
  db.session.add(article)
80
82
  db.session.commit()
81
83
  ```
@@ -89,24 +91,24 @@ Now we can check the newly created activity.
89
91
 
90
92
  ``` python
91
93
  activity = db.session.scalar(select(audit.Activity))
92
- activity.id # 1
93
- activity.table_name # 'article'
94
- activity.verb # 'insert'
95
- activity.old_data # None
96
- activity.changed_data # {'id': '1', 'name': 'Some article'}
94
+ activity.id # 1
95
+ activity.table_name # 'article'
96
+ activity.verb # 'insert'
97
+ activity.old_data # None
98
+ activity.changed_data # {'id': '1', 'name': 'Some article'}
97
99
  ```
98
100
 
99
101
  ``` python
100
- article.name = 'Some other article'
102
+ article.name = "Some other article"
101
103
  db.session.commit()
102
104
 
103
105
  activity = db.session.scalar(select(audit.Activity).order_by(desc("id")))
104
- activity.id # 2
105
- activity.table_name # 'article'
106
- activity.verb # 'update'
107
- activity.object_id # 1
108
- activity.old_data # {'id': '1', 'name': 'Some article'}
109
- activity.changed_data # {'name': 'Some other article'}
106
+ activity.id # 2
107
+ activity.table_name # 'article'
108
+ activity.verb # 'update'
109
+ activity.object_id # 1
110
+ activity.old_data # {'id': '1', 'name': 'Some article'}
111
+ activity.changed_data # {'name': 'Some other article'}
110
112
  ```
111
113
 
112
114
  ``` python
@@ -114,14 +116,34 @@ db.session.delete(article)
114
116
  db.session.commit()
115
117
 
116
118
  activity = db.session.scalar(select(audit.Activity).order_by(desc("id")))
117
- activity.id # 3
118
- activity.table_name # 'article'
119
- activity.verb # 'delete'
120
- activity.object_id # 1
121
- activity.old_data # {'id': '1', 'name': 'Some other article'}
122
- activity.changed_data # None
119
+ activity.id # 3
120
+ activity.table_name # 'article'
121
+ activity.verb # 'delete'
122
+ activity.object_id # 1
123
+ activity.old_data # {'id': '1', 'name': 'Some other article'}
124
+ activity.changed_data # None
125
+ ```
126
+
127
+ ## Querying Activity History
128
+
129
+ Instead of querying `audit.Activity` manually, you can use `fetch_activity()` to retrieve the audit trail for a model class, a single model instance, or a sequence/list of model instances:
130
+
131
+ ``` python
132
+ # Query activity for a specific model class (e.g. Article)
133
+ stmt = audit.fetch_activity(Article)
134
+ activities = db.session.execute(stmt).all()
135
+
136
+ # Query activity for a specific article instance
137
+ stmt = audit.fetch_activity(article)
138
+ activities = db.session.execute(stmt).all()
139
+
140
+ # Query activity for multiple instances
141
+ stmt = audit.fetch_activity([article1, article2])
142
+ activities = db.session.execute(stmt).all()
123
143
  ```
124
144
 
145
+ The returned statement is a SQLAlchemy `select` object that joins `Activity` and `Transaction` tables, ordered by activity ID descending. You can further customize or execute this statement.
146
+
125
147
  ## Different Schema
126
148
  You can isolate `pg_audit` objects entirely to a different schema by doing
127
149
 
@@ -159,6 +181,7 @@ You can customize actor id getter function by doing the following. Here is an ex
159
181
  ``` python
160
182
  from flask_jwt_extended import jwt_required, current_user
161
183
 
184
+
162
185
  @jwt_required(optional=True)
163
186
  def actor_id_getter():
164
187
  try:
@@ -166,6 +189,7 @@ def actor_id_getter():
166
189
  except Exception:
167
190
  return None
168
191
 
192
+
169
193
  ...
170
194
 
171
195
  audit = PostgreSQLAudit(actor_id_getter=actor_id_getter)
@@ -188,6 +212,88 @@ handlers =
188
212
  qualname = pg_audit
189
213
  ```
190
214
 
215
+ ## Troubleshooting
216
+
217
+ ### Activity not being recorded?
218
+
219
+ 1. **Check triggers are installed**: Run `flask db upgrade` after installing the extension
220
+ 2. **Verify audit is initialized**: Ensure you called `audit.init_app(app, db)` before using models
221
+ 3. **Commit after changes**: Audit records are only created on `db.session.commit()`
222
+
223
+ ### Actor tracking not working?
224
+
225
+ The default actor ID getter tries to import `flask_login.current_user`. If you're not using Flask-Login:
226
+
227
+ 1. Provide your own `actor_id_getter` function:
228
+ ```python
229
+ def my_actor_id():
230
+ return current_user.id if current_user.is_authenticated else None
231
+ ```
232
+
233
+ 2. Pass it to the extension: `audit = PostgreSQLAudit(actor_id_getter=my_actor_id)`
234
+
235
+ ### Using a custom schema?
236
+
237
+ 1. Set schema when creating audit: `audit = PostgreSQLAudit(schema_name="my_schema")`
238
+ 2. Configure Alembic to track multiple schemas (see "Different Schema" section)
239
+ 3. Add `"include_schemas=True"` to `alembic/env.py` in `run_migrations_online()`
240
+
241
+ ### Fetching activity for custom models?
242
+
243
+ The `fetch_activity()` method works with:
244
+ - A model class: `audit.fetch_activity(MyModel)`
245
+ - A single instance: `audit.fetch_activity(my_instance)`
246
+ - Multiple instances: `audit.fetch_activity([instance1, instance2])`
247
+
248
+ ## Extensions
249
+
250
+ ### Document Staging
251
+
252
+ The `document_staging` extension provides document lifecycle management and version revision tracking for models.
253
+
254
+ ```python
255
+ from flask_postgresql_audit.extensions.document_staging import (
256
+ DocumentStaging,
257
+ Docstatus,
258
+ attach_listener,
259
+ )
260
+
261
+
262
+ class Article(BaseModel, DocumentStaging):
263
+ __tablename__ = "article"
264
+ id: Mapped[int] = mapped_column(primary_key=True)
265
+ title: Mapped[str]
266
+
267
+
268
+ # Attach session listener for automatic actor and timestamp tracking
269
+ attach_listener()
270
+ ```
271
+
272
+ #### Lifecycle & Transitions
273
+
274
+ - **`Docstatus` states**: `DRAFT` (0), `SUBMITTED` (1), `CANCELLED` (2), `REVISED` (3).
275
+ - **`doc.bump()`**: Advances document status (`DRAFT` -> `SUBMITTED` -> `CANCELLED`).
276
+ - **`doc.revise(new_doc)`**: Creates a new revision `new_doc` from a `CANCELLED` document and sets state to `REVISED`.
277
+ - **`doc.delete(session)`**: Deletes a `DRAFT` document. If it was a revision, reverts parent document status back to `CANCELLED`.
278
+ - **Automatic Fields**: `created_by`, `created_on`, `submitted_by`, `submitted_on`, `cancelled_by`, `cancelled_on` are automatically set during session flush events.
279
+
280
+ ### Custom PostgreSQL Aggregates (`alembic_utils`)
281
+
282
+ `PGAggregate` extends `alembic_utils` to manage custom PostgreSQL aggregate functions in Alembic migrations.
283
+
284
+ ```python
285
+ from flask_postgresql_audit.extensions.alembic_utils import PGAggregate
286
+
287
+ # Example: Define custom aggregate function entity
288
+ sum_agg = PGAggregate(
289
+ schema="public",
290
+ signature="custom_sum(numeric)",
291
+ definition="(SFUNC = numeric_add, STYPE = numeric, INITCOND = '0')",
292
+ )
293
+ ```
294
+
295
+ Automatically detects and tracks custom `CREATE AGGREGATE` definitions during `alembic revision --autogenerate`.
296
+
191
297
  ## Running the tests
192
298
 
193
299
  git clone https://github.com/higamigu/flask-postgresql-audit.git
@@ -0,0 +1,276 @@
1
+ # Flask-PostgreSQL-Audit
2
+
3
+ ![BuildStatus](https://github.com/higamigu/flask-postgresql-audit/actions/workflows/test.yml/badge.svg?branch=main)
4
+ [![VersionStatus](https://img.shields.io/pypi/v/flask-postgresql-audit.svg)](https://pypi.org/project/flask-postgresql-audit/)
5
+
6
+ Auditing extension for Flask-SQLAlchemy with PostgreSQL.
7
+ Forked from [PostgreSQL-Audit](https://github.com/kvesteri/postgresql-audit), tries to combine the best of breed from existing solutions such as
8
+ [SQLAlchemy-Continuum](https://github.com/kvesteri/SQLAlchemy-Continuum),
9
+ [Papertrail](https://github.com/airblade/paper_trail) and especially
10
+ [Audit Trigger by 2ndQuadrant](https://github.com/2ndQuadrant/audit-trigger).
11
+
12
+ - Stores audit recordss into single table called `pga_activity`
13
+ - Uses trigger based approach to keep INSERTs, UPDATEs
14
+ and DELETEs as fast as possible
15
+ - Tracks and stores actor identities into table called `pga_transaction`
16
+ - Uses Alembic and Alembic-Utils to generate necessary database objects for migration
17
+
18
+ ## Installation
19
+ ```
20
+ pip install flask-postgresql-audit
21
+ ```
22
+ or using `uv`
23
+ ```
24
+ uv add flask-postgresql-audit
25
+ ```
26
+ or install directly from this repo
27
+ ```
28
+ uv add git+https://github.com/higamigu/flask-postgresql-audit --tag v1.1.0
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ``` python
34
+ from flask_sqlalchemy import SQLAlchemy
35
+ from flask_postgresql_audit import PostgreSQLAudit, Audit
36
+
37
+ from my_app import app # your flask app
38
+
39
+ db = SQLAlchemy()
40
+ audit = PostgreSQLAudit()
41
+
42
+
43
+ class Article(db.Model, Audit):
44
+ __tablename__ = "article"
45
+
46
+ id = db.Column(db.Integer, primary_key=True)
47
+ name = db.Column(db.String)
48
+
49
+
50
+ db.init_app(app)
51
+ audit.init_app(app, db)
52
+
53
+ article = Article(name="Some article")
54
+ db.session.add(article)
55
+ db.session.commit()
56
+ ```
57
+ Then generate migration file
58
+ ```
59
+ flask db migrate -m "pg audit initial migration"
60
+ flask db upgrade
61
+ ```
62
+
63
+ Now we can check the newly created activity.
64
+
65
+ ``` python
66
+ activity = db.session.scalar(select(audit.Activity))
67
+ activity.id # 1
68
+ activity.table_name # 'article'
69
+ activity.verb # 'insert'
70
+ activity.old_data # None
71
+ activity.changed_data # {'id': '1', 'name': 'Some article'}
72
+ ```
73
+
74
+ ``` python
75
+ article.name = "Some other article"
76
+ db.session.commit()
77
+
78
+ activity = db.session.scalar(select(audit.Activity).order_by(desc("id")))
79
+ activity.id # 2
80
+ activity.table_name # 'article'
81
+ activity.verb # 'update'
82
+ activity.object_id # 1
83
+ activity.old_data # {'id': '1', 'name': 'Some article'}
84
+ activity.changed_data # {'name': 'Some other article'}
85
+ ```
86
+
87
+ ``` python
88
+ db.session.delete(article)
89
+ db.session.commit()
90
+
91
+ activity = db.session.scalar(select(audit.Activity).order_by(desc("id")))
92
+ activity.id # 3
93
+ activity.table_name # 'article'
94
+ activity.verb # 'delete'
95
+ activity.object_id # 1
96
+ activity.old_data # {'id': '1', 'name': 'Some other article'}
97
+ activity.changed_data # None
98
+ ```
99
+
100
+ ## Querying Activity History
101
+
102
+ Instead of querying `audit.Activity` manually, you can use `fetch_activity()` to retrieve the audit trail for a model class, a single model instance, or a sequence/list of model instances:
103
+
104
+ ``` python
105
+ # Query activity for a specific model class (e.g. Article)
106
+ stmt = audit.fetch_activity(Article)
107
+ activities = db.session.execute(stmt).all()
108
+
109
+ # Query activity for a specific article instance
110
+ stmt = audit.fetch_activity(article)
111
+ activities = db.session.execute(stmt).all()
112
+
113
+ # Query activity for multiple instances
114
+ stmt = audit.fetch_activity([article1, article2])
115
+ activities = db.session.execute(stmt).all()
116
+ ```
117
+
118
+ The returned statement is a SQLAlchemy `select` object that joins `Activity` and `Transaction` tables, ordered by activity ID descending. You can further customize or execute this statement.
119
+
120
+ ## Different Schema
121
+ You can isolate `pg_audit` objects entirely to a different schema by doing
122
+
123
+ ``` python
124
+ from flask_postgresql_audit import PostgreSQLAudit
125
+
126
+ audit = PostgreSQLAudit(schema="audit")
127
+
128
+ ...
129
+ ```
130
+ And then you need to tell alembic to track other than `public` schema by adding following line in `alembic/env.py`
131
+ ``` python
132
+ ...
133
+
134
+ def run_migrations_online():
135
+ connectable = get_engine()
136
+
137
+ with connectable.connect() as connection:
138
+ context.configure(
139
+ connection=connection,
140
+ target_metadata=get_metadata(),
141
+ include_schemas=True, # add this arg
142
+ ...
143
+ )
144
+
145
+ with context.begin_transaction():
146
+ context.run_migrations()
147
+
148
+ ...
149
+ ```
150
+
151
+ ## Custom Actor ID getter
152
+ You can customize actor id getter function by doing the following. Here is an example using `current_user` from `flask_jwt_extended` library.
153
+
154
+ ``` python
155
+ from flask_jwt_extended import jwt_required, current_user
156
+
157
+
158
+ @jwt_required(optional=True)
159
+ def actor_id_getter():
160
+ try:
161
+ return current_user.email or None
162
+ except Exception:
163
+ return None
164
+
165
+
166
+ ...
167
+
168
+ audit = PostgreSQLAudit(actor_id_getter=actor_id_getter)
169
+
170
+ ...
171
+ ```
172
+
173
+ ## Enable Alembic Logger
174
+ You can enable alembic logger for `pg_audit` by adding the following to your `alembic.ini`
175
+ ``` ini
176
+ # Logging configuration
177
+ [loggers]
178
+ keys = root,sqlalchemy,alembic,alembic_utils,pg_audit # add 'pg_audit' here
179
+
180
+ ...
181
+
182
+ [logger_pg_audit] # and add the logger for pg_audit here
183
+ level = INFO
184
+ handlers =
185
+ qualname = pg_audit
186
+ ```
187
+
188
+ ## Troubleshooting
189
+
190
+ ### Activity not being recorded?
191
+
192
+ 1. **Check triggers are installed**: Run `flask db upgrade` after installing the extension
193
+ 2. **Verify audit is initialized**: Ensure you called `audit.init_app(app, db)` before using models
194
+ 3. **Commit after changes**: Audit records are only created on `db.session.commit()`
195
+
196
+ ### Actor tracking not working?
197
+
198
+ The default actor ID getter tries to import `flask_login.current_user`. If you're not using Flask-Login:
199
+
200
+ 1. Provide your own `actor_id_getter` function:
201
+ ```python
202
+ def my_actor_id():
203
+ return current_user.id if current_user.is_authenticated else None
204
+ ```
205
+
206
+ 2. Pass it to the extension: `audit = PostgreSQLAudit(actor_id_getter=my_actor_id)`
207
+
208
+ ### Using a custom schema?
209
+
210
+ 1. Set schema when creating audit: `audit = PostgreSQLAudit(schema_name="my_schema")`
211
+ 2. Configure Alembic to track multiple schemas (see "Different Schema" section)
212
+ 3. Add `"include_schemas=True"` to `alembic/env.py` in `run_migrations_online()`
213
+
214
+ ### Fetching activity for custom models?
215
+
216
+ The `fetch_activity()` method works with:
217
+ - A model class: `audit.fetch_activity(MyModel)`
218
+ - A single instance: `audit.fetch_activity(my_instance)`
219
+ - Multiple instances: `audit.fetch_activity([instance1, instance2])`
220
+
221
+ ## Extensions
222
+
223
+ ### Document Staging
224
+
225
+ The `document_staging` extension provides document lifecycle management and version revision tracking for models.
226
+
227
+ ```python
228
+ from flask_postgresql_audit.extensions.document_staging import (
229
+ DocumentStaging,
230
+ Docstatus,
231
+ attach_listener,
232
+ )
233
+
234
+
235
+ class Article(BaseModel, DocumentStaging):
236
+ __tablename__ = "article"
237
+ id: Mapped[int] = mapped_column(primary_key=True)
238
+ title: Mapped[str]
239
+
240
+
241
+ # Attach session listener for automatic actor and timestamp tracking
242
+ attach_listener()
243
+ ```
244
+
245
+ #### Lifecycle & Transitions
246
+
247
+ - **`Docstatus` states**: `DRAFT` (0), `SUBMITTED` (1), `CANCELLED` (2), `REVISED` (3).
248
+ - **`doc.bump()`**: Advances document status (`DRAFT` -> `SUBMITTED` -> `CANCELLED`).
249
+ - **`doc.revise(new_doc)`**: Creates a new revision `new_doc` from a `CANCELLED` document and sets state to `REVISED`.
250
+ - **`doc.delete(session)`**: Deletes a `DRAFT` document. If it was a revision, reverts parent document status back to `CANCELLED`.
251
+ - **Automatic Fields**: `created_by`, `created_on`, `submitted_by`, `submitted_on`, `cancelled_by`, `cancelled_on` are automatically set during session flush events.
252
+
253
+ ### Custom PostgreSQL Aggregates (`alembic_utils`)
254
+
255
+ `PGAggregate` extends `alembic_utils` to manage custom PostgreSQL aggregate functions in Alembic migrations.
256
+
257
+ ```python
258
+ from flask_postgresql_audit.extensions.alembic_utils import PGAggregate
259
+
260
+ # Example: Define custom aggregate function entity
261
+ sum_agg = PGAggregate(
262
+ schema="public",
263
+ signature="custom_sum(numeric)",
264
+ definition="(SFUNC = numeric_add, STYPE = numeric, INITCOND = '0')",
265
+ )
266
+ ```
267
+
268
+ Automatically detects and tracks custom `CREATE AGGREGATE` definitions during `alembic revision --autogenerate`.
269
+
270
+ ## Running the tests
271
+
272
+ git clone https://github.com/higamigu/flask-postgresql-audit.git
273
+ cd flask-postgresql-audit
274
+ pip install tox
275
+ createdb postgresql_audit_test
276
+ tox
@@ -1,4 +1,4 @@
1
1
  # ruff: noqa: F401
2
2
  from .core import Audit, PostgreSQLAudit
3
3
 
4
- __version__ = "1.1.0"
4
+ __version__ = "1.1.1"
@@ -33,15 +33,15 @@ class SchemaDrop(SchemaOperation):
33
33
  @Operations.implementation_for(SchemaCreate)
34
34
  def create_schema(operations: Operations, operation: SchemaCreate):
35
35
  stmt = f"""
36
- CREATE SCHEMA {operation.schema_name};
37
- REVOKE ALL ON SCHEMA {operation.schema_name} FROM public;
36
+ CREATE SCHEMA "{operation.schema_name}";
37
+ REVOKE ALL ON SCHEMA "{operation.schema_name}" FROM public;
38
38
  """
39
39
  operations.execute(text(stmt))
40
40
 
41
41
 
42
42
  @Operations.implementation_for(SchemaDrop)
43
43
  def drop_schema(operations: Operations, operation: SchemaDrop):
44
- stmt = f"DROP SCHEMA {operation.schema_name} CASCADE;"
44
+ stmt = f'DROP SCHEMA "{operation.schema_name}" CASCADE;'
45
45
  operations.execute(text(stmt))
46
46
 
47
47
 
@@ -12,7 +12,7 @@ from sqlalchemy import Connection, text
12
12
  from sqlalchemy.orm import Session
13
13
 
14
14
  from . import entities
15
- from .schema import SchemaCreate
15
+ from .schema import SchemaCreate, SchemaDrop
16
16
 
17
17
  if TYPE_CHECKING:
18
18
  from alembic_utils.replaceable_entity import ReplaceableEntity
@@ -37,9 +37,11 @@ def setup_db(audit: "PostgreSQLAudit"):
37
37
  if connection := autogen_context.connection:
38
38
  check_schema = """
39
39
  SELECT TRUE FROM information_schema.schemata
40
- WHERE schema_name = '{name}'
41
- """.format(name=audit.context["schema_name"])
42
- if not connection.scalar(text(check_schema)):
40
+ WHERE schema_name = :name
41
+ """
42
+ if not connection.scalar(
43
+ text(check_schema), {"name": audit.context["schema_name"]}
44
+ ):
43
45
  upgrade_ops.ops.append(SchemaCreate(audit.context["schema_name"]))
44
46
 
45
47
  for ent in audit.pg_audit_entities:
@@ -83,6 +85,29 @@ def reorder_migration_ops(context, revision, directives: list[MigrationScript]):
83
85
  # Reassemble the ops sequence in strict dependency order
84
86
  directive.upgrade_ops.ops = schema_ops + extension_ops + other_ops + trigger_ops
85
87
 
88
+ if hasattr(directive, "downgrade_ops") and directive.downgrade_ops is not None:
89
+ downgrade_ops = directive.downgrade_ops.ops
90
+ d_schema_ops = []
91
+ d_extension_ops = []
92
+ d_trigger_ops = []
93
+ d_other_ops = []
94
+ for op in downgrade_ops:
95
+ target = getattr(op, "target", None)
96
+ if isinstance(op, SchemaDrop):
97
+ d_schema_ops.append(op)
98
+ elif (
99
+ target is not None
100
+ and getattr(target, "signature", None) == "btree_gist"
101
+ ):
102
+ d_extension_ops.append(op)
103
+ elif target is not None:
104
+ d_trigger_ops.append(op)
105
+ else:
106
+ d_other_ops.append(op)
107
+ directive.downgrade_ops.ops = (
108
+ d_trigger_ops + d_other_ops + d_extension_ops + d_schema_ops
109
+ )
110
+
86
111
 
87
112
  def chain_revision_directives(*callbacks):
88
113
  """
@@ -1,6 +1,7 @@
1
1
  import inspect
2
2
  import typing as t
3
3
  from contextlib import contextmanager
4
+ from contextvars import ContextVar
4
5
  from itertools import groupby
5
6
 
6
7
  import sqlalchemy as sa
@@ -13,9 +14,11 @@ from sqlalchemy.util import OrderedSet
13
14
 
14
15
  from .alembic import register_triggers, setup_db
15
16
  from .models import activity_model_factory, transaction_model_factory
16
- from .typing import AnyAttrribute
17
+ from .typing import AnyAttribute
17
18
  from .utils import load_template
18
19
 
20
+ _pg_audit_enabled: ContextVar[bool] = ContextVar("pg_audit_enabled", default=True)
21
+
19
22
  if t.TYPE_CHECKING:
20
23
  from sqlalchemy.sql.functions import _FunctionGenerator
21
24
 
@@ -76,7 +79,7 @@ def get_modified_columns(obj: orm.DeclarativeBase):
76
79
  if not attr.history.has_changes():
77
80
  continue
78
81
 
79
- cls_attr: AnyAttrribute = getattr(obj.__class__, key)
82
+ cls_attr: AnyAttribute = getattr(obj.__class__, key)
80
83
  prop = cls_attr.property
81
84
  if isinstance(prop, orm.ColumnProperty):
82
85
  modified.update(prop.columns)
@@ -139,11 +142,18 @@ def _default_client_addr() -> str | None:
139
142
 
140
143
  class PostgreSQLAudit:
141
144
  pg_audit_classes: set[type[Audit]]
142
- pg_audit_enabled: bool
143
145
  pg_audit_entities: OrderedSet[ReplaceableEntity]
144
146
 
145
147
  options: dict[str, t.Any]
146
148
 
149
+ @property
150
+ def pg_audit_enabled(self) -> bool:
151
+ return _pg_audit_enabled.get()
152
+
153
+ @pg_audit_enabled.setter
154
+ def pg_audit_enabled(self, value: bool) -> None:
155
+ _pg_audit_enabled.set(value)
156
+
147
157
  def __init__(
148
158
  self,
149
159
  *,
@@ -6,4 +6,4 @@ T = TypeVar("T")
6
6
  OMap = Mapped[T | None] # Optional Mapped / Nullable
7
7
 
8
8
 
9
- AnyAttrribute = InstrumentedAttribute[Any]
9
+ AnyAttribute = InstrumentedAttribute[Any]
@@ -1,7 +1,7 @@
1
1
 
2
2
 
3
3
  [project]
4
- name = "flask-postgresql_audit"
4
+ name = "flask-postgresql-audit"
5
5
  dynamic = ["version"]
6
6
  description = "Versioning and auditing extension for PostgreSQL and SQLAlchemy."
7
7
  readme = "README.md"
@@ -5,7 +5,7 @@ from .app import User, audit, db
5
5
 
6
6
 
7
7
  @pytest.mark.usefixtures("test_client")
8
- class TestCustomSchemaactivityCreation:
8
+ class TestCustomSchemaActivityCreation:
9
9
  def test_insert(self, user):
10
10
  stmt = sa.select(audit.Activity).limit(1)
11
11
  if activity := db.session.scalar(stmt):
@@ -76,7 +76,7 @@ def downgrade(engine: Engine, alembic_cfg_path: Path, **kw):
76
76
  alembic_config("downgrade", engine, alembic_cfg_path, **kw) as cfg,
77
77
  contextlib.redirect_stdout(stdout),
78
78
  ):
79
- command.upgrade(cfg, **kw)
79
+ command.downgrade(cfg, **kw)
80
80
  return stdout.getvalue()
81
81
 
82
82
 
@@ -1,170 +0,0 @@
1
- # Flask-PostgreSQL-Audit
2
-
3
- ![BuildStatus](https://github.com/higamigu/flask-postgresql-audit/actions/workflows/test.yml/badge.svg?branch=main)
4
- [![VersionStatus](https://img.shields.io/pypi/v/flask-postgresql-audit.svg)](https://pypi.org/project/flask-postgresql-audit/)
5
-
6
- Auditing extension for Flask-SQLAlchemy with PostgreSQL.
7
- Forked from [PostgreSQL-Audit](https://github.com/kvesteri/postgresql-audit), tries to combine the best of breed from existing solutions such as
8
- [SQLAlchemy-Continuum](https://github.com/kvesteri/SQLAlchemy-Continuum),
9
- [Papertrail](https://github.com/airblade/paper_trail) and especially
10
- [Audit Trigger by 2ndQuadrant](https://github.com/2ndQuadrant/audit-trigger).
11
-
12
- - Stores audit recordss into single table called `pga_activity`
13
- - Uses trigger based approach to keep INSERTs, UPDATEs
14
- and DELETEs as fast as possible
15
- - Tracks and stores actor identities into table called `pga_transaction`
16
- - Uses Alembic and Alembic-Utils to generate necessary database objects for migration
17
-
18
- ## Installation
19
- ```
20
- pip install flask-postgresql-audit
21
- ```
22
- or using `uv`
23
- ```
24
- uv add flask-postgresql-audit
25
- ```
26
- or install directly from this repo
27
- ```
28
- uv add git+https://github.com/higamigu/flask-postgresql-audit --tag v1.0.0
29
- ```
30
-
31
- ## Usage
32
-
33
- ``` python
34
- from flask_sqlalchemy import SQLAlchemy
35
- from flask_postgresql_audit import PostgreSQLAudit, Audit
36
-
37
- from my_app import app # your flask app
38
-
39
- db = SQLAlchemy()
40
- audit = PostgreSQLAudit()
41
-
42
- class Article(db.Model, Audit):
43
- __tablename__ = 'article'
44
-
45
- id = db.Column(db.Integer, primary_key=True)
46
- name = db.Column(db.String)
47
-
48
- db.init_app(app)
49
- audit.init_app(app, db)
50
-
51
- article = Article(name='Some article')
52
- db.session.add(article)
53
- db.session.commit()
54
- ```
55
- Then generate migration file
56
- ```
57
- flask db migrate -m "pg audit initial migration"
58
- flask db upgrade
59
- ```
60
-
61
- Now we can check the newly created activity.
62
-
63
- ``` python
64
- activity = db.session.scalar(select(audit.Activity))
65
- activity.id # 1
66
- activity.table_name # 'article'
67
- activity.verb # 'insert'
68
- activity.old_data # None
69
- activity.changed_data # {'id': '1', 'name': 'Some article'}
70
- ```
71
-
72
- ``` python
73
- article.name = 'Some other article'
74
- db.session.commit()
75
-
76
- activity = db.session.scalar(select(audit.Activity).order_by(desc("id")))
77
- activity.id # 2
78
- activity.table_name # 'article'
79
- activity.verb # 'update'
80
- activity.object_id # 1
81
- activity.old_data # {'id': '1', 'name': 'Some article'}
82
- activity.changed_data # {'name': 'Some other article'}
83
- ```
84
-
85
- ``` python
86
- db.session.delete(article)
87
- db.session.commit()
88
-
89
- activity = db.session.scalar(select(audit.Activity).order_by(desc("id")))
90
- activity.id # 3
91
- activity.table_name # 'article'
92
- activity.verb # 'delete'
93
- activity.object_id # 1
94
- activity.old_data # {'id': '1', 'name': 'Some other article'}
95
- activity.changed_data # None
96
- ```
97
-
98
- ## Different Schema
99
- You can isolate `pg_audit` objects entirely to a different schema by doing
100
-
101
- ``` python
102
- from flask_postgresql_audit import PostgreSQLAudit
103
-
104
- audit = PostgreSQLAudit(schema="audit")
105
-
106
- ...
107
- ```
108
- And then you need to tell alembic to track other than `public` schema by adding following line in `alembic/env.py`
109
- ``` python
110
- ...
111
-
112
- def run_migrations_online():
113
- connectable = get_engine()
114
-
115
- with connectable.connect() as connection:
116
- context.configure(
117
- connection=connection,
118
- target_metadata=get_metadata(),
119
- include_schemas=True, # add this arg
120
- ...
121
- )
122
-
123
- with context.begin_transaction():
124
- context.run_migrations()
125
-
126
- ...
127
- ```
128
-
129
- ## Custom Actor ID getter
130
- You can customize actor id getter function by doing the following. Here is an example using `current_user` from `flask_jwt_extended` library.
131
-
132
- ``` python
133
- from flask_jwt_extended import jwt_required, current_user
134
-
135
- @jwt_required(optional=True)
136
- def actor_id_getter():
137
- try:
138
- return current_user.email or None
139
- except Exception:
140
- return None
141
-
142
- ...
143
-
144
- audit = PostgreSQLAudit(actor_id_getter=actor_id_getter)
145
-
146
- ...
147
- ```
148
-
149
- ## Enable Alembic Logger
150
- You can enable alembic logger for `pg_audit` by adding the following to your `alembic.ini`
151
- ``` ini
152
- # Logging configuration
153
- [loggers]
154
- keys = root,sqlalchemy,alembic,alembic_utils,pg_audit # add 'pg_audit' here
155
-
156
- ...
157
-
158
- [logger_pg_audit] # and add the logger for pg_audit here
159
- level = INFO
160
- handlers =
161
- qualname = pg_audit
162
- ```
163
-
164
- ## Running the tests
165
-
166
- git clone https://github.com/higamigu/flask-postgresql-audit.git
167
- cd flask-postgresql-audit
168
- pip install tox
169
- createdb postgresql_audit_test
170
- tox
@@ -1,38 +0,0 @@
1
- import typing as t
2
-
3
- _P = t.ParamSpec("_P")
4
- _R = t.TypeVar("_R")
5
-
6
-
7
- class hybridmethod(t.Generic[_P, _R]):
8
- """A decorator which allows definition of a Python object method with both
9
- instance-level and class-level behavior.
10
-
11
- """
12
-
13
- def __init__(
14
- self,
15
- inst_func: t.Callable[t.Concatenate[t.Any, _P], _R],
16
- cls_func: t.Callable[t.Concatenate[t.Any, _P], _R] | None = None,
17
- ):
18
- self.inst_func = inst_func
19
- if cls_func is not None:
20
- self.classmethod(cls_func)
21
- else:
22
- self.classmethod(inst_func)
23
-
24
- def __get__(
25
- self, instance: object | None, owner: type[object]
26
- ) -> t.Callable[t.Concatenate[t.Any, _P], _R]:
27
- if instance is None:
28
- return self.cls_func.__get__(owner, owner)
29
- else:
30
- return self.inst_func.__get__(instance, owner)
31
-
32
- def classmethod(
33
- self, cls_func: t.Callable[t.Concatenate[t.Any, _P], _R]
34
- ) -> "hybridmethod[_P, _R]":
35
- self.cls_func = cls_func
36
- if not self.cls_func.__doc__:
37
- self.cls_func.__doc__ = self.inst_func.__doc__
38
- return self
@@ -1,2 +0,0 @@
1
- CREATE SCHEMA ${schema_name};
2
- REVOKE ALL ON SCHEMA ${schema_name} FROM public;
@@ -1 +0,0 @@
1
- DROP SCHEMA ${schema_name} CASCADE;
@@ -1,4 +0,0 @@
1
- CREATE TRIGGER audit_trigger AFTER INSERT OR UPDATE OR DELETE ON ${table_name}
2
- FOR EACH ROW
3
- WHEN (${schema_prefix}get_setting('flask_pga.enable_audit', 'true')::bool)
4
- EXECUTE PROCEDURE ${schema_prefix}create_activity(${excluded_columns})
@@ -1,48 +0,0 @@
1
- CREATE OR REPLACE FUNCTION ${schema_prefix}create_activity() RETURNS TRIGGER AS $$
2
- DECLARE
3
- audit_row ${schema_prefix}pga_activity;
4
- excluded_cols text[] = ARRAY[]::text[];
5
- BEGIN
6
- audit_row.id = nextval('${schema_prefix}pga_activity_id_seq');
7
- audit_row.schema_name = TG_TABLE_SCHEMA::text;
8
- audit_row.table_name = TG_TABLE_NAME::text;
9
- audit_row.relid = TG_RELID;
10
- audit_row.issued_at = statement_timestamp() AT TIME ZONE 'UTC';
11
- audit_row.native_transaction_id = txid_current();
12
- audit_row.transaction_id = (
13
- SELECT id
14
- FROM ${schema_prefix}pga_transaction
15
- WHERE native_transaction_id = txid_current()
16
- AND issued_at >= (NOW() - INTERVAL '1 day')
17
- ORDER BY issued_at DESC
18
- LIMIT 1
19
- );
20
- audit_row.verb = LOWER(TG_OP);
21
- audit_row.old_data = '{}'::jsonb;
22
- audit_row.changed_data = '{}'::jsonb;
23
-
24
- IF TG_ARGV[0] IS NOT NULL THEN
25
- excluded_cols = TG_ARGV[0]::text[];
26
- END IF;
27
-
28
- IF (TG_OP = 'UPDATE' AND TG_LEVEL = 'ROW') THEN
29
- audit_row.old_data = row_to_json(OLD.*)::jsonb - excluded_cols;
30
- audit_row.changed_data = (
31
- row_to_json(NEW.*)::jsonb - audit_row.old_data - excluded_cols
32
- );
33
- IF audit_row.changed_data = '{}'::jsonb THEN
34
- -- All changed fields are ignored. Skip this update.
35
- RETURN NULL;
36
- END IF;
37
- ELSIF (TG_OP = 'DELETE' AND TG_LEVEL = 'ROW') THEN
38
- audit_row.old_data = row_to_json(OLD.*)::jsonb - excluded_cols;
39
- ELSIF (TG_OP = 'INSERT' AND TG_LEVEL = 'ROW') THEN
40
- audit_row.changed_data = row_to_json(NEW.*)::jsonb - excluded_cols;
41
- END IF;
42
- INSERT INTO ${schema_prefix}pga_activity VALUES (audit_row.*);
43
- RETURN NULL;
44
- END;
45
- $$
46
- LANGUAGE plpgsql
47
- SECURITY DEFINER
48
- SET search_path = pg_catalog, public;