aws-python-helper 1.5.0__tar.gz → 1.7.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.
Files changed (59) hide show
  1. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/PKG-INFO +1 -1
  2. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/__init__.py +12 -1
  3. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/api/base.py +21 -0
  4. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/api/fetcher.py +29 -24
  5. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/permissions/catalog.py +38 -1
  6. aws_python_helper-1.7.0/aws_python_helper/repository/__init__.py +19 -0
  7. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/repository/audit.py +77 -20
  8. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/repository/base.py +39 -3
  9. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper.egg-info/PKG-INFO +1 -1
  10. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/pyproject.toml +1 -1
  11. aws_python_helper-1.5.0/aws_python_helper/repository/__init__.py +0 -4
  12. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/README.md +0 -0
  13. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/api/__init__.py +0 -0
  14. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/api/auth_middleware.py +0 -0
  15. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/api/auth_validators.py +0 -0
  16. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/api/dispatcher.py +0 -0
  17. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/api/exceptions.py +0 -0
  18. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/api/handler.py +0 -0
  19. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/context/__init__.py +0 -0
  20. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/context/session.py +0 -0
  21. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/context/state_validator.py +0 -0
  22. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/database/__init__.py +0 -0
  23. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/database/database_proxy.py +0 -0
  24. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/database/external_database_proxy.py +0 -0
  25. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/database/external_mongo_manager.py +0 -0
  26. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/database/mongo_manager.py +0 -0
  27. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/fargate/__init__.py +0 -0
  28. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/fargate/executor.py +0 -0
  29. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/fargate/fetcher.py +0 -0
  30. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/fargate/handler.py +0 -0
  31. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/fargate/task_base.py +0 -0
  32. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/invoker/__init__.py +0 -0
  33. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/invoker/api_client.py +0 -0
  34. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/invoker/exceptions.py +0 -0
  35. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/invoker/lambda_invoker.py +0 -0
  36. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/lambda_standalone/__init__.py +0 -0
  37. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/lambda_standalone/base.py +0 -0
  38. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/lambda_standalone/fetcher.py +0 -0
  39. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/lambda_standalone/handler.py +0 -0
  40. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/model_index_sync/__init__.py +0 -0
  41. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/model_index_sync/base.py +0 -0
  42. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/model_query/__init__.py +0 -0
  43. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/model_query/base.py +0 -0
  44. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/permissions/__init__.py +0 -0
  45. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/sns/__init__.py +0 -0
  46. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/sns/publisher.py +0 -0
  47. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/sqs/__init__.py +0 -0
  48. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/sqs/consumer_base.py +0 -0
  49. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/sqs/fetcher.py +0 -0
  50. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/sqs/handler.py +0 -0
  51. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/utils/__init__.py +0 -0
  52. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/utils/json_encoder.py +0 -0
  53. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/utils/response.py +0 -0
  54. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper/utils/serializer.py +0 -0
  55. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper.egg-info/SOURCES.txt +0 -0
  56. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper.egg-info/dependency_links.txt +0 -0
  57. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper.egg-info/requires.txt +0 -0
  58. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/aws_python_helper.egg-info/top_level.txt +0 -0
  59. {aws_python_helper-1.5.0 → aws_python_helper-1.7.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-python-helper
3
- Version: 1.5.0
3
+ Version: 1.7.0
4
4
  Summary: AWS Python Helper Framework
5
5
  Author-email: Fabian Claros <neufabiae@gmail.com>
6
6
  License-Expression: MIT
@@ -26,7 +26,14 @@ from .utils.serializer import serialize_mongo_types
26
26
 
27
27
  # Repository
28
28
  from .repository.base import Repository
29
- from .repository.audit import current_actor, public_audit
29
+ from .repository.audit import (
30
+ ACTIVE,
31
+ DEFAULT_STATUSES,
32
+ INACTIVE,
33
+ InvalidStatusError,
34
+ current_actor,
35
+ public_audit,
36
+ )
30
37
 
31
38
  # Context
32
39
  from .context.session import Session, get_session, set_session
@@ -62,6 +69,10 @@ __all__ = [
62
69
  'Repository',
63
70
  'current_actor',
64
71
  'public_audit',
72
+ 'ACTIVE',
73
+ 'INACTIVE',
74
+ 'DEFAULT_STATUSES',
75
+ 'InvalidStatusError',
65
76
  'fargate_handler',
66
77
  'api_handler',
67
78
  'sqs_handler',
@@ -288,6 +288,27 @@ class API(ABC):
288
288
  'headers': self._response_headers
289
289
  }
290
290
 
291
+ @property
292
+ def public(self) -> bool:
293
+ """
294
+ Whether this endpoint answers without a token.
295
+
296
+ Declared here, next to the code, so the permission catalog can leave it
297
+ out: a permission for something nobody needs permission for is noise in
298
+ every role screen, and an administrator ticking it would be granting
299
+ nothing.
300
+
301
+ This does not make an endpoint public — terraform does, by leaving
302
+ `authorization` unset on its lambda. This says so, so the rest of the
303
+ framework can act on it.
304
+
305
+ class AuthLoginAPI(API):
306
+ @property
307
+ def public(self):
308
+ return True
309
+ """
310
+ return False
311
+
291
312
  @property
292
313
  def schema(self):
293
314
  """
@@ -7,6 +7,34 @@ import importlib.util
7
7
  from pathlib import Path
8
8
  from typing import List
9
9
 
10
+ def load_controller_class(file_path: str):
11
+ """The API class a controller file defines.
12
+
13
+ Shared so the permission catalog asks the class the same questions the
14
+ dispatcher does — a second way of finding it would answer differently the
15
+ day one of them changes.
16
+ """
17
+ spec = importlib.util.spec_from_file_location("api_module", file_path)
18
+ if not spec or not spec.loader:
19
+ raise ImportError(f"Could not load module spec from: {file_path}")
20
+
21
+ module = importlib.util.module_from_spec(spec)
22
+ spec.loader.exec_module(module)
23
+
24
+ for item_name in dir(module):
25
+ item = getattr(module, item_name)
26
+ if (isinstance(item, type) and
27
+ hasattr(item, 'process') and
28
+ item.__name__ not in ['API', 'ABC'] and
29
+ item.__module__ == module.__name__):
30
+ return item
31
+
32
+ raise ValueError(
33
+ f"No API class found in {file_path}\n"
34
+ f"Make sure your file exports a class that inherits from API"
35
+ )
36
+
37
+
10
38
  class Fetcher:
11
39
  """
12
40
  Dynamically load API controllers
@@ -187,30 +215,7 @@ class Fetcher:
187
215
  f"Expected file for endpoint '{self.endpoint}' with method '{self.method}'"
188
216
  )
189
217
 
190
- # Load module dynamically
191
- spec = importlib.util.spec_from_file_location("api_module", file_path)
192
- if not spec or not spec.loader:
193
- raise ImportError(f"Could not load module spec from: {file_path}")
194
-
195
- module = importlib.util.module_from_spec(spec)
196
- spec.loader.exec_module(module)
197
-
198
- # Search for a class that inherits from API
199
- controller_class = None
200
- for item_name in dir(module):
201
- item = getattr(module, item_name)
202
- if (isinstance(item, type) and
203
- hasattr(item, 'process') and
204
- item.__name__ not in ['API', 'ABC'] and
205
- item.__module__ == module.__name__):
206
- controller_class = item
207
- break
208
-
209
- if not controller_class:
210
- raise ValueError(
211
- f"No API class found in {file_path}\n"
212
- f"Make sure your file exports a class that inherits from API"
213
- )
218
+ controller_class = load_controller_class(file_path)
214
219
 
215
220
  # Cache the class (not the instance)
216
221
  self._cache[file_path] = controller_class
@@ -12,10 +12,14 @@ otherwise silently stop matching in the other, and an access check that never
12
12
  matches fails open or shut without anyone noticing.
13
13
  """
14
14
 
15
+ import logging
15
16
  import os
17
+ import re
16
18
  from typing import Dict, List
17
19
 
18
- from ..api.fetcher import Fetcher
20
+ from ..api.fetcher import Fetcher, load_controller_class
21
+
22
+ logger = logging.getLogger(__name__)
19
23
 
20
24
  # Cómo se llama el archivo según el método. `list` y `get` son ambos GET: uno
21
25
  # responde la colección, el otro un elemento.
@@ -46,6 +50,10 @@ def discover(api_root: str, service: str) -> List[Dict[str, str]]:
46
50
  if extension != ".py" or stem not in FILE_METHODS:
47
51
  continue
48
52
 
53
+ controller = _controller(os.path.join(current, filename))
54
+ if controller and getattr(controller(), "public", False):
55
+ continue
56
+
49
57
  relative = os.path.relpath(current, api_root)
50
58
  parts = [] if relative == "." else relative.split(os.sep)
51
59
  path = "/" + "/".join(parts)
@@ -62,11 +70,40 @@ def discover(api_root: str, service: str) -> List[Dict[str, str]]:
62
70
  "method": method,
63
71
  "path": path,
64
72
  "scope": "item" if stem == "get" else "collection",
73
+ "name": _readable(controller),
65
74
  })
66
75
 
67
76
  return sorted(found, key=lambda item: item["code"])
68
77
 
69
78
 
79
+ def _controller(file_path: str):
80
+ """The controller class, or None if it cannot be read.
81
+
82
+ None errs towards listing the endpoint. A permission that appears for a
83
+ public endpoint is noise; one that disappears for a protected endpoint
84
+ leaves the check with nothing to match and locks everybody out of it.
85
+ """
86
+ try:
87
+ return load_controller_class(file_path)
88
+ except Exception as exc:
89
+ logger.warning("Could not read %s, listing it anyway: %s", file_path, exc)
90
+ return None
91
+
92
+
93
+ def _readable(controller) -> str:
94
+ """The class name as words: `UsersInviteAPI` -> `Users Invite`.
95
+
96
+ A panel showing raw paths makes people read routing to answer "what does
97
+ this let someone do". The class already names the thing; this only spaces it
98
+ out. Empty when the class could not be read, so the panel falls back to the
99
+ path rather than inventing a name.
100
+ """
101
+ if controller is None:
102
+ return ""
103
+ name = re.sub(r"(API|Api)$", "", controller.__name__)
104
+ return re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", name).strip()
105
+
106
+
70
107
  def service_wildcard(service: str) -> str:
71
108
  return f"{service}:*"
72
109
 
@@ -0,0 +1,19 @@
1
+ from .base import Repository
2
+ from .audit import (
3
+ ACTIVE,
4
+ DEFAULT_STATUSES,
5
+ INACTIVE,
6
+ InvalidStatusError,
7
+ current_actor,
8
+ public_audit,
9
+ )
10
+
11
+ __all__ = [
12
+ 'Repository',
13
+ 'ACTIVE',
14
+ 'INACTIVE',
15
+ 'DEFAULT_STATUSES',
16
+ 'InvalidStatusError',
17
+ 'current_actor',
18
+ 'public_audit',
19
+ ]
@@ -1,10 +1,17 @@
1
1
  """
2
- Automatic audit stamping for every write.
2
+ Framework-managed fields on every write: who, when, and what state.
3
3
 
4
4
  Records answer "who last touched this, and when" without any repository having to
5
- remember to fill it in. The four fields —created_at, created_by, updated_at,
6
- updated_by— are injected by wrapping the Motor collection, so call sites keep
7
- using `self.collection.insert_one(...)` exactly as before.
5
+ remember to fill it in. The four audit fields —created_at, created_by,
6
+ updated_at, updated_by— plus `status` are injected by wrapping the Motor
7
+ collection, so call sites keep using `self.collection.insert_one(...)` exactly as
8
+ before.
9
+
10
+ `status` is the lifecycle of a record, uniform across every entity so a listing
11
+ can show it without knowing what it is looking at. Two values by default, active
12
+ and inactive; a repository declaring `statuses` gets its own set. A value outside
13
+ that set is refused rather than stored: a status nobody expected reaches a panel
14
+ as an unknown label and a query as a silent mismatch.
8
15
 
9
16
  Not every write comes from a person. A scraper, a queue consumer or a scheduled
10
17
  task writes too, and the stamp says so: "nobody knows" and "the system did it"
@@ -28,6 +35,11 @@ CREATED_AT = 'created_at'
28
35
  CREATED_BY = 'created_by'
29
36
  UPDATED_AT = 'updated_at'
30
37
  UPDATED_BY = 'updated_by'
38
+ STATUS = 'status'
39
+
40
+ ACTIVE = 'active'
41
+ INACTIVE = 'inactive'
42
+ DEFAULT_STATUSES = [ACTIVE, INACTIVE]
31
43
 
32
44
 
33
45
  def _system_actor() -> Dict[str, str]:
@@ -66,11 +78,27 @@ def _now() -> datetime:
66
78
  return datetime.now(timezone.utc)
67
79
 
68
80
 
69
- def stamp_new(document: Dict[str, Any]) -> Dict[str, Any]:
70
- """The four fields on a document being created.
81
+ class InvalidStatusError(ValueError):
82
+ """A status the repository does not declare."""
83
+
84
+
85
+ def _check_status(value: Any, statuses: Optional[List[str]]) -> None:
86
+ if statuses and value is not None and value not in statuses:
87
+ raise InvalidStatusError(
88
+ f"'{value}' is not a valid status. Declared: {', '.join(statuses)}"
89
+ )
90
+
91
+
92
+ def stamp_new(
93
+ document: Dict[str, Any],
94
+ statuses: Optional[List[str]] = None,
95
+ default_status: Optional[str] = None,
96
+ ) -> Dict[str, Any]:
97
+ """The framework fields on a document being created.
71
98
 
72
99
  Whatever the caller set explicitly wins: this fills gaps, it does not
73
- overrule a repository that knows better.
100
+ overrule a repository that knows better. What it does not let pass is a
101
+ status outside the declared set.
74
102
  """
75
103
  if not isinstance(document, dict):
76
104
  return document
@@ -80,6 +108,10 @@ def stamp_new(document: Dict[str, Any]) -> Dict[str, Any]:
80
108
  document.setdefault(UPDATED_AT, now)
81
109
  document.setdefault(CREATED_BY, who)
82
110
  document.setdefault(UPDATED_BY, who)
111
+
112
+ if statuses:
113
+ document.setdefault(STATUS, default_status or statuses[0])
114
+ _check_status(document.get(STATUS), statuses)
83
115
  return document
84
116
 
85
117
 
@@ -95,6 +127,8 @@ def _merge_into_set(update: Dict[str, Any], values: Dict[str, Any], operator: st
95
127
  def stamp_update(
96
128
  update: Union[Dict[str, Any], List[Dict[str, Any]]],
97
129
  upsert: bool = False,
130
+ statuses: Optional[List[str]] = None,
131
+ default_status: Optional[str] = None,
98
132
  ) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
99
133
  """The two 'updated' fields on a modification, and the 'created' ones on an upsert.
100
134
 
@@ -114,18 +148,28 @@ def stamp_update(
114
148
 
115
149
  # Un reemplazo entero —sin operadores— es un documento, no una modificación.
116
150
  if update and not any(key.startswith('$') for key in update):
117
- return stamp_new(dict(update))
151
+ return stamp_new(dict(update), statuses, default_status)
118
152
 
119
153
  update = dict(update)
154
+ if statuses:
155
+ for operator in ('$set', '$setOnInsert'):
156
+ section = update.get(operator)
157
+ if isinstance(section, dict) and STATUS in section:
158
+ _check_status(section[STATUS], statuses)
159
+
120
160
  _merge_into_set(update, touched, '$set')
121
161
  if upsert:
122
- # Si termina insertando, el registro nace ahora: created_* sólo en ese caso.
123
- _merge_into_set(update, {CREATED_AT: now, CREATED_BY: who}, '$setOnInsert')
162
+ # Si termina insertando, el registro nace ahora: created_* y el estado
163
+ # inicial sólo en ese caso.
164
+ born = {CREATED_AT: now, CREATED_BY: who}
165
+ if statuses:
166
+ born[STATUS] = default_status or statuses[0]
167
+ _merge_into_set(update, born, '$setOnInsert')
124
168
  return update
125
169
 
126
170
 
127
171
  class AuditedCollection:
128
- """A Motor collection that stamps who wrote and when.
172
+ """A Motor collection that fills in who wrote, when, and the record's status.
129
173
 
130
174
  Only the write methods are intercepted; everything else —find, aggregate,
131
175
  count_documents, create_index— passes straight through untouched.
@@ -135,10 +179,23 @@ class AuditedCollection:
135
179
  than asking for the stamp explicitly.
136
180
  """
137
181
 
138
- __slots__ = ('_collection',)
182
+ __slots__ = ('_collection', '_statuses', '_default_status')
139
183
 
140
- def __init__(self, collection: Any):
184
+ def __init__(
185
+ self,
186
+ collection: Any,
187
+ statuses: Optional[List[str]] = None,
188
+ default_status: Optional[str] = None,
189
+ ):
141
190
  object.__setattr__(self, '_collection', collection)
191
+ object.__setattr__(self, '_statuses', statuses)
192
+ object.__setattr__(self, '_default_status', default_status)
193
+
194
+ def _new(self, document: Dict[str, Any]) -> Dict[str, Any]:
195
+ return stamp_new(document, self._statuses, self._default_status)
196
+
197
+ def _update(self, update: Any, upsert: bool) -> Any:
198
+ return stamp_update(update, upsert, self._statuses, self._default_status)
142
199
 
143
200
  def __getattr__(self, name: str) -> Any:
144
201
  return getattr(self._collection, name)
@@ -147,36 +204,36 @@ class AuditedCollection:
147
204
  return f'AuditedCollection({self._collection!r})'
148
205
 
149
206
  async def insert_one(self, document: Dict[str, Any], *args: Any, **kwargs: Any):
150
- return await self._collection.insert_one(stamp_new(document), *args, **kwargs)
207
+ return await self._collection.insert_one(self._new(document), *args, **kwargs)
151
208
 
152
209
  async def insert_many(self, documents: Any, *args: Any, **kwargs: Any):
153
210
  return await self._collection.insert_many(
154
- [stamp_new(document) for document in documents], *args, **kwargs
211
+ [self._new(document) for document in documents], *args, **kwargs
155
212
  )
156
213
 
157
214
  async def update_one(self, filter: Any, update: Any, *args: Any, **kwargs: Any):
158
215
  return await self._collection.update_one(
159
- filter, stamp_update(update, kwargs.get('upsert', False)), *args, **kwargs
216
+ filter, self._update(update, kwargs.get('upsert', False)), *args, **kwargs
160
217
  )
161
218
 
162
219
  async def update_many(self, filter: Any, update: Any, *args: Any, **kwargs: Any):
163
220
  return await self._collection.update_many(
164
- filter, stamp_update(update, kwargs.get('upsert', False)), *args, **kwargs
221
+ filter, self._update(update, kwargs.get('upsert', False)), *args, **kwargs
165
222
  )
166
223
 
167
224
  async def find_one_and_update(self, filter: Any, update: Any, *args: Any, **kwargs: Any):
168
225
  return await self._collection.find_one_and_update(
169
- filter, stamp_update(update, kwargs.get('upsert', False)), *args, **kwargs
226
+ filter, self._update(update, kwargs.get('upsert', False)), *args, **kwargs
170
227
  )
171
228
 
172
229
  async def replace_one(self, filter: Any, replacement: Any, *args: Any, **kwargs: Any):
173
230
  return await self._collection.replace_one(
174
- filter, stamp_new(dict(replacement)), *args, **kwargs
231
+ filter, self._new(dict(replacement)), *args, **kwargs
175
232
  )
176
233
 
177
234
  async def find_one_and_replace(self, filter: Any, replacement: Any, *args: Any, **kwargs: Any):
178
235
  return await self._collection.find_one_and_replace(
179
- filter, stamp_new(dict(replacement)), *args, **kwargs
236
+ filter, self._new(dict(replacement)), *args, **kwargs
180
237
  )
181
238
 
182
239
 
@@ -13,7 +13,7 @@ from typing import Any, Dict, List, Optional, Tuple
13
13
  from ..database.mongo_manager import MongoManager
14
14
  from ..database.external_mongo_manager import ExternalMongoManager
15
15
  from ..context.session import get_session
16
- from .audit import AuditedCollection
16
+ from .audit import DEFAULT_STATUSES, AuditedCollection
17
17
 
18
18
 
19
19
  class Repository(ABC):
@@ -38,6 +38,11 @@ class Repository(ABC):
38
38
  indexes (list): List of index definitions to create automatically.
39
39
  audit (bool): Stamp created_at/created_by/updated_at/updated_by on every
40
40
  write. Default: True — no repository has to remember to do it.
41
+ statuses (list[str] | None): Allowed values of the record's `status`.
42
+ Default: ['active', 'inactive']. None means this collection has no
43
+ status at all.
44
+ default_status (str): What a new record gets. Default: the first of
45
+ `statuses`.
41
46
 
42
47
  Usage:
43
48
  # Core repository — always uses 'core' database
@@ -141,6 +146,35 @@ class Repository(ABC):
141
146
  """Whether this repository uses an external MongoDB cluster. Default: False."""
142
147
  return False
143
148
 
149
+ @property
150
+ def statuses(self) -> Optional[List[str]]:
151
+ """
152
+ The values this record's `status` can take.
153
+
154
+ Every entity carries one, and the same two by default, so a listing can
155
+ show the state of anything without knowing what it is looking at. A
156
+ repository with its own lifecycle declares it:
157
+
158
+ class UserRepository(Repository):
159
+ @property
160
+ def statuses(self):
161
+ return ["active", "pending", "inactive"]
162
+
163
+ A write with a value outside this set is refused, not stored: an
164
+ unexpected status reaches a panel as an unknown label and a query as a
165
+ silent mismatch.
166
+
167
+ None for collections where a lifecycle makes no sense — sessions, tokens,
168
+ counters.
169
+ """
170
+ return list(DEFAULT_STATUSES)
171
+
172
+ @property
173
+ def default_status(self) -> Optional[str]:
174
+ """What a record gets when nobody says otherwise. First of `statuses`."""
175
+ declared = self.statuses
176
+ return declared[0] if declared else None
177
+
144
178
  @property
145
179
  def audit(self) -> bool:
146
180
  """
@@ -220,9 +254,11 @@ class Repository(ABC):
220
254
  db = MongoManager.get_database(self.database_name)
221
255
 
222
256
  collection = db[self.collection_name]
223
- # La envoltura sella las escrituras; todo lo demás pasa de largo.
257
+ # La envoltura completa las escrituras; todo lo demás pasa de largo.
224
258
  self._collection_cache[key] = (
225
- AuditedCollection(collection) if self.audit else collection
259
+ AuditedCollection(collection, self.statuses, self.default_status)
260
+ if self.audit
261
+ else collection
226
262
  )
227
263
 
228
264
  return self._collection_cache[key]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-python-helper
3
- Version: 1.5.0
3
+ Version: 1.7.0
4
4
  Summary: AWS Python Helper Framework
5
5
  Author-email: Fabian Claros <neufabiae@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aws-python-helper"
7
- version = "1.5.0"
7
+ version = "1.7.0"
8
8
  description = "AWS Python Helper Framework"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,4 +0,0 @@
1
- from .base import Repository
2
- from .audit import current_actor, public_audit
3
-
4
- __all__ = ['Repository', 'current_actor', 'public_audit']