velocity-python 0.2.3__py3-none-any.whl → 0.2.4__py3-none-any.whl

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.
velocity/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = version = "0.2.3"
1
+ __version__ = version = "0.2.4"
2
2
 
3
3
  import importlib as _importlib
4
4
 
@@ -1093,6 +1093,9 @@ class Context:
1093
1093
  "enabled": response.get("Enabled"),
1094
1094
  "user_status": response.get("UserStatus"),
1095
1095
  "sub": attributes.get("sub", user_sub),
1096
+ # the token issuer for this pool: with "sub", the identity
1097
+ # the provider never reassigns (velocity.aws.identity)
1098
+ "issuer": f"https://{user_pool_segment}",
1096
1099
  }
1097
1100
 
1098
1101
  return user
@@ -177,10 +177,15 @@ class LambdaHandler(BaseHandler):
177
177
  )
178
178
 
179
179
  context.perf.start("user lookup")
180
- user = self._cached_user(tx, session.get("email_address"))
180
+ user = self._find_current_user(tx, session, masquerade)
181
181
  context.perf.log("user lookup")
182
182
  if not user and not masquerade and _stage_allows_provisioning():
183
- user = self._provision_and_reload(tx, session)
183
+ if self._by_subject(masquerade):
184
+ # the hook's new row is bound on this, its first, sign-in
185
+ self.provision_missing_user(tx, session)
186
+ user = self._find_current_user(tx, session, masquerade)
187
+ else:
188
+ user = self._provision_and_reload(tx, session)
184
189
  if not user:
185
190
  raise UserNotProvisionedError(
186
191
  "A valid user with permission is required to access this function [DB]"
@@ -245,6 +250,59 @@ class LambdaHandler(BaseHandler):
245
250
  )
246
251
  return user
247
252
 
253
+ # How a request finds its user row (velocity.aws.identity): "email" (the
254
+ # default) or "subject" — the identity provider's issuer + subject, the
255
+ # row bound once by verified email on the first sign-in after the switch.
256
+ # The IdentityMode config key (per environment) overrides this.
257
+ identity_mode = "email"
258
+
259
+ def _by_subject(self, masquerade):
260
+ from velocity.aws import identity
261
+
262
+ # a masquerade identity is the grant's user, not a provider subject;
263
+ # the mode is the IdentityMode config key, else identity_mode
264
+ return not masquerade and identity.mode_of(self) == identity.IDENTITY_SUBJECT
265
+
266
+ def _find_current_user(self, tx, session, masquerade=False):
267
+ """The signed-in caller's user row (dict) or None, by email or — in
268
+ ``identity_mode = "subject"`` — by issuer + subject."""
269
+ if not self._by_subject(masquerade):
270
+ return self._cached_user(tx, session.get("email_address"))
271
+ from velocity.aws import identity
272
+
273
+ issuer, subject, email, verified = identity.claims_of(self.cognito_user)
274
+ session["iss"], session["sub"] = issuer, subject
275
+ if not (issuer and subject):
276
+ return None
277
+ return self._cached_subject_user(tx, issuer, subject, email, verified)
278
+
279
+ def _cached_subject_user(self, tx, issuer, subject, email, verified):
280
+ """``_cached_user`` by issuer + subject. A first sign-in binds its row
281
+ inside the load; a miss is never cached."""
282
+ from velocity.cache import memo
283
+
284
+ from velocity.aws import identity
285
+ from velocity.aws.handlers.context import user_cache_seconds
286
+
287
+ def load():
288
+ return identity.find_user(
289
+ tx, self.user_table, issuer, subject, email, verified
290
+ )
291
+
292
+ key = ("user-subject", self.user_table, issuer, subject)
293
+ if getattr(self, "permission_source", None) == "tables":
294
+ from velocity.aws import permissions as _grants
295
+
296
+ key = (*key, _grants.cached_version(tx))
297
+ user = memo(
298
+ key,
299
+ load,
300
+ user_cache_seconds(),
301
+ tables=(self.user_table,),
302
+ cache_none=False,
303
+ )
304
+ return dict(user) if user else None
305
+
248
306
  def _cached_user(self, tx, email_address):
249
307
  """The user-table row for the signed-in user as a dict, through the
250
308
  process read cache (velocity.cache) for ``UserCacheSeconds``: one
@@ -23,8 +23,9 @@ from .row_scoped import RowScopedDataService
23
23
  from .audit_trail import AuditTrailMixin
24
24
  from .job_handler import JobHandler
25
25
  from .session_control import SessionControlMixin
26
- from .permissions_actions import PermissionsMixin
27
- from .tiles_actions import TilesMixin
26
+ from .action_names import action_names
27
+ from .permissions_actions import PERMISSION_ACTIONS, PermissionsMixin
28
+ from .tiles_actions import TILE_ACTIONS, TilesMixin
28
29
  from .client_errors import ClientErrorReporting, AnonymousClientErrorReporting
29
30
 
30
31
  __all__ = [
@@ -37,7 +38,10 @@ __all__ = [
37
38
  "JobHandler",
38
39
  "SessionControlMixin",
39
40
  "PermissionsMixin",
41
+ "PERMISSION_ACTIONS",
40
42
  "TilesMixin",
43
+ "TILE_ACTIONS",
44
+ "action_names",
41
45
  "ClientErrorReporting",
42
46
  "AnonymousClientErrorReporting",
43
47
  ]
@@ -0,0 +1,30 @@
1
+ """The client action names a handler class serves, from its methods.
2
+
3
+ ``BaseHandler.format_action_name`` turns ``"tiles-load"`` into
4
+ ``"OnActionTilesLoad"``; this is the inverse, for a site's ``client_actions``
5
+ allow-list: ``client_actions = MINE | TILE_ACTIONS | PERMISSION_ACTIONS``
6
+ picks up every action a mixin gains with a release, instead of a list each
7
+ site copies by hand (and forgets to extend: admin refused every tile action
8
+ until its list was edited, 2026-10-14).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ _PREFIX = "OnAction"
16
+ _WORD = re.compile(r"[A-Z][a-z0-9]*")
17
+
18
+
19
+ def action_names(cls) -> frozenset:
20
+ """The kebab-case action of every ``OnAction<Name>`` method ``cls``
21
+ itself defines (not inherited ones; not ``OnActionDefault``)."""
22
+ names = set()
23
+ for attr, value in vars(cls).items():
24
+ if not attr.startswith(_PREFIX) or not callable(value):
25
+ continue
26
+ rest = attr[len(_PREFIX) :]
27
+ if not rest or rest == "Default":
28
+ continue
29
+ names.add("-".join(word.lower() for word in _WORD.findall(rest)))
30
+ return frozenset(names)
@@ -426,6 +426,19 @@ def plan_row_number(params):
426
426
 
427
427
  # result_formats query answers with a file download (velocity.misc.export).
428
428
  FILE_EXPORT_FORMATS = frozenset(export.EXPORT_FORMATS)
429
+ # Formats written as XLSX cells, which carry no time zone: aware values are
430
+ # converted to the handler's display_timezone() first.
431
+ XLSX_EXPORT_FORMATS = frozenset({"excel", "xlsx"})
432
+
433
+
434
+ def _export_zone(handler, result_format, context):
435
+ """The display zone for an export: the handler's ``display_timezone``
436
+ for the XLSX formats, ``None`` for the text formats (which write values
437
+ as given and need no SSM read)."""
438
+ if result_format not in XLSX_EXPORT_FORMATS:
439
+ return None
440
+ zone_for = getattr(handler, "display_timezone", None)
441
+ return zone_for(context) if zone_for is not None else None
429
442
 
430
443
 
431
444
  def _positive_int(value):
@@ -679,7 +692,11 @@ class ExportStream:
679
692
  """The whole export as one file: :func:`velocity.misc.export.build_buffer`
680
693
  over :meth:`rows` (line formats are written as the batches arrive)."""
681
694
  built = export.build_buffer(
682
- result_format, self.headers, self.rows(), filename_base
695
+ result_format,
696
+ self.headers,
697
+ self.rows(),
698
+ filename_base,
699
+ display_timezone=_export_zone(self.handler, result_format, self.context),
683
700
  )
684
701
  if built is None:
685
702
  raise BadRequestError(f"Unsupported export format: {result_format!r}")
@@ -838,6 +855,24 @@ class DataServiceMixin:
838
855
  "timestamp": "string",
839
856
  }
840
857
 
858
+ def display_timezone(self, context=None):
859
+ """The zone XLSX exports show times in: Excel cells carry no zone,
860
+ so an aware value (every ``timestamptz``) is written as its wall time
861
+ here. Default: the site's ``SiteTimezone`` config key (an IANA name
862
+ such as ``America/Denver``, read through ``ssm_config.getenv``, which
863
+ caches it per process); UTC, with a warning, when it is unset or not
864
+ a zone. Override to choose per request (``context`` is the request
865
+ context) or per handler. Returns a ``tzinfo``."""
866
+ try:
867
+ name = ssm_config.getenv("SiteTimezone", None)
868
+ except Exception:
869
+ logger.warning("SiteTimezone could not be read; exports use UTC")
870
+ name = None
871
+ if not name:
872
+ logger.warning("SiteTimezone is not set; exports show times in UTC")
873
+ return datetime.timezone.utc
874
+ return export.resolve_display_timezone(name)
875
+
841
876
  def _resolve_query_max_rows(self):
842
877
  """Effective query row ceiling: `QueryMaxRows` config key first, then
843
878
  the class attribute. Returns a positive int or None (no cap)."""
@@ -2231,6 +2266,7 @@ class DataServiceMixin:
2231
2266
  data.get("headers", []),
2232
2267
  data.get("rows", []),
2233
2268
  filename_base,
2269
+ display_timezone=_export_zone(self, result_format, context),
2234
2270
  )
2235
2271
  if result_format == "excel":
2236
2272
  # the legacy excel download keeps its caller-chosen filename
@@ -2412,7 +2448,12 @@ class DataServiceMixin:
2412
2448
  )
2413
2449
  rows = result.as_list().all()
2414
2450
  filebuffer = BytesIO()
2415
- export.create_spreadsheet(headers, rows, filebuffer)
2451
+ export.create_spreadsheet(
2452
+ headers,
2453
+ rows,
2454
+ filebuffer,
2455
+ display_timezone=self.display_timezone(context),
2456
+ )
2416
2457
  context.response().file_download(
2417
2458
  {
2418
2459
  "filename": payload.get("filename", "temp_file.xls"),
@@ -63,6 +63,7 @@ from types import SimpleNamespace
63
63
 
64
64
  from velocity.aws import authz, permissions
65
65
  from velocity.aws.handlers.exceptions import AlertError
66
+ from .action_names import action_names
66
67
 
67
68
  READ = "permissions.read"
68
69
  READ_ACTION = "permissions.read"
@@ -804,3 +805,8 @@ class PermissionsMixin:
804
805
  )
805
806
  )
806
807
  context.response().set_body(self._inbox_body(tx, actor))
808
+
809
+
810
+ # every action PermissionsMixin serves, for a site's ``client_actions``:
811
+ # ``client_actions = MINE | PERMISSION_ACTIONS``
812
+ PERMISSION_ACTIONS = action_names(PermissionsMixin)
@@ -39,6 +39,7 @@ from velocity.aws import authz, facets, permissions, tiles
39
39
  from velocity.aws.handlers.exceptions import AccessError, AlertError
40
40
  from velocity.db.fragments import Expr
41
41
  from velocity.db.servers.tablehelper import TableHelper
42
+ from .action_names import action_names
42
43
 
43
44
  logger = logging.getLogger(__name__)
44
45
 
@@ -659,3 +660,8 @@ class TilesMixin:
659
660
  )
660
661
  )
661
662
  context.response().set_body(self._manage_body(tx, actor))
663
+
664
+
665
+ # every action TilesMixin serves, for a site's ``client_actions``:
666
+ # ``client_actions = MINE | TILE_ACTIONS``
667
+ TILE_ACTIONS = action_names(TilesMixin)
@@ -621,6 +621,14 @@ Request Details:
621
621
  return
622
622
  session = context.session() or {}
623
623
  email = session.get("email_address")
624
+ if not email:
625
+ # Nobody is signed in (a public page that saves its layout as it
626
+ # goes): there is no owner, so nothing is saved. Rows mode used to
627
+ # upsert under a blank userid — one shared junk row per key that
628
+ # every anonymous visitor overwrote. Not an error, so a public
629
+ # page does not report a failure on every change.
630
+ context.response().set_body({"saved": False, "reason": "signed_out"})
631
+ return
624
632
  if self.user_data_mode == USER_DATA_DOCUMENT:
625
633
  owner = (getattr(self, "current_user", None) or {}).get("sys_id")
626
634
  decision = authz.decide(
@@ -0,0 +1,138 @@
1
+ """Who is signed in: the user row bound to the identity provider's issuer and
2
+ subject, never found by email.
3
+
4
+ An email address can change, be reused, or be claimed by an external identity
5
+ provider; the provider's issuer plus subject (``iss`` + ``sub``) is the one
6
+ pair it guarantees never to reassign (OpenID Connect Core 1.0, section 5.7).
7
+ So a user row carries the pair it is bound to, and each request finds its
8
+ user by that pair.
9
+
10
+ The mode is the ``IdentityMode`` config key (SSM ``/{Project}/{env}/`` then
11
+ ``/Global/{env}/``) when set, else the handler's ``identity_mode``
12
+ (see ``LambdaHandler.beforeAction``), else ``"email"`` — so a site switches
13
+ one environment at a time, demo first, with no code change:
14
+
15
+ * ``"email"`` — the default and the behaviour before this module: the user
16
+ row is found by ``email_address``.
17
+ * ``"subject"`` — the user row is found by :data:`ISSUER_COLUMN` +
18
+ :data:`SUBJECT_COLUMN`. The first sign-in after a site switches binds its
19
+ existing row ONCE, by the caller's *verified* email, and only a row not
20
+ yet bound to anyone; from then on email is never used to find that user.
21
+ A row already bound to another identity is never re-bound by email (the
22
+ address moved to a different account), and an unverified email binds
23
+ nothing.
24
+
25
+ Both columns are TEXT on the site's user table. Declare them in the site's
26
+ schema lane before switching a locked environment
27
+ (``SCHEMA_COLUMNS``); in an unlocked one the first binding creates them.
28
+
29
+ A masquerade session keeps the email path: its identity is the verified
30
+ grant's user, not a provider subject.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import logging
36
+
37
+ from velocity.aws import ssm_config
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ IDENTITY_EMAIL = "email"
42
+ IDENTITY_SUBJECT = "subject"
43
+ IDENTITY_MODES = (IDENTITY_EMAIL, IDENTITY_SUBJECT)
44
+
45
+ ISSUER_COLUMN = "identity_issuer"
46
+ SUBJECT_COLUMN = "identity_subject"
47
+ # for a site's schema lane: ensure_columns(tx, user_table, SCHEMA_COLUMNS)
48
+ SCHEMA_COLUMNS = {ISSUER_COLUMN: "TEXT", SUBJECT_COLUMN: "TEXT"}
49
+
50
+
51
+ def mode_of(handler):
52
+ """``IdentityMode`` config first, then the handler's ``identity_mode``,
53
+ then ``"email"``. A value that is not a mode is ``"email"`` (logged):
54
+ unlike authorization, the safe fallback is the established lookup, so a
55
+ typo never locks every user out."""
56
+ raw = None
57
+ try:
58
+ raw = ssm_config.getenv("IdentityMode", None)
59
+ except Exception:
60
+ raw = None
61
+ raw = str(raw or getattr(handler, "identity_mode", None) or IDENTITY_EMAIL)
62
+ mode = raw.strip().lower()
63
+ if mode not in IDENTITY_MODES:
64
+ logger.warning("identity: unknown IdentityMode %r; using email", raw)
65
+ return IDENTITY_EMAIL
66
+ return mode
67
+
68
+
69
+ def claims_of(cognito_user):
70
+ """``(issuer, subject, email, email_verified)`` from a resolved Cognito
71
+ user (``Context.get_cognito_user``); ``None`` parts when absent."""
72
+ user = cognito_user if isinstance(cognito_user, dict) else {}
73
+ attributes = user.get("attributes") or {}
74
+ email = (attributes.get("email") or user.get("email") or "").strip().lower() or None
75
+ verified = str(attributes.get("email_verified", "")).strip().lower() == "true"
76
+ return user.get("issuer") or None, user.get("sub") or None, email, verified
77
+
78
+
79
+ def _row(found):
80
+ if not found:
81
+ return None
82
+ return found.to_dict() if hasattr(found, "to_dict") else dict(found)
83
+
84
+
85
+ def find_bound(tx, user_table, issuer, subject):
86
+ """The user row bound to ``issuer`` + ``subject``, or None."""
87
+ if not (issuer and subject):
88
+ return None
89
+ return _row(
90
+ tx.table(user_table).find({ISSUER_COLUMN: issuer, SUBJECT_COLUMN: subject})
91
+ )
92
+
93
+
94
+ def bind_by_verified_email(tx, user_table, issuer, subject, email, verified):
95
+ """Bind the unbound row with this verified email to ``issuer`` +
96
+ ``subject`` and return it; None when there is nothing it may bind.
97
+
98
+ Refuses (returns None, logs why) when the email is not verified, when no
99
+ row has it, or when that row is already bound to a different identity —
100
+ the address now belongs to another account, and following it would hand
101
+ that account someone else's row.
102
+ """
103
+ if not (issuer and subject and email):
104
+ return None
105
+ if not verified:
106
+ logger.warning("identity: not binding %s — email not verified", email)
107
+ return None
108
+ row = _row(tx.table(user_table).find({"email_address": email}))
109
+ if not row:
110
+ return None
111
+ bound = (row.get(ISSUER_COLUMN), row.get(SUBJECT_COLUMN))
112
+ if any(bound):
113
+ if bound != (issuer, subject):
114
+ logger.warning(
115
+ "identity: %s row %s is bound to another identity; not re-binding by email",
116
+ user_table,
117
+ row.get("sys_id"),
118
+ )
119
+ return None
120
+ return row
121
+ tx.table(user_table).update(
122
+ {ISSUER_COLUMN: issuer, SUBJECT_COLUMN: subject}, {"sys_id": row["sys_id"]}
123
+ )
124
+ logger.info(
125
+ "identity: bound %s row %s to its sign-in (first sign-in by subject)",
126
+ user_table,
127
+ row.get("sys_id"),
128
+ )
129
+ row.update({ISSUER_COLUMN: issuer, SUBJECT_COLUMN: subject})
130
+ return row
131
+
132
+
133
+ def find_user(tx, user_table, issuer, subject, email=None, verified=False):
134
+ """The signed-in caller's user row: by ``issuer`` + ``subject``, else (on
135
+ the first sign-in after the switch) the row its verified email binds."""
136
+ return find_bound(tx, user_table, issuer, subject) or bind_by_verified_email(
137
+ tx, user_table, issuer, subject, email, verified
138
+ )
@@ -13,6 +13,8 @@ holds. This module is where those come from once a site keeps them as data
13
13
  ``sys_permission`` the catalog (:func:`velocity.aws.authz.catalog`),
14
14
  materialised so it is a table like any other
15
15
  ``sys_authz_state`` one counter, bumped by every change
16
+ ``sys_permission_notice`` when each person was last told about waiting
17
+ requests (:func:`waiting_notices`)
16
18
 
17
19
  **The rule.** Start with nothing. Add every *allow* from the user's current
18
20
  roles and direct grants. Remove everything matched by any *deny* — a deny
@@ -75,6 +77,7 @@ USER_ROLE_TABLE = "sys_user_role"
75
77
  USER_PERMISSION_TABLE = "sys_user_permission"
76
78
  REQUEST_TABLE = "sys_permission_request"
77
79
  STATE_TABLE = "sys_authz_state"
80
+ NOTICE_TABLE = "sys_permission_notice"
78
81
 
79
82
  # The column on the site's user table that carries the compiled set.
80
83
  USER_COLUMN = "permissions"
@@ -188,6 +191,23 @@ SCHEMA = {
188
191
  {"columns": ["status"], "name": "idx__sys_permission_request__status"},
189
192
  ],
190
193
  },
194
+ # when a person was last told about something (waiting_notices): one row
195
+ # per person and kind of notice, so "at most once a day" survives restarts
196
+ NOTICE_TABLE: {
197
+ "columns": {
198
+ "user_sys_id": "BIGINT",
199
+ "notice": "TEXT",
200
+ "sent_at": "TIMESTAMPTZ",
201
+ "sent_to": "TEXT",
202
+ "waiting": "INTEGER",
203
+ },
204
+ "unique_indexes": [
205
+ {
206
+ "columns": ["user_sys_id", "notice"],
207
+ "name": "idx__sys_permission_notice__user_notice",
208
+ }
209
+ ],
210
+ },
191
211
  }
192
212
 
193
213
  GRANT_TABLES = (
@@ -203,7 +223,7 @@ GRANT_TABLES = (
203
223
  # (``DataServiceMixin``): a row written around the guards would be a grant
204
224
  # nobody was allowed to give.
205
225
  PROTECTED_TABLES = frozenset(
206
- {STATE_TABLE, PERMISSION_TABLE, REQUEST_TABLE, *GRANT_TABLES}
226
+ {STATE_TABLE, PERMISSION_TABLE, REQUEST_TABLE, NOTICE_TABLE, *GRANT_TABLES}
207
227
  )
208
228
  authz.protect_tables(*PROTECTED_TABLES)
209
229
 
@@ -1801,3 +1821,171 @@ def requests(tx, *, status=None, user_sys_id=None) -> list:
1801
1821
  table = tx.table(REQUEST_TABLE)
1802
1822
  rows = _rows(table.select(where=where) if where else table.select())
1803
1823
  return sorted(rows, key=lambda row: row["sys_id"], reverse=True)
1824
+
1825
+
1826
+ # ── telling the people who decide ──────────────────────────────────────────
1827
+
1828
+ # the kind of notice (sys_permission_notice.notice) for requests waiting
1829
+ REQUESTS_WAITING = "requests_waiting"
1830
+ # nobody is told about waiting requests more often than this
1831
+ NOTICE_INTERVAL = datetime.timedelta(days=1)
1832
+ DEFAULT_LABEL_COLUMNS = ("full_name", "email_address")
1833
+
1834
+
1835
+ @dataclass(frozen=True)
1836
+ class WaitingNotice:
1837
+ """One person to tell that requests wait for them (:func:`waiting_notices`).
1838
+
1839
+ ``requests`` holds, oldest first, only what a notice may say: who asked
1840
+ (``person``), what for (``asked_for``: the role's name or the
1841
+ permission's label), where (``scope_type`` / ``scope_id``), and when
1842
+ (``requested_at``). Never the reason."""
1843
+
1844
+ user_sys_id: Any
1845
+ email: str
1846
+ name: str
1847
+ count: int
1848
+ oldest_at: Optional[datetime.datetime]
1849
+ requests: tuple
1850
+ notice: str = REQUESTS_WAITING
1851
+
1852
+
1853
+ def _person_label(row, columns):
1854
+ for column in columns:
1855
+ value = str((row or {}).get(column) or "").strip()
1856
+ if value:
1857
+ return value
1858
+ return f"User {(row or {}).get('sys_id')}"
1859
+
1860
+
1861
+ def _principal_of_row(row, principal_permissions=None):
1862
+ """The principal a request by this user would have (the same rule as
1863
+ :func:`velocity.aws.authz.principal_for`), never in a masquerade: a
1864
+ notice goes to the person themselves."""
1865
+ from types import SimpleNamespace
1866
+
1867
+ return authz.principal_for(
1868
+ SimpleNamespace(
1869
+ current_user=row,
1870
+ principal_permissions=principal_permissions,
1871
+ permission_source=TABLES,
1872
+ context=None,
1873
+ )
1874
+ )
1875
+
1876
+
1877
+ def waiting_notices(
1878
+ tx,
1879
+ user_table,
1880
+ *,
1881
+ email_column="email_address",
1882
+ label_columns=DEFAULT_LABEL_COLUMNS,
1883
+ include=None,
1884
+ principal_permissions=None,
1885
+ every=NOTICE_INTERVAL,
1886
+ now=None,
1887
+ ) -> list:
1888
+ """Who must be told that access requests are waiting, and what: one
1889
+ :class:`WaitingNotice` per person due one. Reads only; record each
1890
+ notice sent with :func:`record_notice`.
1891
+
1892
+ A person is told when they hold :data:`GRANT` the way deciding needs it
1893
+ (site-wide, or as a super user), at least one pending request is theirs
1894
+ to decide (not their own, a super user excepted, as
1895
+ :func:`decide_request` has it), they have an address in
1896
+ ``email_column``, ``include(row)`` (when given) says yes, and they were
1897
+ not told within ``every``. ``principal_permissions`` is the handler hook
1898
+ of the same name, for permissions a site's code hands out.
1899
+
1900
+ Nothing when the tables are not turned on or nothing waits. Nothing,
1901
+ with a warning, when :data:`NOTICE_TABLE` is missing: without it nobody
1902
+ could be told only once."""
1903
+ if not tables_present(tx):
1904
+ return []
1905
+ # oldest first
1906
+ pending = list(reversed(requests(tx, status=PENDING)))
1907
+ if not pending:
1908
+ return []
1909
+ notices = tx.table(NOTICE_TABLE)
1910
+ if not notices.exists():
1911
+ logger.warning(
1912
+ "permissions: %s is missing; nobody is told about waiting requests "
1913
+ "until the schema step creates it",
1914
+ NOTICE_TABLE,
1915
+ )
1916
+ return []
1917
+ now = _now(now)
1918
+ last = {
1919
+ row.get("user_sys_id"): _aware(row.get("sent_at"))
1920
+ for row in _rows(notices.select(where={"notice": REQUESTS_WAITING}))
1921
+ }
1922
+ people = {row.get("sys_id"): row for row in _rows(tx.table(user_table).select())}
1923
+ roles = {
1924
+ row["sys_id"]: row.get("name") for row in _rows(tx.table(ROLE_TABLE).select())
1925
+ }
1926
+ described = [
1927
+ {
1928
+ "sys_id": row["sys_id"],
1929
+ "user_sys_id": row.get("user_sys_id"),
1930
+ "person": _person_label(
1931
+ people.get(row.get("user_sys_id"))
1932
+ or {"sys_id": row.get("user_sys_id")},
1933
+ label_columns,
1934
+ ),
1935
+ "asked_for": (
1936
+ (roles.get(row.get("role_sys_id")) or f"Role {row.get('role_sys_id')}")
1937
+ if row.get("role_sys_id")
1938
+ else _label(row.get("permission"))
1939
+ ),
1940
+ "scope_type": row.get("scope_type"),
1941
+ "scope_id": row.get("scope_id"),
1942
+ "requested_at": _aware(row.get("requested_at")),
1943
+ }
1944
+ for row in pending
1945
+ ]
1946
+ due = []
1947
+ for user_sys_id, row in sorted(people.items(), key=lambda item: str(item[0])):
1948
+ principal = _principal_of_row(row, principal_permissions)
1949
+ if principal.user_sys_id is None or not principal.holds(GRANT):
1950
+ continue
1951
+ email = str(row.get(email_column) or "").strip()
1952
+ if not email or (include is not None and not include(row)):
1953
+ continue
1954
+ sent_at = last.get(principal.user_sys_id)
1955
+ if sent_at is not None and sent_at > now - every:
1956
+ continue
1957
+ theirs = tuple(
1958
+ entry
1959
+ for entry in described
1960
+ if principal.superuser or entry["user_sys_id"] != principal.user_sys_id
1961
+ )
1962
+ if not theirs:
1963
+ continue
1964
+ times = [e["requested_at"] for e in theirs if e["requested_at"] is not None]
1965
+ due.append(
1966
+ WaitingNotice(
1967
+ user_sys_id=principal.user_sys_id,
1968
+ email=email,
1969
+ name=_person_label(row, label_columns),
1970
+ count=len(theirs),
1971
+ oldest_at=min(times) if times else None,
1972
+ requests=theirs,
1973
+ )
1974
+ )
1975
+ return due
1976
+
1977
+
1978
+ def record_notice(tx, notice, *, now=None):
1979
+ """Record that ``notice`` (a :class:`WaitingNotice`) was sent, so the
1980
+ person is not told again within the interval. Call it in the
1981
+ transaction, in the same savepoint as the send."""
1982
+ tx.table(NOTICE_TABLE).merge(
1983
+ {
1984
+ "user_sys_id": notice.user_sys_id,
1985
+ "notice": notice.notice,
1986
+ "sent_at": _now(now),
1987
+ "sent_to": notice.email,
1988
+ "waiting": notice.count,
1989
+ },
1990
+ pk={"user_sys_id": notice.user_sys_id, "notice": notice.notice},
1991
+ )
velocity/misc/export.py CHANGED
@@ -1,13 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
- from datetime import date, datetime, time, timedelta
3
+ from datetime import date, datetime, time, timedelta, timezone, tzinfo
4
4
  from decimal import Decimal
5
5
  from io import BytesIO, StringIO
6
- from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Tuple
6
+ from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Tuple, Union
7
7
  import base64
8
8
  import csv
9
9
  import html
10
10
  import json
11
+ import logging
11
12
  import re
12
13
  import xml.etree.ElementTree as ET
13
14
 
@@ -18,6 +19,81 @@ if TYPE_CHECKING: # pragma: no cover
18
19
 
19
20
  NUMBER_FORMAT_RE = re.compile(r"[#0?][#0?,]*(?:\.[#0?]+)?")
20
21
 
22
+ # Display formats for native date/time cells in XLSX exports: human readable,
23
+ # 12-hour clock. The cell value stays a real Excel date/time.
24
+ TIME_FORMAT = "h:mm AM/PM"
25
+ DATE_FORMAT = "mmm d, yyyy"
26
+ DATETIME_FORMAT = "mmm d, yyyy h:mm AM/PM"
27
+
28
+
29
+ def _default_number_format(value) -> Optional[str]:
30
+ """The display format an XLSX data cell gets when its column has no
31
+ explicit one: ``time`` -> ``h:mm AM/PM``, ``date`` -> ``mmm d, yyyy``,
32
+ ``datetime`` -> ``mmm d, yyyy h:mm AM/PM``; ``None`` for anything else
33
+ (numbers, text, ``timedelta`` keep openpyxl's format)."""
34
+ if isinstance(value, datetime):
35
+ return DATETIME_FORMAT
36
+ if isinstance(value, date):
37
+ return DATE_FORMAT
38
+ if isinstance(value, time):
39
+ return TIME_FORMAT
40
+ return None
41
+
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ # A display zone for XLSX writers: a ``tzinfo``, an IANA name
46
+ # (``"America/Denver"``) or ``None`` (UTC).
47
+ DisplayZone = Union[tzinfo, str, None]
48
+
49
+
50
+ def resolve_display_timezone(zone: DisplayZone) -> tzinfo:
51
+ """The ``tzinfo`` an XLSX writer converts aware values to: ``zone``
52
+ itself when it is a ``tzinfo``, the IANA zone it names when it is a
53
+ string, else UTC. An unknown or blank name logs a warning and gives UTC:
54
+ a bad setting must not fail an export."""
55
+ if isinstance(zone, tzinfo):
56
+ return zone
57
+ if isinstance(zone, str) and zone.strip():
58
+ try:
59
+ from zoneinfo import ZoneInfo
60
+
61
+ return ZoneInfo(zone.strip())
62
+ except Exception: # ZoneInfoNotFoundError, ValueError on odd paths
63
+ logger.warning("unknown display timezone %r; using UTC", zone)
64
+ elif zone not in (None, ""):
65
+ logger.warning("display timezone %r is not a zone; using UTC", zone)
66
+ return timezone.utc
67
+
68
+
69
+ def _excel_value(value, zone: tzinfo):
70
+ """``value`` as an XLSX cell can hold it. Excel has no time zones, so
71
+ an aware ``datetime`` becomes the naive wall time in ``zone``, and an
72
+ aware ``time`` the wall time in ``zone`` on today's date. Everything
73
+ else, naive values included, is returned unchanged."""
74
+ if isinstance(value, datetime):
75
+ if value.utcoffset() is not None:
76
+ return value.astimezone(zone).replace(tzinfo=None)
77
+ return value
78
+ if isinstance(value, time) and value.tzinfo is not None:
79
+ moment = datetime.combine(date.today(), value)
80
+ if moment.utcoffset() is None: # a zone that needs a date to answer
81
+ return value.replace(tzinfo=None)
82
+ return moment.astimezone(zone).time().replace(tzinfo=None)
83
+ return value
84
+
85
+
86
+ def _excel_row(row, zone: tzinfo) -> list:
87
+ return [_excel_value(v, zone) for v in row]
88
+
89
+
90
+ def _format_data_row(ws) -> None:
91
+ """Apply :func:`_default_number_format` to the row just appended to ``ws``."""
92
+ for cell in ws[ws.max_row]:
93
+ code = _default_number_format(cell.value)
94
+ if code:
95
+ cell.number_format = code
96
+
21
97
 
22
98
  def extract(d: dict, keys: List[str]) -> List:
23
99
  """Extract values from a dictionary based on a list of keys."""
@@ -161,8 +237,17 @@ def create_spreadsheet(
161
237
  freeze_panes: str = "A2",
162
238
  dimensions: dict = None,
163
239
  auto_size: bool = True,
240
+ display_timezone: DisplayZone = None,
164
241
  ):
165
- """Create an Excel spreadsheet with specified headers, rows, and styles."""
242
+ """Create an Excel spreadsheet with specified headers, rows, and styles.
243
+
244
+ ``formats`` maps a cell reference (``"B2"``) to a number format. Native
245
+ ``date`` / ``time`` / ``datetime`` data cells otherwise display human
246
+ readable on a 12-hour clock (see :func:`_default_number_format`).
247
+ Timezone-aware values are written as the wall time in
248
+ ``display_timezone`` (a ``tzinfo`` or IANA name; UTC when ``None``),
249
+ since Excel cells carry no zone (see :func:`resolve_display_timezone`).
250
+ """
166
251
  import openpyxl
167
252
  from openpyxl.styles import Alignment, Border, Font, NamedStyle, Side
168
253
 
@@ -170,6 +255,7 @@ def create_spreadsheet(
170
255
  merge = merge or []
171
256
  formats = formats or {}
172
257
  named_styles = named_styles or []
258
+ zone = resolve_display_timezone(display_timezone)
173
259
 
174
260
  wb = openpyxl.Workbook()
175
261
  ws = wb.active
@@ -229,11 +315,13 @@ def create_spreadsheet(
229
315
  # Add headers and rows
230
316
  ws.append(headers)
231
317
  for row in rows:
232
- ws.append(row)
318
+ ws.append(_excel_row(row, zone))
319
+ _format_data_row(ws)
233
320
 
234
321
  # Set freeze panes
235
322
  ws.freeze_panes = freeze_panes
236
323
 
324
+ # explicit per-cell formats win over the date/time defaults
237
325
  for cell, format_code in formats.items():
238
326
  ws[cell].number_format = format_code
239
327
 
@@ -279,12 +367,18 @@ def sheet_title(name, used: set | None = None, fallback: str = "Sheet") -> str:
279
367
  return title
280
368
 
281
369
 
282
- def _sheet_cell(value):
370
+ def _sheet_cell(value, zone: tzinfo):
283
371
  # Decimal (every Postgres numeric) is written as a number cell.
284
- return float(value) if isinstance(value, Decimal) else value
372
+ if isinstance(value, Decimal):
373
+ return float(value)
374
+ return _excel_value(value, zone)
285
375
 
286
376
 
287
- def create_workbook(sheets: Iterable[Dict], fileorbuffer=None) -> Optional[bytes]:
377
+ def create_workbook(
378
+ sheets: Iterable[Dict],
379
+ fileorbuffer=None,
380
+ display_timezone: DisplayZone = None,
381
+ ) -> Optional[bytes]:
288
382
  """A multi-sheet XLSX: one worksheet per spec, in order.
289
383
 
290
384
  Each spec is a dict:
@@ -292,9 +386,15 @@ def create_workbook(sheets: Iterable[Dict], fileorbuffer=None) -> Optional[bytes
292
386
  - ``title`` (required) — made Excel-safe and unique (:func:`sheet_title`).
293
387
  - ``headers`` — the first row, bold unless ``bold_header`` is False.
294
388
  - ``rows`` — iterable of row value lists, in header order. ``Decimal`` is
295
- written as a number; other values as openpyxl writes them.
389
+ written as a number; other values as openpyxl writes them. Native
390
+ ``date`` / ``time`` / ``datetime`` cells display human readable on a
391
+ 12-hour clock (``Aug 17, 2026``, ``4:45 PM``, ``Aug 17, 2026 4:45 PM``)
392
+ unless their column has a ``number_formats`` entry. Timezone-aware
393
+ values are written as the wall time in ``display_timezone`` (a
394
+ ``tzinfo`` or IANA name; UTC when ``None``): Excel cells carry no zone.
296
395
  - ``number_formats`` — ``{header: format_code}`` applied to that column's
297
- data cells (e.g. ``{"Total Sales": "$#,##0.00"}``).
396
+ data cells (e.g. ``{"Total Sales": "$#,##0.00"}``); wins over the
397
+ date/time defaults.
298
398
  - ``freeze`` — freeze-panes cell, default ``"A2"`` (``None`` for none).
299
399
  - ``tab_color`` — hex colour of the sheet tab (``"#1072BA"`` or ``"1072BA"``).
300
400
  - ``autosize`` (default True), ``max_width``, ``min_width`` — passed to
@@ -306,6 +406,7 @@ def create_workbook(sheets: Iterable[Dict], fileorbuffer=None) -> Optional[bytes
306
406
  import openpyxl
307
407
  from openpyxl.styles import Font
308
408
 
409
+ zone = resolve_display_timezone(display_timezone)
309
410
  wb = openpyxl.Workbook()
310
411
  wb.remove(wb.active)
311
412
  used: set = set()
@@ -318,7 +419,8 @@ def create_workbook(sheets: Iterable[Dict], fileorbuffer=None) -> Optional[bytes
318
419
  for cell in ws[1]:
319
420
  cell.font = Font(bold=True)
320
421
  for row in spec.get("rows") or ():
321
- ws.append([_sheet_cell(v) for v in row])
422
+ ws.append([_sheet_cell(v, zone) for v in row])
423
+ _format_data_row(ws)
322
424
  formats = spec.get("number_formats") or {}
323
425
  if formats:
324
426
  first = 2 if headers else 1
@@ -361,6 +463,7 @@ def get_downloadable_spreadsheet(
361
463
  freeze_panes: str = "A2",
362
464
  dimensions: dict = None,
363
465
  auto_size: bool = True,
466
+ display_timezone: DisplayZone = None,
364
467
  ) -> str:
365
468
  """Generate a downloadable spreadsheet encoded in base64."""
366
469
  buffer = BytesIO()
@@ -375,6 +478,7 @@ def get_downloadable_spreadsheet(
375
478
  freeze_panes,
376
479
  dimensions,
377
480
  auto_size,
481
+ display_timezone=display_timezone,
378
482
  )
379
483
  return base64.b64encode(buffer.getvalue()).decode()
380
484
 
@@ -473,7 +577,12 @@ def _ods_document(headers, rows) -> bytes:
473
577
  return buf.getvalue()
474
578
 
475
579
 
476
- def _render(result_format: str, headers: List, rows: Iterable) -> bytes:
580
+ def _render(
581
+ result_format: str,
582
+ headers: List,
583
+ rows: Iterable,
584
+ display_timezone: DisplayZone = None,
585
+ ) -> bytes:
477
586
  if result_format in ("csv", "tsv"):
478
587
  buf = StringIO()
479
588
  writer = csv.writer(buf, delimiter="\t" if result_format == "tsv" else ",")
@@ -522,7 +631,9 @@ def _render(result_format: str, headers: List, rows: Iterable) -> bytes:
522
631
  return "\n".join(lines).encode("utf-8")
523
632
  if result_format == "excel":
524
633
  buf = BytesIO()
525
- create_spreadsheet(list(headers), list(rows), buf)
634
+ create_spreadsheet(
635
+ list(headers), list(rows), buf, display_timezone=display_timezone
636
+ )
526
637
  return buf.getvalue()
527
638
  if result_format == "xlsx":
528
639
  try:
@@ -531,11 +642,13 @@ def _render(result_format: str, headers: List, rows: Iterable) -> bytes:
531
642
  raise ImportError(
532
643
  "openpyxl is required for xlsx export (install velocity-python[excel])"
533
644
  )
645
+ zone = resolve_display_timezone(display_timezone)
534
646
  wb = Workbook()
535
647
  ws = wb.active
536
648
  ws.append(list(headers))
537
649
  for row in rows:
538
- ws.append(list(row))
650
+ ws.append(_excel_row(row, zone))
651
+ _format_data_row(ws)
539
652
  buf = BytesIO()
540
653
  wb.save(buf)
541
654
  return buf.getvalue()
@@ -545,7 +658,11 @@ def _render(result_format: str, headers: List, rows: Iterable) -> bytes:
545
658
 
546
659
 
547
660
  def build_buffer(
548
- result_format: str, headers: List, rows: Iterable, filename_base: str
661
+ result_format: str,
662
+ headers: List,
663
+ rows: Iterable,
664
+ filename_base: str,
665
+ display_timezone: DisplayZone = None,
549
666
  ) -> Optional[Tuple[bytes, str, str]]:
550
667
  """A file export as ``(bytes, filename, mime_type)``.
551
668
 
@@ -554,10 +671,15 @@ def build_buffer(
554
671
  ``headers`` are the column labels and ``rows`` the row value lists, in
555
672
  header order — for :data:`LINE_FORMATS` any iterable, consumed once.
556
673
  ``filename_base`` is used as given: sanitise client-supplied names first.
557
- HTML cell text is escaped and Markdown ``|`` is escaped.
674
+ HTML cell text is escaped and Markdown ``|`` is escaped. Excel/XLSX cells
675
+ stay native; date/time cells display human readable on a 12-hour clock,
676
+ and timezone-aware values are written as the wall time in
677
+ ``display_timezone`` (a ``tzinfo`` or IANA name; UTC when ``None``). The
678
+ text formats write values as given.
558
679
  """
559
680
  spec = EXPORT_FORMATS.get(result_format)
560
681
  if spec is None:
561
682
  return None
562
683
  extension, mime = spec
563
- return _render(result_format, headers, rows), f"{filename_base}.{extension}", mime
684
+ body = _render(result_format, headers, rows, display_timezone)
685
+ return body, f"{filename_base}.{extension}", mime
velocity/misc/format.py CHANGED
@@ -38,6 +38,35 @@ def currency(data: Union[None, str, float, decimal.Decimal]) -> str:
38
38
  return ""
39
39
 
40
40
 
41
+ def format_time_of_day(value) -> str:
42
+ """A time of day in standard 12-hour form: ``"4:45 PM"``.
43
+
44
+ Takes a ``datetime.time`` / ``datetime.datetime`` or text such as
45
+ ``"16:45"`` / ``"16:45:00"``. Blank (``None`` or ``""``) gives ``""``;
46
+ anything unreadable comes back as text, unchanged. No time zone is
47
+ applied: a time of day is a wall-clock value. The same rule as
48
+ velocity-react's ``formatTimeOfDay``, for what a server writes itself
49
+ (sentences, workbooks, PDFs); a value sent to a screen is better sent raw
50
+ and formatted there.
51
+ """
52
+ if value is None or value == "":
53
+ return ""
54
+ if isinstance(value, (time, datetime)):
55
+ hour, minute = value.hour, value.minute
56
+ else:
57
+ text = str(value).strip()
58
+ parts = text.split(":")
59
+ if len(parts) < 2 or len(parts[1]) < 2:
60
+ return text
61
+ try:
62
+ hour, minute = int(parts[0]), int(parts[1][:2])
63
+ except ValueError:
64
+ return text
65
+ if not (0 <= hour <= 23 and 0 <= minute <= 59):
66
+ return text
67
+ return f"{(hour + 11) % 12 + 1}:{minute:02d} {'AM' if hour < 12 else 'PM'}"
68
+
69
+
41
70
  def human_delta(tdelta: timedelta) -> str:
42
71
  """Formats a timedelta object into a human-readable format."""
43
72
  d = {"days": tdelta.days, "hrs": 0, "min": 0, "sec": 0}
@@ -1,11 +1,40 @@
1
1
  import unittest
2
2
  import decimal
3
3
  from datetime import datetime, date, time, timedelta
4
- from ..format import gallons, gallons2liters, currency, human_delta, to_json
4
+ from ..format import (
5
+ currency,
6
+ format_time_of_day,
7
+ gallons,
8
+ gallons2liters,
9
+ human_delta,
10
+ to_json,
11
+ )
5
12
 
6
13
 
7
14
  class TestYourModule(unittest.TestCase):
8
15
 
16
+ def test_format_time_of_day(self):
17
+ """A time of day in standard 12-hour form, never zone-shifted."""
18
+ cases = [
19
+ ("16:45", "4:45 PM"),
20
+ ("16:45:00", "4:45 PM"),
21
+ ("09:05", "9:05 AM"),
22
+ ("00:00", "12:00 AM"),
23
+ ("12:00", "12:00 PM"),
24
+ ("23:59:59", "11:59 PM"),
25
+ ]
26
+ for text, expected in cases:
27
+ with self.subTest(text=text):
28
+ self.assertEqual(format_time_of_day(text), expected)
29
+ self.assertEqual(format_time_of_day(time(17, 30)), "5:30 PM")
30
+ self.assertEqual(format_time_of_day(datetime(2026, 9, 7, 8, 0)), "8:00 AM")
31
+ # blank stays blank; anything unreadable comes back unchanged
32
+ self.assertEqual(format_time_of_day(None), "")
33
+ self.assertEqual(format_time_of_day(""), "")
34
+ for text in ("soon", "25:00", "9:5", "16"):
35
+ with self.subTest(text=text):
36
+ self.assertEqual(format_time_of_day(text), text)
37
+
9
38
  def test_gallons(self):
10
39
  """Tests the gallons function with various inputs."""
11
40
  self.assertEqual(gallons("10.5"), "10.50")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: velocity-python
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: A rapid application development library for interfacing with data storage
5
5
  Author-email: Velocity Team <info@codeclubs.org>
6
6
  License-Expression: MIT
@@ -1,4 +1,4 @@
1
- velocity/__init__.py,sha256=vPxGRTSBGj7ZKCOPBROmsFHLRQabf6Y6JEYuG4jpB6A,362
1
+ velocity/__init__.py,sha256=84gbnHEUDdcmEw4DdO9GRo7A5VBhALffvnNP7_LlCW8,362
2
2
  velocity/cache.py,sha256=pthvw7gDVEXRzkTcQtA5YVf4Q1IxAamWsba4UReT0RE,8933
3
3
  velocity/logging.py,sha256=MAsZap1cDSdOBNRc_9ezQxup5dHOS0ARxU9NI_-UK3E,4037
4
4
  velocity/aws/__init__.py,sha256=XJJ79gnPlhg3aJij713PB8-vjQOt-BkqxYeGCBOxAu8,3460
@@ -10,11 +10,12 @@ velocity/aws/email.py,sha256=CAvXTAUxJqMaT8ylpRwRcVEA4YLFMUuHCrwYLVE4G1E,6243
10
10
  velocity/aws/facets.py,sha256=vAXbp2PSjCTUzFpI84iUHvQoPXD1N-DJ6qIz9UhitFo,22574
11
11
  velocity/aws/failures.py,sha256=9H0yvo-mH2bfNytYAvDoocsOSjjGfDrRwwcAoB-SPrI,25172
12
12
  velocity/aws/feature_flags.py,sha256=G2S1Ifjjex1QEmaAnAwRISyawxfGTdzaKUh3150M5aw,9604
13
+ velocity/aws/identity.py,sha256=u6f_oaVhDa3tNatsQkeXZPJDhMfXCzeDo1zGoTqW_Yo,5547
13
14
  velocity/aws/job_lifecycle.py,sha256=6ltzdrkSjU_pY0eU6UaGbGh_1X5RhE5F4hypEH6MoRA,2735
14
15
  velocity/aws/job_results.py,sha256=qHu2FDWi9zdqbo0C6k7NYGy7KE6FECkATZ0KkdJyFS0,20332
15
16
  velocity/aws/mirror.py,sha256=8Ik1_GVCcD7E2nCe1ci6sp56PEVBMYZF-alkMFlP4O4,8858
16
17
  velocity/aws/ops.py,sha256=re8e3LkkQwgNS1ZjtaZMPnKyPbi4PuUC4YeS0wZwg_Q,18701
17
- velocity/aws/permissions.py,sha256=kJ4RlesxF3H-O6VPYJW8jwKClXteArwG3oGOmS2r4s8,67318
18
+ velocity/aws/permissions.py,sha256=peAJHQEWpP9wsm0i-Wq15E55Ze5w9CwAGVA5mmrZPtA,74152
18
19
  velocity/aws/queue_defaults.py,sha256=480V3pDvWBBamJ6hIJyVtPGJKMVmnP43otl3E9qnwfs,479
19
20
  velocity/aws/s3.py,sha256=Mn6rlK6sXykx32EjptFK2IRRBQZ9xijl9r33DUnKDzQ,30277
20
21
  velocity/aws/schema.py,sha256=6f6rdOAHfdYlq81CQtxXpYVcMeYUNDd5ch8UuSUgQeU,5143
@@ -47,25 +48,26 @@ velocity/aws/deploy/reconcile.py,sha256=3xZXXiiVeCw4nUV8lKyk1XCgRbpNCLVYXIXbOLXe
47
48
  velocity/aws/deploy/targets.py,sha256=vmZzGbLDKNtWkwd2ado_ZhT8AT6nXYbJD5nIgDiBzRc,6632
48
49
  velocity/aws/handlers/__init__.py,sha256=0yvVADxMHK8JOhgGRxMIz1XkNGlUk08AOmdR4qhNP2U,906
49
50
  velocity/aws/handlers/base_handler.py,sha256=xa1H5lS3CCxuM5_LXva2bmvWxUzxARIqkjnkp7bux3s,19143
50
- velocity/aws/handlers/context.py,sha256=tfotYkmRsIw6zp2Y2CLmhmu8kNmqL_GjbgXu-r3rnsk,46931
51
+ velocity/aws/handlers/context.py,sha256=7tfhzhidxWEJExVpu5Uz_BWbGlC7Xj0n8sqyr67f0jI,47135
51
52
  velocity/aws/handlers/context_factory.py,sha256=8rhYKrylzwQxBFqBckmF7jgWGZhfomLOT_Yu404CtJQ,784
52
53
  velocity/aws/handlers/exceptions.py,sha256=vChotaBjpPhvb_g058fGTnOG-vH1ZDMmHVwlj980DEg,2731
53
- velocity/aws/handlers/lambda_handler.py,sha256=MOQLaIjun4dbaZmwEyyg02rQlKBfeqxA2nk6youlhW8,18818
54
+ velocity/aws/handlers/lambda_handler.py,sha256=WfB_tBoLZsQREUR3ZS4pkLLwevqozjJsA_yfHBrcc5Y,21358
54
55
  velocity/aws/handlers/masquerade.py,sha256=ZMXGGB-w7pXRV8Fq0qBmJOL1Eq55hXP3Rq3dWjJk6Ck,5157
55
56
  velocity/aws/handlers/perf.py,sha256=zLAcr_IrvpnZS1zzzXi-YhopRgCv3OeetQgOx_S43Xw,1421
56
57
  velocity/aws/handlers/response.py,sha256=p2d2Omv2dH8aJ9ZO9vXsiCIAJRiLGmBpNmT-OPMLe-M,16996
57
58
  velocity/aws/handlers/sqs_handler.py,sha256=1A7pHnZ-gl6AGcdK5hWvNUg-s8IjRhrBeiQLacHHhHk,9669
58
- velocity/aws/handlers/mixins/__init__.py,sha256=j50JvNnil757XtoGcCADfojdPn-vio-PcjHJ9Reo2nk,1746
59
+ velocity/aws/handlers/mixins/__init__.py,sha256=xhVfpkqNQuDq2v0cQJFcUGmnwtFHEHeJb82vWS03kaw,1885
60
+ velocity/aws/handlers/mixins/action_names.py,sha256=6RYAdromGcDD9Fv6tYKAtgzLhKx8gf5oG-7K67roWqI,1112
59
61
  velocity/aws/handlers/mixins/audit_trail.py,sha256=D8BHuBfa2EXAcC4NtV_0Gie22zshhalcscf7L1MEDVw,16873
60
62
  velocity/aws/handlers/mixins/client_errors.py,sha256=0LqIRm1gZRwzHH32ravGHAgcQqJzjh5p6M_ptuL2Tto,20105
61
63
  velocity/aws/handlers/mixins/client_input.py,sha256=UhfwsRiztOYDrJnQPZhQ6J71NAmCyrXjMgxbko2rnfQ,24225
62
- velocity/aws/handlers/mixins/data_service.py,sha256=EobIrZiTX_IyxmogKLjtGfme3Ic8wmFiO6LeXFBpQFk,106397
64
+ velocity/aws/handlers/mixins/data_service.py,sha256=zJGgL1w70L9KqyYNPR2HehR-zvnAPruO_xCLrsxvfD8,108299
63
65
  velocity/aws/handlers/mixins/job_handler.py,sha256=yIbNcIg5pKXtZeQFNW7B4xqJJTjtjIxI8HRQsbMWixA,8095
64
- velocity/aws/handlers/mixins/permissions_actions.py,sha256=lAquZuFGKPd_yzHzfRNzSEd3Lyvaos6HxbhW5so3GJs,32194
66
+ velocity/aws/handlers/mixins/permissions_actions.py,sha256=KjWKu4in0OvczEqO4JpVkmBPL_C0I1qOAraBeW21XvI,32409
65
67
  velocity/aws/handlers/mixins/row_scoped.py,sha256=f7N70RjzlHIVyjDsEI1oImCdjyFsgoP9aIvYXUXD5-Q,12967
66
68
  velocity/aws/handlers/mixins/session_control.py,sha256=w2wGC_XiM-W-tL5mhZvVCt_GiQjE3Gao0Ut7tx6JdLk,12937
67
- velocity/aws/handlers/mixins/tiles_actions.py,sha256=MG1D85m9aCqD4etSTDkymXaJ9ZQACLhqBBkGjdxOoP0,27804
68
- velocity/aws/handlers/mixins/web_handler.py,sha256=6hA7GUSj_6y0U2fnhGFvuzHRH73RUOdvjxaH3ZZZ-Qg,36549
69
+ velocity/aws/handlers/mixins/tiles_actions.py,sha256=6476EeGZK1W-bRlFjzqu2WYaOPN6HuLfA3Zev9ZCPnc,27995
70
+ velocity/aws/handlers/mixins/web_handler.py,sha256=NYYdtNbMQyVkDEJVntEzrPVx6DYQ1E_lJaz7hn1Vj04,37044
69
71
  velocity/aws/tests/__init__.py,sha256=hk-efPTkFb_py5ouA5d1OuPVQzaa5Wa01Wsc1oQ9dhY,19
70
72
  velocity/aws/tests/test_base_handler_error_response.py,sha256=CgnTMzxGkd4ArUHvsuiO_iQsiQMSlZSX_fM0YrMVRrc,2408
71
73
  velocity/aws/tests/test_deploy_least_privilege.py,sha256=ShdsbNwl2qzPaeQp2ZcMqtWRSoIB0DM0GmX0EUGJOUk,15947
@@ -164,8 +166,8 @@ velocity/db/tests/sql/test_postgres_select_advanced.py,sha256=xODItdjxYBg1kdpmiE
164
166
  velocity/db/tests/sql/test_postgres_select_variances.py,sha256=F3d6TleZVtYnWndvEqeamvn5BteILyHfUAlUegiZMvA,17611
165
167
  velocity/misc/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
166
168
  velocity/misc/db.py,sha256=MPgt-kkukKR_Wh_S_5W-MyDgaeoZ4YLoDJ54wU2ppm4,2830
167
- velocity/misc/export.py,sha256=o_6fllHarvODQlrpvBnCnNlrc1b_48wTn9_it1dYp9g,19805
168
- velocity/misc/format.py,sha256=7RyEj85_5NJXAr9UdkNVdmkdw__D9g6hrcWBvvH2V9s,3182
169
+ velocity/misc/export.py,sha256=DbrhNFH3MQPA6DC222KSRhjwqQYrGnGYOtiz7zCfEac,24692
170
+ velocity/misc/format.py,sha256=YHkmeUeBPBaM3auwZYu3z4-qCGTfQiNJNC1CjaqDgs0,4332
169
171
  velocity/misc/mail.py,sha256=h-CbGVCNLpIol3PIkCPcySomTxJivBg0rDxZZhstNKQ,20097
170
172
  velocity/misc/merge.py,sha256=J5WIKWLs6t1jZg0BNiI0OJ962mYa16liHlBwxlFSmK8,2144
171
173
  velocity/misc/pdf.py,sha256=7tlD9bUVtfHp9buXRuYIPM86C5tu1hd-gmX5kKs_HbU,13453
@@ -177,7 +179,7 @@ velocity/misc/conv/oconv.py,sha256=tq6JVNQmK6YK62nO5FnnpnG4jgX0lQsvjD-W92wsK4I,6
177
179
  velocity/misc/tests/__init__.py,sha256=uUg-3PgXRGan21wF2k-1GG0WBCdVuwtoRrJZbz_yet0,20
178
180
  velocity/misc/tests/test_db.py,sha256=yGGthOlwzT2reXTHPxNfmmvUpAbQIt4-K9bhFYGylMs,2671
179
181
  velocity/misc/tests/test_fix.py,sha256=D63WZDKFm4oYkZL4dmotnvIPUmXMJveS8k1__7fcm-U,2293
180
- velocity/misc/tests/test_format.py,sha256=PeDtJo7n-3XJ0YaaZRj0DIedwnDK4k29W7_vJ49shac,2667
182
+ velocity/misc/tests/test_format.py,sha256=EQGbJidpuWBgzD4HOckf68t6TdJuSgVLJKBjB9qDQCk,3710
181
183
  velocity/misc/tests/test_iconv.py,sha256=lblHkTDr0yuTOlMuUCBwGRmz1G-ufsKn5xElZSnewq4,7119
182
184
  velocity/misc/tests/test_mail_send.py,sha256=Sa9Q4SRhxRG9gnFn7c2j0tb1XJofJ2gFHEW8tJi-u84,12528
183
185
  velocity/misc/tests/test_merge.py,sha256=Vm5_jY5cVczw0hZF-3TYzmxFw81heJOJB-dvhCgXh_k,3042
@@ -221,9 +223,9 @@ velocity/payment/stripe_adapter.py,sha256=6EmVBZZchg2s3Teya0eQGA_8pvSK_teizr036U
221
223
  velocity/payment/stripe_mirror.py,sha256=6GKJk_PjAgySqFEuxbcuV423OfuGXupVTiGu9QwS5W8,12320
222
224
  velocity/testing/__init__.py,sha256=WtYMDHgCLxOq6mlemcOYV5Y9HqF_2QQgSfuAjFRg69Q,731
223
225
  velocity/testing/fake_db.py,sha256=phgdulk5owf3noUDpEfI7yFsVcKxRGZ82f2BJPYnaiU,53226
224
- velocity_python-0.2.3.dist-info/licenses/LICENSE,sha256=aoN245GG8s9oRUU89KNiGTU4_4OtnNmVi4hQeChg6rM,1076
225
- velocity_python-0.2.3.dist-info/METADATA,sha256=MJ9lXm16krWeQ0drqTBqQykR2ZtqTKW9yZe9GdMi738,17782
226
- velocity_python-0.2.3.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
227
- velocity_python-0.2.3.dist-info/entry_points.txt,sha256=nXUKJHkOGQCSPoagdmMNd_qycrle1LVU-GhYknLGM1M,56
228
- velocity_python-0.2.3.dist-info/top_level.txt,sha256=JW2vJPmodgdgSz7H6yoZvnxF8S3fTMIv-YJWCT1sNW0,9
229
- velocity_python-0.2.3.dist-info/RECORD,,
226
+ velocity_python-0.2.4.dist-info/licenses/LICENSE,sha256=aoN245GG8s9oRUU89KNiGTU4_4OtnNmVi4hQeChg6rM,1076
227
+ velocity_python-0.2.4.dist-info/METADATA,sha256=wYu_9tM6EPj1sLFlthIKiPxjPin8VYr3h-TBzoteM5I,17782
228
+ velocity_python-0.2.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
229
+ velocity_python-0.2.4.dist-info/entry_points.txt,sha256=nXUKJHkOGQCSPoagdmMNd_qycrle1LVU-GhYknLGM1M,56
230
+ velocity_python-0.2.4.dist-info/top_level.txt,sha256=JW2vJPmodgdgSz7H6yoZvnxF8S3fTMIv-YJWCT1sNW0,9
231
+ velocity_python-0.2.4.dist-info/RECORD,,