flask-postgresql-audit 1.0.5__tar.gz → 1.0.6__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.
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/PKG-INFO +1 -1
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/__init__.py +1 -1
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/core.py +59 -1
- flask_postgresql_audit-1.0.6/flask_postgresql_audit/descriptors.py +38 -0
- flask_postgresql_audit-1.0.6/flask_postgresql_audit/extensions/__init__.py +1 -0
- flask_postgresql_audit-1.0.6/flask_postgresql_audit/extensions/document_staging/__init__.py +4 -0
- flask_postgresql_audit-1.0.6/flask_postgresql_audit/extensions/document_staging/enum.py +8 -0
- flask_postgresql_audit-1.0.6/flask_postgresql_audit/extensions/document_staging/listener.py +35 -0
- flask_postgresql_audit-1.0.6/flask_postgresql_audit/extensions/document_staging/model.py +123 -0
- flask_postgresql_audit-1.0.5/tests/__init__.py +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/.gitignore +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/CHANGES.md +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/LICENSE +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/README.md +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/alembic/__init__.py +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/alembic/entities.py +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/alembic/schema.py +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/alembic/setup.py +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/base.py +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/extensions/alembic_utils/__init__.py +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/extensions/alembic_utils/pg_aggregate.py +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/models.py +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/templates/audit_trigger_delete.sql +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/templates/audit_trigger_insert.sql +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/templates/audit_trigger_update.sql +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/templates/create_activity.sql +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/templates/create_schema.sql +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/templates/drop_schema.sql +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/templates/get_pk_values.sql +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/templates/get_setting.sql +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/templates/jsonb_subtract.sql +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/templates/row_audit_trigger.sql +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/templates/row_create_activity.sql +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/typing.py +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/utils.py +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/pyproject.toml +0 -0
- {flask_postgresql_audit-1.0.5/flask_postgresql_audit/extensions → flask_postgresql_audit-1.0.6/tests}/__init__.py +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/tests/app.py +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/tests/conftest.py +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/tests/migrations/README +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/tests/migrations/alembic.ini +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/tests/migrations/env.py +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/tests/migrations/script.py.mako +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/tests/test_defaults.py +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/tests/test_operators.py +0 -0
- {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/tests/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flask-postgresql_audit
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.6
|
|
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>
|
{flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/core.py
RENAMED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import inspect
|
|
1
2
|
import typing as t
|
|
2
3
|
from contextlib import contextmanager
|
|
4
|
+
from itertools import groupby
|
|
3
5
|
|
|
4
6
|
import sqlalchemy as sa
|
|
5
7
|
import sqlalchemy.event as event
|
|
@@ -15,7 +17,8 @@ from .models import activity_model_factory, transaction_model_factory
|
|
|
15
17
|
from .typing import AnyAttrribute
|
|
16
18
|
from .utils import load_template
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
if t.TYPE_CHECKING:
|
|
21
|
+
from sqlalchemy.sql.functions import _FunctionGenerator
|
|
19
22
|
|
|
20
23
|
|
|
21
24
|
class ImproperlyConfigured(Exception):
|
|
@@ -131,6 +134,12 @@ class PostgreSQLAudit:
|
|
|
131
134
|
ctx["jsonb_subtract_join_type"] = "FULL"
|
|
132
135
|
return ctx
|
|
133
136
|
|
|
137
|
+
@property
|
|
138
|
+
def func(self) -> "_FunctionGenerator":
|
|
139
|
+
if self.schema_name and self.schema_name != "public":
|
|
140
|
+
return getattr(sa.func, self.schema_name)
|
|
141
|
+
return sa.func
|
|
142
|
+
|
|
134
143
|
@contextmanager
|
|
135
144
|
def disable(self, session: orm.Session | orm.scoped_session[t.Any]):
|
|
136
145
|
session.execute(self.set_local("'false'"))
|
|
@@ -238,3 +247,52 @@ class PostgreSQLAudit:
|
|
|
238
247
|
self.attach_listeners()
|
|
239
248
|
|
|
240
249
|
app.extensions["postgresql-audit"] = self
|
|
250
|
+
|
|
251
|
+
def fetch_activity(self, obj: type[Audit] | Audit | t.Sequence[Audit]):
|
|
252
|
+
if inspect.isclass(obj):
|
|
253
|
+
source = obj
|
|
254
|
+
activity_join_cond = self.Activity.table_name == source.__tablename__
|
|
255
|
+
else:
|
|
256
|
+
objects = list(obj) if isinstance(obj, t.Sequence) else list([obj])
|
|
257
|
+
unions = []
|
|
258
|
+
for key, group in groupby(objects, key=lambda o: o.__class__):
|
|
259
|
+
relid = sa.text(f"'{key.__tablename__}'::regclass::oid")
|
|
260
|
+
jsonb = sa.func.to_jsonb(key.__table__.table_valued())
|
|
261
|
+
|
|
262
|
+
union = sa.select(
|
|
263
|
+
sa.literal(key.__tablename__).label("table_name"),
|
|
264
|
+
self.func.get_pk_values(relid, jsonb).label("row_key"),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
for pk in key.__table__.primary_key.columns:
|
|
268
|
+
pk_attr: sa.ColumnElement[t.Any] = getattr(key, pk.name)
|
|
269
|
+
pks = [getattr(o, pk.name) for o in group]
|
|
270
|
+
union = union.filter(pk_attr.in_(pks))
|
|
271
|
+
|
|
272
|
+
unions.append(union)
|
|
273
|
+
|
|
274
|
+
source = sa.union(*unions).subquery("source")
|
|
275
|
+
activity_join_cond = sa.and_(
|
|
276
|
+
self.Activity.table_name == source.c.table_name,
|
|
277
|
+
self.Activity.row_key == source.c.row_key,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
stmt = (
|
|
281
|
+
sa.select(
|
|
282
|
+
self.Activity.id,
|
|
283
|
+
self.Activity.verb,
|
|
284
|
+
self.Activity.table_name,
|
|
285
|
+
self.Activity.issued_at.label("activity_timestamp"),
|
|
286
|
+
self.Transaction.issued_at.label("transaction_timestamp"),
|
|
287
|
+
self.Transaction.client_addr.label("transaction_addr"),
|
|
288
|
+
self.Transaction.actor_id.label("transaction_actor"),
|
|
289
|
+
self.Activity.old_data,
|
|
290
|
+
self.Activity.changed_data,
|
|
291
|
+
)
|
|
292
|
+
.select_from(source)
|
|
293
|
+
.join(self.Activity, activity_join_cond)
|
|
294
|
+
.join(self.Transaction)
|
|
295
|
+
.order_by(self.Activity.id.desc())
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
return stmt
|
|
@@ -0,0 +1,38 @@
|
|
|
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.Optional[t.Callable[t.Concatenate[t.Any, _P], _R]] = 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: t.Optional[object], owner: t.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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# ruff: noqa: F401
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import typing as t
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
import sqlalchemy.event as event
|
|
5
|
+
import sqlalchemy.orm as orm
|
|
6
|
+
from flask_sqlalchemy.session import Session
|
|
7
|
+
|
|
8
|
+
from flask_postgresql_audit.core import _default_actor_id
|
|
9
|
+
|
|
10
|
+
from .enum import Docstatus
|
|
11
|
+
from .model import DocumentStaging
|
|
12
|
+
|
|
13
|
+
_O = t.TypeVar("_O", bound=object)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def attach_listener(actor_id_getter: t.Callable[[], t.Any] = _default_actor_id):
|
|
17
|
+
@event.listens_for(orm.Session, "before_flush")
|
|
18
|
+
def __receive_before_flush__(
|
|
19
|
+
session: Session,
|
|
20
|
+
flush_context: orm.UOWTransaction,
|
|
21
|
+
instances: t.Optional[t.Sequence[_O]],
|
|
22
|
+
):
|
|
23
|
+
for obj in session.new:
|
|
24
|
+
if isinstance(obj, DocumentStaging):
|
|
25
|
+
if obj.docstatus == Docstatus.DRAFT or obj.docstatus is None:
|
|
26
|
+
obj.created_by = actor_id_getter()
|
|
27
|
+
obj.created_on = datetime.now()
|
|
28
|
+
for obj in session.dirty:
|
|
29
|
+
if isinstance(obj, DocumentStaging):
|
|
30
|
+
if obj.docstatus == Docstatus.SUBMITTED:
|
|
31
|
+
obj.submitted_by = actor_id_getter()
|
|
32
|
+
obj.submitted_on = datetime.now()
|
|
33
|
+
if obj.docstatus == Docstatus.CANCELLED:
|
|
34
|
+
obj.cancelled_by = actor_id_getter()
|
|
35
|
+
obj.cancelled_on = datetime.now()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import typing as t
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
import sqlalchemy as sa
|
|
5
|
+
import sqlalchemy.event as event
|
|
6
|
+
import sqlalchemy.orm as orm
|
|
7
|
+
from flask_sqlalchemy.session import Session
|
|
8
|
+
from sqlalchemy.dialects.postgresql import ENUM as PGEnum
|
|
9
|
+
|
|
10
|
+
from flask_postgresql_audit.typing import OMap
|
|
11
|
+
|
|
12
|
+
from .enum import Docstatus
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DocumentStaging:
|
|
16
|
+
__staging_configured__: t.ClassVar[bool]
|
|
17
|
+
__staging_fk_keys__: t.ClassVar[list[sa.ColumnClause[t.Any]]]
|
|
18
|
+
|
|
19
|
+
__bump_transitions__: t.ClassVar[dict[Docstatus, Docstatus]] = {
|
|
20
|
+
Docstatus.DRAFT: Docstatus.SUBMITTED,
|
|
21
|
+
Docstatus.SUBMITTED: Docstatus.CANCELLED,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
__unique_docstatus__: t.ClassVar[list[Docstatus]] = [
|
|
25
|
+
Docstatus.DRAFT,
|
|
26
|
+
Docstatus.SUBMITTED,
|
|
27
|
+
Docstatus.CANCELLED,
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
if t.TYPE_CHECKING:
|
|
31
|
+
__table__: sa.Table
|
|
32
|
+
__tablename__: str
|
|
33
|
+
|
|
34
|
+
def __init__(self, **kw: t.Any): ...
|
|
35
|
+
|
|
36
|
+
def __init_subclass__(cls, *args, **kwargs) -> None:
|
|
37
|
+
event.listen(cls, "instrument_class", cls.__init_staging__)
|
|
38
|
+
super().__init_subclass__(*args, **kwargs)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def __init_staging__(cls, mapper: orm.Mapper[t.Self], class_: type[t.Self]):
|
|
42
|
+
if not getattr(class_, "__staging_configured__", False):
|
|
43
|
+
table = class_.__table__
|
|
44
|
+
|
|
45
|
+
pk_cols = []
|
|
46
|
+
fk_cols = []
|
|
47
|
+
|
|
48
|
+
for pk in table.primary_key.columns:
|
|
49
|
+
fk_name = f"revision_{pk.name}"
|
|
50
|
+
fk = table.c.get(fk_name) or sa.Column(fk_name, pk.type, nullable=True)
|
|
51
|
+
|
|
52
|
+
if fk.name not in table.columns:
|
|
53
|
+
table.append_column(fk)
|
|
54
|
+
|
|
55
|
+
if not hasattr(class_, fk.name):
|
|
56
|
+
setattr(class_, fk.name, orm.mapped_column(pk.type, nullable=True))
|
|
57
|
+
|
|
58
|
+
pk_cols.append(pk)
|
|
59
|
+
fk_cols.append(fk)
|
|
60
|
+
|
|
61
|
+
fk_const = sa.ForeignKeyConstraint(
|
|
62
|
+
fk_cols,
|
|
63
|
+
pk_cols,
|
|
64
|
+
name=f"{class_.__tablename__}_revision_fkey",
|
|
65
|
+
ondelete="SET NULL",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if fk_const.name not in table.constraints:
|
|
69
|
+
table.append_constraint(fk_const)
|
|
70
|
+
|
|
71
|
+
class_.__staging_fk_keys__ = fk_cols
|
|
72
|
+
class_.__staging_configured__ = True
|
|
73
|
+
|
|
74
|
+
docstatus: orm.Mapped[Docstatus] = orm.mapped_column(
|
|
75
|
+
PGEnum(Docstatus, name="docstatus_enum", create_type=True),
|
|
76
|
+
default=Docstatus.DRAFT,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
created_on: OMap[datetime] = orm.mapped_column(default=sa.func.now())
|
|
80
|
+
created_by: OMap[str]
|
|
81
|
+
|
|
82
|
+
submitted_by: OMap[str]
|
|
83
|
+
submitted_on: OMap[datetime]
|
|
84
|
+
|
|
85
|
+
cancelled_by: OMap[str]
|
|
86
|
+
cancelled_on: OMap[datetime]
|
|
87
|
+
|
|
88
|
+
@orm.declared_attr
|
|
89
|
+
@classmethod
|
|
90
|
+
def revision(cls) -> "orm.Mapped[t.Self]":
|
|
91
|
+
return orm.relationship(cls, uselist=False)
|
|
92
|
+
|
|
93
|
+
@orm.declared_attr
|
|
94
|
+
@classmethod
|
|
95
|
+
def revision_of(cls) -> "orm.Mapped[t.Self]":
|
|
96
|
+
return orm.relationship(
|
|
97
|
+
cls,
|
|
98
|
+
uselist=False,
|
|
99
|
+
remote_side=lambda: list(cls.__table__.primary_key.columns),
|
|
100
|
+
back_populates="revision",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def bump(self):
|
|
104
|
+
if self.docstatus not in self.__bump_transitions__:
|
|
105
|
+
msg = f"Invalid docstatus: {self.docstatus.name} cannot be bumped"
|
|
106
|
+
raise ValueError(msg)
|
|
107
|
+
self.docstatus = self.__bump_transitions__[self.docstatus]
|
|
108
|
+
|
|
109
|
+
def revise(self, new_doc: t.Self):
|
|
110
|
+
if self.docstatus != Docstatus.CANCELLED:
|
|
111
|
+
msg = f"Invalid docstatus: {self.docstatus.name} cannot be revised"
|
|
112
|
+
raise ValueError(msg)
|
|
113
|
+
self.docstatus = Docstatus.REVISED
|
|
114
|
+
self.revision = new_doc
|
|
115
|
+
return new_doc
|
|
116
|
+
|
|
117
|
+
def delete(self, session: orm.scoped_session[Session]):
|
|
118
|
+
if self.docstatus != Docstatus.DRAFT:
|
|
119
|
+
msg = f"Invalid docstatus: {self.docstatus.name} cannot be deleted"
|
|
120
|
+
raise ValueError(msg)
|
|
121
|
+
session.delete(self)
|
|
122
|
+
if parent := self.revision_of:
|
|
123
|
+
parent.docstatus = Docstatus.CANCELLED
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/base.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/typing.py
RENAMED
|
File without changes
|
{flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/flask_postgresql_audit/utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.6}/tests/migrations/script.py.mako
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|