MambuPy 2.3.2__tar.gz → 2.4.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 (98) hide show
  1. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/__init__.py +1 -1
  2. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/api/classes.py +18 -19
  3. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/api/connector/mambuconnector.py +31 -0
  4. {mambupy-2.3.2/mambupy → mambupy-2.4.0/MambuPy}/api/connector/rest.py +49 -48
  5. mambupy-2.4.0/MambuPy/api/connector/rest_helpers.py +63 -0
  6. {mambupy-2.3.2/mambupy → mambupy-2.4.0/MambuPy}/api/entities.py +13 -12
  7. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/api/mambuloan.py +62 -5
  8. {mambupy-2.3.2/mambupy → mambupy-2.4.0/MambuPy}/api/mambustruct.py +22 -4
  9. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/mambuutil.py +69 -9
  10. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy.egg-info/PKG-INFO +2 -1
  11. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy.egg-info/SOURCES.txt +2 -0
  12. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy.egg-info/requires.txt +1 -0
  13. {mambupy-2.3.2 → mambupy-2.4.0}/PKG-INFO +2 -1
  14. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/__init__.py +1 -1
  15. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/api/classes.py +18 -19
  16. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/api/connector/mambuconnector.py +31 -0
  17. {mambupy-2.3.2/MambuPy → mambupy-2.4.0/mambupy}/api/connector/rest.py +49 -48
  18. mambupy-2.4.0/mambupy/api/connector/rest_helpers.py +63 -0
  19. {mambupy-2.3.2/MambuPy → mambupy-2.4.0/mambupy}/api/entities.py +13 -12
  20. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/api/mambuloan.py +62 -5
  21. {mambupy-2.3.2/MambuPy → mambupy-2.4.0/mambupy}/api/mambustruct.py +22 -4
  22. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/mambuutil.py +69 -9
  23. {mambupy-2.3.2 → mambupy-2.4.0}/pyproject.toml +1 -0
  24. {mambupy-2.3.2 → mambupy-2.4.0}/LICENSE +0 -0
  25. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/api/__init__.py +0 -0
  26. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/api/connector/__init__.py +0 -0
  27. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/api/interfaces.py +0 -0
  28. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/api/mambubranch.py +0 -0
  29. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/api/mambucentre.py +0 -0
  30. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/api/mambuclient.py +0 -0
  31. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/api/mambucustomfield.py +0 -0
  32. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/api/mambugroup.py +0 -0
  33. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/api/mambuproduct.py +0 -0
  34. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/api/mamburole.py +0 -0
  35. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/api/mambutask.py +0 -0
  36. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/api/mambutransaction.py +0 -0
  37. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/api/mambuuser.py +0 -0
  38. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/api/vos.py +0 -0
  39. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/mambuconfig.py +0 -0
  40. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/mambugeturl.py +0 -0
  41. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/orm/__init__.py +0 -0
  42. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/orm/schema_activities.py +0 -0
  43. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/orm/schema_addresses.py +0 -0
  44. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/orm/schema_branches.py +0 -0
  45. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/orm/schema_centres.py +0 -0
  46. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/orm/schema_clients.py +0 -0
  47. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/orm/schema_customfields.py +0 -0
  48. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/orm/schema_dummies.py +0 -0
  49. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/orm/schema_groups.py +0 -0
  50. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/orm/schema_loans.py +0 -0
  51. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/orm/schema_mambu.py +0 -0
  52. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/orm/schema_orm.py +0 -0
  53. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/orm/schema_tasks.py +0 -0
  54. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/orm/schema_users.py +0 -0
  55. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/rest/__init__.py +0 -0
  56. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/rest/mambuactivity.py +0 -0
  57. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/utils/__init__.py +0 -0
  58. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/utils/update_user_roles.py +0 -0
  59. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy/utils/userdeactivate.py +0 -0
  60. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy.egg-info/dependency_links.txt +0 -0
  61. {mambupy-2.3.2 → mambupy-2.4.0}/MambuPy.egg-info/top_level.txt +0 -0
  62. {mambupy-2.3.2 → mambupy-2.4.0}/README.md +0 -0
  63. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/api/__init__.py +0 -0
  64. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/api/connector/__init__.py +0 -0
  65. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/api/interfaces.py +0 -0
  66. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/api/mambubranch.py +0 -0
  67. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/api/mambucentre.py +0 -0
  68. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/api/mambuclient.py +0 -0
  69. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/api/mambucustomfield.py +0 -0
  70. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/api/mambugroup.py +0 -0
  71. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/api/mambuproduct.py +0 -0
  72. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/api/mamburole.py +0 -0
  73. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/api/mambutask.py +0 -0
  74. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/api/mambutransaction.py +0 -0
  75. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/api/mambuuser.py +0 -0
  76. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/api/vos.py +0 -0
  77. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/mambuconfig.py +0 -0
  78. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/mambugeturl.py +0 -0
  79. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/orm/__init__.py +0 -0
  80. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/orm/schema_activities.py +0 -0
  81. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/orm/schema_addresses.py +0 -0
  82. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/orm/schema_branches.py +0 -0
  83. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/orm/schema_centres.py +0 -0
  84. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/orm/schema_clients.py +0 -0
  85. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/orm/schema_customfields.py +0 -0
  86. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/orm/schema_dummies.py +0 -0
  87. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/orm/schema_groups.py +0 -0
  88. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/orm/schema_loans.py +0 -0
  89. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/orm/schema_mambu.py +0 -0
  90. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/orm/schema_orm.py +0 -0
  91. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/orm/schema_tasks.py +0 -0
  92. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/orm/schema_users.py +0 -0
  93. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/rest/__init__.py +0 -0
  94. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/rest/mambuactivity.py +0 -0
  95. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/utils/__init__.py +0 -0
  96. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/utils/update_user_roles.py +0 -0
  97. {mambupy-2.3.2 → mambupy-2.4.0}/mambupy/utils/userdeactivate.py +0 -0
  98. {mambupy-2.3.2 → mambupy-2.4.0}/setup.cfg +0 -0
@@ -37,7 +37,7 @@ import sys
37
37
  from importlib.abc import MetaPathFinder
38
38
  from importlib.util import spec_from_file_location
39
39
 
40
- __version__ = "2.3.2"
40
+ __version__ = "2.4.0"
41
41
  """The version of this module."""
42
42
 
43
43
 
@@ -31,32 +31,31 @@ class MambuMapObj:
31
31
  for key, val in kwargs.items():
32
32
  self._attrs[key] = val
33
33
 
34
- def __getattribute__(self, name):
34
+ def __getattr__(self, name):
35
35
  """Object-like get attribute
36
36
 
37
37
  When accessing an attribute, tries to find it in the _attrs
38
38
  dictionary, so now MambuMapObj may act not only as a dict-like
39
39
  structure, but as a full object-like too (this is the getter
40
40
  side).
41
+
42
+ Implemented as __getattr__ (not __getattribute__) so it only runs
43
+ after the normal attribute lookup already failed: a real
44
+ attribute/method always wins, and successful accesses keep the
45
+ native fast path instead of being intercepted on every access.
41
46
  """
42
- try:
43
- # first, try to read 'name' as if it's a property of the object
44
- # if it doesn't exists as property, AttributeError raises
45
- return object.__getattribute__(self, name)
46
- except AttributeError:
47
- # try to read the _attrs property
48
- _attrs = object.__getattribute__(self, "_attrs")
49
- if isinstance(_attrs, list) or name not in _attrs:
50
- # magic won't happen when not a dict-like MambuMapObj or
51
- # when _attrs has not the 'name' key (this last one means
52
- # that if 'name' is not a property of the object too,
53
- # AttributeError will raise by default)
54
- return object.__getattribute__(self, name)
55
- # all else, read the property from the _attrs dict, but with a . syntax
56
- # if a cf_class, just return its value
57
- if _attrs[name].__class__.__name__ == self._cf_class.__name__:
58
- return _attrs[name]["value"]
59
- return _attrs[name]
47
+ # try to read the _attrs property
48
+ _attrs = object.__getattribute__(self, "_attrs")
49
+ if isinstance(_attrs, list) or name not in _attrs:
50
+ # magic won't happen when not a dict-like MambuMapObj or
51
+ # when _attrs has not the 'name' key: this is a genuine
52
+ # missing attribute, so AttributeError raises by default
53
+ raise AttributeError(name)
54
+ # all else, read the property from the _attrs dict, but with a . syntax
55
+ # if a cf_class, just return its value
56
+ if _attrs[name].__class__.__name__ == self._cf_class.__name__:
57
+ return _attrs[name]["value"]
58
+ return _attrs[name]
60
59
 
61
60
  def __setattr__(self, name, value):
62
61
  """Object-like set attribute
@@ -286,6 +286,37 @@ class MambuConnectorWriter(ABC):
286
286
  """
287
287
  raise NotImplementedError
288
288
 
289
+ @abstractmethod
290
+ def mambu_loanaccount_updateSchedule(self, loanid, installments):
291
+ """Updates the installments schedule of a loan account
292
+
293
+ Mambu replaces the whole schedule with the given installments, so the
294
+ complete list must be sent, not just the modified ones.
295
+
296
+ Args:
297
+ loanid (str): the id or encoded key of the loan account
298
+ installments (list of dict): the installments of the schedule,
299
+ complying with Mambu's schema
300
+ """
301
+ raise NotImplementedError
302
+
303
+ @abstractmethod
304
+ def mambu_loanaccount_changeDueDatesSettings(
305
+ self, loanid, fixedDaysOfMonth, notes=None, valueDate=None
306
+ ):
307
+ """Changes the due dates settings of a loan account
308
+
309
+ Args:
310
+ loanid (str): the id or encoded key of the loan account
311
+ fixedDaysOfMonth (list of int): the new fixed days of month to be used
312
+ on the loan account
313
+ notes (str): notes to associate to the change due dates settings
314
+ operation in Mambu
315
+ valueDate (str or :py:obj:`datetime`): the date when to change the due
316
+ dates settings
317
+ """
318
+ raise NotImplementedError
319
+
289
320
  @abstractmethod
290
321
  def mambu_loantransaction_adjust(self, transactionid, notes):
291
322
  """Adjust a loan transaction
@@ -9,21 +9,18 @@ Currently supports REST.
9
9
 
10
10
  import base64
11
11
  import copy
12
- import datetime
13
- import enum
14
12
  import json
15
13
  import mimetypes
14
+ import orjson
16
15
  import os
17
16
  import re
18
17
  import uuid
19
- from decimal import Decimal
20
18
 
21
19
  import requests
22
20
  from requests_toolbelt.multipart.encoder import MultipartEncoder
23
- from requests.adapters import HTTPAdapter
24
- from requests.packages.urllib3.util.retry import Retry
25
21
 
26
22
  from .mambuconnector import MambuConnector, MambuConnectorReader, MambuConnectorWriter
23
+ from .rest_helpers import _MambuJSONEncoder, _configure_retry_strategy
27
24
  from MambuPy.mambuutil import (
28
25
  ALLOWED_UPLOAD_MIMETYPES,
29
26
  DETAILSLEVEL,
@@ -46,46 +43,6 @@ from MambuPy.mambuutil import (
46
43
  logger = setup_logging(__name__)
47
44
 
48
45
 
49
- class _MambuJSONEncoder(json.JSONEncoder):
50
- """JSON encoder that handles Python types not natively serializable by json.dumps."""
51
-
52
- def default(self, obj):
53
- if isinstance(obj, (datetime.datetime, datetime.date)):
54
- return obj.isoformat()
55
- if isinstance(obj, enum.Enum):
56
- return obj.value
57
- if isinstance(obj, Decimal):
58
- return float(obj)
59
- return super().default(obj)
60
-
61
-
62
- def _configure_retry_strategy(session, retries=5):
63
- """Configure retry strategy for a session.
64
-
65
- Args:
66
- session (requests.Session): The session to configure
67
- retries (int, optional): Number of retries. Defaults to 5.
68
- """
69
- retry_strategy = Retry(
70
- total=retries,
71
- status_forcelist=[429, 500, 502, 503, 504],
72
- backoff_factor=1,
73
- allowed_methods=[
74
- "HEAD",
75
- "GET",
76
- "OPTIONS",
77
- "POST",
78
- "PUT",
79
- "DELETE",
80
- "TRACE",
81
- "PATCH",
82
- ],
83
- )
84
- adapter = HTTPAdapter(max_retries=retry_strategy)
85
- session.mount("https://", adapter)
86
- session.mount("http://", adapter)
87
-
88
-
89
46
  class SessionSingleton:
90
47
  """Singleton class to manage HTTP sessions for MambuPy.
91
48
 
@@ -224,7 +181,7 @@ url %s, params %s, data %s, headers %s",
224
181
  if hasattr(resp, "content"): # pragma: no cover
225
182
  logger.warning("HTTPError, resp content: %s", resp.content)
226
183
  try:
227
- content = json.loads(resp.content.decode())
184
+ content = orjson.loads(resp.content.decode())
228
185
  except ValueError:
229
186
  # in case resp.content doesn't conforms to json
230
187
  content = {
@@ -300,7 +257,7 @@ url %s, params %s, data %s, headers %s",
300
257
  def __list_request_cat_response(self, list_resp, resp):
301
258
  if list_resp == b"":
302
259
  list_resp = resp
303
- elif len(json.loads(resp.decode())) > 0:
260
+ elif len(orjson.loads(resp.decode())) > 0:
304
261
  list_resp = list_resp[:-1] + b"," + resp[1:]
305
262
 
306
263
  return list_resp
@@ -340,7 +297,7 @@ url %s, params %s, data %s, headers %s",
340
297
  method, url, params=copy.copy(params), data=copy.copy(data)
341
298
  )
342
299
 
343
- jsonresp = list(json.loads(resp.decode()))
300
+ jsonresp = list(orjson.loads(resp.decode()))
344
301
  if len(jsonresp) < limit:
345
302
  window = False
346
303
 
@@ -765,6 +722,50 @@ url %s, params %s, data %s, headers %s",
765
722
  data = {"notes": notes}
766
723
  return self.__request("POST", url, data=data)
767
724
 
725
+ def mambu_loanaccount_updateSchedule(self, loanid, installments):
726
+ """Updates the installments schedule of a loan account
727
+
728
+ Mambu replaces the whole schedule with the given installments, so the
729
+ complete list must be sent, not just the modified ones.
730
+
731
+ Args:
732
+ loanid (str): the id or encoded key of the loan account
733
+ installments (list of dict): the installments of the schedule,
734
+ complying with Mambu's schema
735
+ """
736
+ url = "https://{}/api/{}/{}/schedule".format(self._tenant, "loans", loanid)
737
+
738
+ return self.__request("PUT", url, data=installments)
739
+
740
+ def mambu_loanaccount_changeDueDatesSettings(
741
+ self, loanid, fixedDaysOfMonth, notes=None, valueDate=None
742
+ ):
743
+ """Changes the due dates settings of a loan account
744
+
745
+ Only available for loan accounts whose product uses the
746
+ FIXED_DAYS_OF_MONTH repayment methodology. Mambu rejects the request
747
+ otherwise.
748
+
749
+ Args:
750
+ loanid (str): the id or encoded key of the loan account
751
+ fixedDaysOfMonth (list of int): the new fixed days of month to be used
752
+ on the loan account
753
+ notes (str): notes to associate to the change due dates settings
754
+ operation in Mambu
755
+ valueDate (str or :py:obj:`datetime`): the date when to change the due
756
+ dates settings
757
+ """
758
+ url = "https://{}/api/{}/{}:changeDueDatesSettings".format(
759
+ self._tenant, "loans", loanid
760
+ )
761
+ data = {"fixedDaysOfMonth": fixedDaysOfMonth}
762
+ if notes is not None:
763
+ data["notes"] = notes
764
+ if valueDate is not None:
765
+ data["valueDate"] = valueDate
766
+
767
+ return self.__request("POST", url, data=data)
768
+
768
769
  def mambu_change_state(self, entid, prefix, action, notes):
769
770
  """change state of mambu entity
770
771
 
@@ -0,0 +1,63 @@
1
+ """Helpers for the REST connector.
2
+
3
+ .. autosummary::
4
+ :nosignatures:
5
+ :toctree: _autosummary
6
+ """
7
+
8
+ import datetime
9
+ import enum
10
+ import json
11
+ from decimal import Decimal
12
+
13
+ from requests.adapters import HTTPAdapter
14
+ from requests.packages.urllib3.util.retry import Retry
15
+
16
+
17
+ class _MambuJSONEncoder(json.JSONEncoder):
18
+ """JSON encoder that handles Python types not natively serializable by json.dumps."""
19
+
20
+ def default(self, obj):
21
+ if isinstance(obj, (datetime.datetime, datetime.date)):
22
+ return obj.isoformat()
23
+ if isinstance(obj, enum.Enum):
24
+ return obj.value
25
+ if isinstance(obj, Decimal):
26
+ return float(obj)
27
+ return super().default(obj)
28
+
29
+
30
+ def _configure_retry_strategy(session, retries=5):
31
+ """Configure retry strategy for a session.
32
+
33
+ El HTTPAdapter se construye con pool_maxsize=50 para que los flujos
34
+ que despachan multiples requests concurrentes contra Mambu (p.ej.
35
+ fan-out de aprobaciones, cargar_datos paralelo) no queden encolados
36
+ sobre el default de urllib3 de 10 conexiones por host.
37
+
38
+ Args:
39
+ session (requests.Session): The session to configure
40
+ retries (int, optional): Number of retries. Defaults to 5.
41
+ """
42
+ retry_strategy = Retry(
43
+ total=retries,
44
+ status_forcelist=[429, 500, 502, 503, 504],
45
+ backoff_factor=1,
46
+ allowed_methods=[
47
+ "HEAD",
48
+ "GET",
49
+ "OPTIONS",
50
+ "POST",
51
+ "PUT",
52
+ "DELETE",
53
+ "TRACE",
54
+ "PATCH",
55
+ ],
56
+ )
57
+ adapter = HTTPAdapter(
58
+ max_retries=retry_strategy,
59
+ pool_connections=50,
60
+ pool_maxsize=50,
61
+ )
62
+ session.mount("https://", adapter)
63
+ session.mount("http://", adapter)
@@ -10,6 +10,7 @@ import datetime
10
10
  import enum
11
11
  from importlib import import_module
12
12
  import json
13
+ import orjson
13
14
  import time
14
15
 
15
16
  from .classes import GenericClass
@@ -240,7 +241,7 @@ class MambuEntity(MambuStruct):
240
241
 
241
242
  logger.debug("request several entities %s", cls.__name__)
242
243
  list_resp = get_func(prefix, **params)
243
- jsonresp = list(json.loads(list_resp.decode()))
244
+ jsonresp = list(orjson.loads(list_resp.decode()))
244
245
  logger.debug("%s, %s retrieved", cls.__name__, len(jsonresp))
245
246
 
246
247
  elements = []
@@ -312,8 +313,8 @@ class MambuEntity(MambuStruct):
312
313
  instance = cls.__build_object(
313
314
  connector=connector,
314
315
  resp=resp,
315
- attrs=dict(json.loads(resp.decode())),
316
- tzattrs=dict(json.loads(resp.decode())),
316
+ attrs=dict(orjson.loads(resp.decode())),
317
+ tzattrs=dict(orjson.loads(resp.decode())),
317
318
  get_entities=get_entities,
318
319
  detailsLevel=detailsLevel,
319
320
  debug=debug,
@@ -355,8 +356,8 @@ class MambuEntity(MambuStruct):
355
356
  self.id, prefix=self._prefix, detailsLevel=detailsLevel
356
357
  )
357
358
 
358
- self._attrs.update(dict(json.loads(self._resp.decode())))
359
- self._tzattrs = dict(json.loads(self._resp.decode()))
359
+ self._attrs.update(dict(orjson.loads(self._resp.decode())))
360
+ self._tzattrs = dict(orjson.loads(self._resp.decode()))
360
361
  self._convertDict2Attrs()
361
362
  self._extractCustomFields()
362
363
  self._extractVOs()
@@ -472,8 +473,8 @@ class MambuEntityWritable(MambuStruct, MambuWritable):
472
473
  self._resp = self._connector.mambu_create(
473
474
  self._prefix, copy.deepcopy(self._attrs), **kwargs
474
475
  )
475
- self._attrs.update(dict(json.loads(self._resp.decode())))
476
- self._tzattrs = dict(json.loads(self._resp.decode()))
476
+ self._attrs.update(dict(orjson.loads(self._resp.decode())))
477
+ self._tzattrs = dict(orjson.loads(self._resp.decode()))
477
478
  self._detailsLevel = "FULL"
478
479
  except MambuError:
479
480
  raise
@@ -573,7 +574,7 @@ class MambuEntityWritable(MambuStruct, MambuWritable):
573
574
  # build fields_ops param with what detected using previous rules
574
575
  # strings: (OP, PATH) (remove) or (OP, PATH, VALUE) (all else)
575
576
  fields_ops = []
576
- original_attrs = dict(json.loads(self._resp.decode()))
577
+ original_attrs = dict(orjson.loads(self._resp.decode()))
577
578
  self._extractCustomFields(original_attrs)
578
579
  self._updateVOs()
579
580
  for field in fields:
@@ -709,7 +710,7 @@ class MambuEntityAttachable(MambuStruct, MambuAttachable):
709
710
  notes=notes,
710
711
  )
711
712
 
712
- doc = MambuDocument(**dict(json.loads(response.decode())))
713
+ doc = MambuDocument(**dict(orjson.loads(response.decode())))
713
714
  self._attachments[str(doc["id"])] = doc
714
715
 
715
716
  return response
@@ -742,7 +743,7 @@ class MambuEntityAttachable(MambuStruct, MambuAttachable):
742
743
  paginationDetails=paginationDetails,
743
744
  )
744
745
 
745
- metadata_list = json.loads(response.decode())
746
+ metadata_list = orjson.loads(response.decode())
746
747
 
747
748
  for metadata in metadata_list:
748
749
  doc = MambuDocument(**metadata)
@@ -831,7 +832,7 @@ class MambuEntityCommentable(MambuStruct, MambuCommentable):
831
832
  paginationDetails=paginationDetails,
832
833
  )
833
834
 
834
- comments_list = json.loads(response.decode())
835
+ comments_list = orjson.loads(response.decode())
835
836
 
836
837
  for comment in comments_list:
837
838
  comm = MambuComment(**comment)
@@ -854,7 +855,7 @@ class MambuEntityCommentable(MambuStruct, MambuCommentable):
854
855
  owner_id=self.id, owner_type=self._ownerType, text=comment
855
856
  )
856
857
 
857
- comment = MambuComment(**dict(json.loads(response.decode())))
858
+ comment = MambuComment(**dict(orjson.loads(response.decode())))
858
859
  self._comments.insert(0, comment)
859
860
 
860
861
  return response
@@ -8,6 +8,7 @@
8
8
  import copy
9
9
  import datetime
10
10
  import json
11
+ import orjson
11
12
 
12
13
  from .entities import (
13
14
  MambuEntity,
@@ -113,11 +114,13 @@ class MambuLoan(
113
114
  except KeyError:
114
115
  pass
115
116
 
116
- def get_schedule(self, **kwargs):
117
- """Retrieves the installments schedule."""
118
- resp = self._connector.mambu_loanaccount_getSchedule(self.id)
117
+ def _hydrate_schedule(self, resp):
118
+ """Builds the schedule of MambuInstallments from a Mambu response.
119
119
 
120
- installments = json.loads(resp.decode())["installments"]
120
+ Args:
121
+ resp (bytes): a Mambu response holding an installments list.
122
+ """
123
+ installments = orjson.loads(resp.decode())["installments"]
121
124
 
122
125
  self.schedule = []
123
126
  for installment in installments:
@@ -126,6 +129,38 @@ class MambuLoan(
126
129
  installment_entity._convertDict2Attrs()
127
130
  self.schedule.append(installment_entity)
128
131
 
132
+ def get_schedule(self, **kwargs):
133
+ """Retrieves the installments schedule."""
134
+ resp = self._connector.mambu_loanaccount_getSchedule(self.id)
135
+
136
+ self._hydrate_schedule(resp)
137
+
138
+ def update_schedule(self, installments=None, **kwargs):
139
+ """Request to update the installments schedule of a loan account.
140
+
141
+ Mambu replaces the whole schedule with the installments sent, so the
142
+ complete list must be given, not just the modified ones. Sending a
143
+ subset means deleting the rest, which Mambu rejects for accounts on
144
+ non dynamic term products.
145
+
146
+ Args:
147
+ installments (list of :py:obj:`MambuInstallment`): the schedule to
148
+ send. Defaults to the schedule currently held by this
149
+ account, as retrieved by `get_schedule`.
150
+ """
151
+ if installments is None:
152
+ installments = self.schedule
153
+
154
+ payload = []
155
+ for installment in installments:
156
+ serialized = copy.deepcopy(installment)
157
+ serialized._serializeFields()
158
+ payload.append(serialized._attrs)
159
+
160
+ resp = self._connector.mambu_loanaccount_updateSchedule(self.id, payload)
161
+
162
+ self._hydrate_schedule(resp)
163
+
129
164
  def get_transactions(self, **kwargs):
130
165
  """Retrieves the transactions of the loan"""
131
166
  self.transactions = MambuTransaction.get_all(self.id)
@@ -144,7 +179,7 @@ class MambuLoan(
144
179
  resp = self._connector.mambu_change_state(
145
180
  entid=self.id, prefix=self._prefix, action=action, notes=notes,
146
181
  )
147
- resp = json.loads(resp)
182
+ resp = orjson.loads(resp)
148
183
  self.accountState = resp["accountState"]
149
184
  else:
150
185
  raise MambuPyError(
@@ -240,6 +275,28 @@ class MambuLoan(
240
275
  self._connector.mambu_loanaccount_writeoff(self.id, notes)
241
276
  self.refresh()
242
277
 
278
+ def change_due_dates_settings(
279
+ self, fixedDaysOfMonth, notes=None, valueDate=None, **kwargs
280
+ ):
281
+ """Request to change the due dates settings of a loan account.
282
+
283
+ Only available for loan accounts whose product uses the
284
+ FIXED_DAYS_OF_MONTH repayment methodology. Mambu rejects the request
285
+ otherwise, raising a `MambuError`.
286
+
287
+ Args:
288
+ fixedDaysOfMonth (list of int): the new fixed days of month to be used
289
+ on the loan account.
290
+ notes (str): notes to attach to the change due dates settings
291
+ operation.
292
+ valueDate (str or :py:obj:`datetime`): the date when to change the due
293
+ dates settings.
294
+ """
295
+ self._connector.mambu_loanaccount_changeDueDatesSettings(
296
+ self.id, fixedDaysOfMonth, notes, valueDate
297
+ )
298
+ self.refresh()
299
+
243
300
  def repay(self, amount, notes, valueDate, **kwargs):
244
301
  """Request to repay a loan account.
245
302
 
@@ -6,7 +6,7 @@
6
6
  """
7
7
 
8
8
  import copy
9
- from datetime import datetime
9
+ from datetime import datetime, timezone
10
10
  from importlib import import_module
11
11
 
12
12
  from .classes import MambuMapObj
@@ -101,15 +101,21 @@ class MambuStruct(MambuMapObj):
101
101
  return self.__getattribute_for_loop(ent, entity.value, entity.mcf)
102
102
  return self.__getattribute_for_one_cf(ent, entity.value, entity.mcf)
103
103
 
104
- def __getattribute__(self, name):
104
+ def __getattr__(self, name):
105
105
  """Object-like get attribute for MambuStructs.
106
106
 
107
107
  If the attribute is not present at the _attrs dict, tries to build a
108
108
  function that calls :py:meth:`getEntities`, which in turns will try
109
109
  to instantiate an entity according to the requested attribute name
110
+
111
+ Implemented as __getattr__ (not __getattribute__) so it only runs
112
+ after the normal lookup failed. We first delegate to the base
113
+ __getattr__ (the _attrs resolution), preserving precedence of
114
+ _attrs over the dynamic get_* magic, and only fall back to building
115
+ a getter when that raises AttributeError.
110
116
  """
111
117
  try:
112
- return super().__getattribute__(name)
118
+ return super().__getattr__(name)
113
119
  except AttributeError as attr_err:
114
120
  if name[0:4] == "get_" and len(name) > 4:
115
121
  ent = name[4:]
@@ -283,7 +289,19 @@ class MambuStruct(MambuMapObj):
283
289
  if isinstance(data, datetime):
284
290
  data_asdate = data.isoformat()
285
291
  if tzdata: # no tzdata means a date (no time) object
286
- data_asdate += tzdata[-6:]
292
+ if self._as_utc:
293
+ # _convertDict2Attrs moved this datetime to UTC and dropped
294
+ # its tzinfo, so appending the original offset would shift
295
+ # the instant. Convert back to that offset instead.
296
+ offset_str = tzdata[3:] or "+00:00"
297
+ tz_info = datetime.fromisoformat(
298
+ "2000-01-01T00:00:00" + offset_str
299
+ ).tzinfo
300
+ data_asdate = (
301
+ data.replace(tzinfo=timezone.utc).astimezone(tz_info).isoformat()
302
+ )
303
+ else:
304
+ data_asdate += tzdata[-6:]
287
305
  else:
288
306
  data_asdate = data_asdate[:10]
289
307
  return data_asdate
@@ -321,13 +321,46 @@ def _backup_db_previous_prep(callback, logger, kwargs):
321
321
  data = {"callback": callback}
322
322
  list_ret.append(data)
323
323
 
324
+ try:
325
+ backup_in_progress_wait = kwargs["backup_in_progress_wait"]
326
+ except KeyError:
327
+ backup_in_progress_wait = 900
328
+ list_ret.append(backup_in_progress_wait)
329
+
330
+ try:
331
+ backup_in_progress_retries = kwargs["backup_in_progress_retries"]
332
+ except KeyError:
333
+ backup_in_progress_retries = 3
334
+ list_ret.append(backup_in_progress_retries)
335
+
324
336
  return list_ret
325
337
 
326
338
 
327
- def _backup_db_request(justbackup, data, user, pwd, logger=None):
339
+ def _backup_db_in_progress(resp):
340
+ """True if Mambu rejected the backup POST because one is already running.
341
+
342
+ Mambu answers with errorCode 1201 / DATABASE_BACKUP_IN_PROGRESS when a
343
+ database backup job is already in progress. That is a transient, retryable
344
+ condition (the running job finishes in ~14 min), not a real failure.
345
+ """
328
346
  try:
329
- if not justbackup:
330
- posturl = iri_to_uri(getmambuurl() + "database/backup")
347
+ errors = json.loads(resp.content).get("errors", [])
348
+ return any(err.get("errorCode") == 1201 for err in errors)
349
+ except (ValueError, AttributeError):
350
+ return False
351
+
352
+
353
+ def _backup_db_request(
354
+ justbackup, data, user, pwd, logger=None,
355
+ backup_in_progress_wait=900, backup_in_progress_retries=3,
356
+ ):
357
+ if justbackup:
358
+ return
359
+
360
+ posturl = iri_to_uri(getmambuurl() + "database/backup")
361
+ attempts_left = backup_in_progress_retries
362
+ while True:
363
+ try:
331
364
  logger.info("open url: " + posturl)
332
365
  logger.info("data: " + str(data))
333
366
  resp = requests.post(
@@ -343,12 +376,26 @@ def _backup_db_request(justbackup, data, user, pwd, logger=None):
343
376
  logger.info(resp.request.url)
344
377
  logger.info(resp.request.body)
345
378
  logger.info(str([(k, v) for k, v in resp.request.headers.items() if k != "Authorization"]))
346
- except Exception as ex:
347
- mess = "Error requesting backup: %s" % repr(ex)
348
- logger.exception(mess)
349
- raise MambuError(mess)
379
+ except Exception as ex:
380
+ mess = "Error requesting backup: %s" % repr(ex)
381
+ logger.exception(mess)
382
+ raise MambuError(mess)
383
+
384
+ if resp.status_code == 202:
385
+ return
386
+
387
+ # Only retry the POST when Mambu says a backup is already in progress.
388
+ # Any other non-202 is a real failure and fails fast, as before.
389
+ if _backup_db_in_progress(resp) and attempts_left > 0:
390
+ attempts_left -= 1
391
+ logger.warning(
392
+ "Mambu backup already in progress (errorCode 1201); waiting "
393
+ "%ss before retrying POST (%s retries left)"
394
+ % (backup_in_progress_wait, attempts_left)
395
+ )
396
+ sleep(backup_in_progress_wait)
397
+ continue
350
398
 
351
- if not justbackup and resp.status_code != 202:
352
399
  mess = "Error posting request for backup: %s" % resp.content
353
400
  logger.error(mess)
354
401
  raise MambuCommError(mess)
@@ -433,6 +480,13 @@ def backup_db(callback, bool_func, output_fname, *args, **kwargs):
433
480
  callback is called. False to throw error if callback isn't received
434
481
  after retries.
435
482
 
483
+ * backup_in_progress_wait seconds to wait before retrying the POST when
484
+ Mambu rejects it with errorCode 1201 (a backup is already in progress).
485
+ Defaults to 900 (~the observed ~14 min a backup job takes).
486
+
487
+ * backup_in_progress_retries number of times to retry the POST on
488
+ errorCode 1201 before giving up and raising. Defaults to 3.
489
+
436
490
  * returns a dictionary with info about the download
437
491
  -latest boolean flag, if the db downloaded was the latest or not
438
492
 
@@ -448,10 +502,16 @@ def backup_db(callback, bool_func, output_fname, *args, **kwargs):
448
502
  user,
449
503
  pwd,
450
504
  data,
505
+ backup_in_progress_wait,
506
+ backup_in_progress_retries,
451
507
  ) = _backup_db_previous_prep(callback, logger, kwargs)
452
508
 
453
509
  # POST to request Mambu to prepare backup of its own DB
454
- _backup_db_request(justbackup, data, user, pwd, logger)
510
+ _backup_db_request(
511
+ justbackup, data, user, pwd, logger,
512
+ backup_in_progress_wait=backup_in_progress_wait,
513
+ backup_in_progress_retries=backup_in_progress_retries,
514
+ )
455
515
 
456
516
  # wait & timeout mechanism
457
517
  data["latest"] = _backup_db_timeout_mechanism(