flask-postgresql-audit 1.0.5__tar.gz → 1.0.7__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 (46) hide show
  1. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/PKG-INFO +1 -1
  2. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/__init__.py +1 -1
  3. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/core.py +65 -1
  4. flask_postgresql_audit-1.0.7/flask_postgresql_audit/descriptors.py +38 -0
  5. flask_postgresql_audit-1.0.7/flask_postgresql_audit/extensions/__init__.py +1 -0
  6. flask_postgresql_audit-1.0.7/flask_postgresql_audit/extensions/document_staging/__init__.py +4 -0
  7. flask_postgresql_audit-1.0.7/flask_postgresql_audit/extensions/document_staging/enum.py +8 -0
  8. flask_postgresql_audit-1.0.7/flask_postgresql_audit/extensions/document_staging/listener.py +35 -0
  9. flask_postgresql_audit-1.0.7/flask_postgresql_audit/extensions/document_staging/model.py +123 -0
  10. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/tests/app.py +1 -4
  11. flask_postgresql_audit-1.0.5/tests/__init__.py +0 -0
  12. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/.gitignore +0 -0
  13. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/CHANGES.md +0 -0
  14. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/LICENSE +0 -0
  15. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/README.md +0 -0
  16. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/alembic/__init__.py +0 -0
  17. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/alembic/entities.py +0 -0
  18. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/alembic/schema.py +0 -0
  19. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/alembic/setup.py +0 -0
  20. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/base.py +0 -0
  21. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/extensions/alembic_utils/__init__.py +0 -0
  22. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/extensions/alembic_utils/pg_aggregate.py +0 -0
  23. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/models.py +0 -0
  24. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/templates/audit_trigger_delete.sql +0 -0
  25. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/templates/audit_trigger_insert.sql +0 -0
  26. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/templates/audit_trigger_update.sql +0 -0
  27. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/templates/create_activity.sql +0 -0
  28. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/templates/create_schema.sql +0 -0
  29. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/templates/drop_schema.sql +0 -0
  30. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/templates/get_pk_values.sql +0 -0
  31. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/templates/get_setting.sql +0 -0
  32. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/templates/jsonb_subtract.sql +0 -0
  33. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/templates/row_audit_trigger.sql +0 -0
  34. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/templates/row_create_activity.sql +0 -0
  35. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/typing.py +0 -0
  36. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/flask_postgresql_audit/utils.py +0 -0
  37. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/pyproject.toml +0 -0
  38. {flask_postgresql_audit-1.0.5/flask_postgresql_audit/extensions → flask_postgresql_audit-1.0.7/tests}/__init__.py +0 -0
  39. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/tests/conftest.py +0 -0
  40. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/tests/migrations/README +0 -0
  41. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/tests/migrations/alembic.ini +0 -0
  42. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/tests/migrations/env.py +0 -0
  43. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/tests/migrations/script.py.mako +0 -0
  44. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/tests/test_defaults.py +0 -0
  45. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/tests/test_operators.py +0 -0
  46. {flask_postgresql_audit-1.0.5 → flask_postgresql_audit-1.0.7}/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.5
3
+ Version: 1.0.7
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>
@@ -1,4 +1,4 @@
1
1
  # ruff: noqa: F401
2
2
  from .core import Audit, PostgreSQLAudit
3
3
 
4
- __version__ = "1.0.5"
4
+ __version__ = "1.0.7"
@@ -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
- Base = t.TypeVar("Base", bound=orm.DeclarativeBase)
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,58 @@ 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
+ relid = sa.text(f"'{source.__tablename__}'::regclass::oid")
255
+ jsonb = sa.func.to_jsonb(source.__table__.table_valued())
256
+ activity_join_cond = sa.and_(
257
+ self.Activity.table_name == source.__tablename__,
258
+ self.Activity.row_key == self.func.get_pk_values(relid, jsonb),
259
+ )
260
+
261
+ else:
262
+ objects = list(obj) if isinstance(obj, t.Sequence) else list([obj])
263
+ unions = []
264
+ for key, group in groupby(objects, key=lambda o: o.__class__):
265
+ relid = sa.text(f"'{key.__tablename__}'::regclass::oid")
266
+ jsonb = sa.func.to_jsonb(key.__table__.table_valued())
267
+
268
+ union = sa.select(
269
+ sa.literal(key.__tablename__).label("table_name"),
270
+ self.func.get_pk_values(relid, jsonb).label("row_key"),
271
+ )
272
+
273
+ for pk in key.__table__.primary_key.columns:
274
+ pk_attr: sa.ColumnElement[t.Any] = getattr(key, pk.name)
275
+ pks = [getattr(o, pk.name) for o in group]
276
+ union = union.filter(pk_attr.in_(pks))
277
+
278
+ unions.append(union)
279
+
280
+ source = sa.union(*unions).subquery("source")
281
+ activity_join_cond = sa.and_(
282
+ self.Activity.table_name == source.c.table_name,
283
+ self.Activity.row_key == source.c.row_key,
284
+ )
285
+
286
+ stmt = (
287
+ sa.select(
288
+ self.Activity.id,
289
+ self.Activity.verb,
290
+ self.Activity.table_name,
291
+ self.Activity.issued_at.label("activity_timestamp"),
292
+ self.Transaction.issued_at.label("transaction_timestamp"),
293
+ self.Transaction.client_addr.label("transaction_addr"),
294
+ self.Transaction.actor_id.label("transaction_actor"),
295
+ self.Activity.old_data,
296
+ self.Activity.changed_data,
297
+ )
298
+ .select_from(source)
299
+ .join(self.Activity, activity_join_cond)
300
+ .join(self.Transaction)
301
+ .order_by(self.Activity.id.desc())
302
+ )
303
+
304
+ 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,4 @@
1
+ # ruff: noqa: F401
2
+ from .enum import Docstatus
3
+ from .listener import attach_listener
4
+ from .model import DocumentStaging
@@ -0,0 +1,8 @@
1
+ import enum
2
+
3
+
4
+ class Docstatus(enum.Enum):
5
+ DRAFT = "draft"
6
+ SUBMITTED = "submitted"
7
+ CANCELLED = "cancelled"
8
+ REVISED = "revised"
@@ -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
@@ -14,11 +14,8 @@ class DefaultConfig:
14
14
  DB_PASSWORD = os.environ.get("POSTGRESQL_AUDIT_TEST_PASSWORD", "")
15
15
  DB_NAME = os.environ.get("POSTGRESQL_AUDIT_TEST_DB", "postgresql_audit_test")
16
16
 
17
- # SQLALCHEMY_DATABASE_URI = (
18
- # f"postgresql://{DB_USER}:{DB_PASSWORD}@localhost/{DB_NAME}"
19
- # )
20
17
  SQLALCHEMY_DATABASE_URI = (
21
- "postgresql://postgres:postgres@localhost:5492/pg_audit_test"
18
+ f"postgresql://{DB_USER}:{DB_PASSWORD}@localhost/{DB_NAME}"
22
19
  )
23
20
  SQLALCHEMY_ECHO = False
24
21
 
File without changes