coati-payroll 0.0.2__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.

Potentially problematic release.


This version of coati-payroll might be problematic. Click here for more details.

Files changed (243) hide show
  1. coati_payroll/__init__.py +415 -0
  2. coati_payroll/app.py +95 -0
  3. coati_payroll/audit_helpers.py +904 -0
  4. coati_payroll/auth.py +123 -0
  5. coati_payroll/cli.py +1318 -0
  6. coati_payroll/config.py +219 -0
  7. coati_payroll/demo_data.py +813 -0
  8. coati_payroll/enums.py +278 -0
  9. coati_payroll/forms.py +1769 -0
  10. coati_payroll/formula_engine/__init__.py +81 -0
  11. coati_payroll/formula_engine/ast/__init__.py +110 -0
  12. coati_payroll/formula_engine/ast/ast_visitor.py +259 -0
  13. coati_payroll/formula_engine/ast/expression_evaluator.py +228 -0
  14. coati_payroll/formula_engine/ast/safe_operators.py +131 -0
  15. coati_payroll/formula_engine/ast/type_converter.py +172 -0
  16. coati_payroll/formula_engine/data_sources.py +752 -0
  17. coati_payroll/formula_engine/engine.py +247 -0
  18. coati_payroll/formula_engine/exceptions.py +52 -0
  19. coati_payroll/formula_engine/execution/__init__.py +24 -0
  20. coati_payroll/formula_engine/execution/execution_context.py +52 -0
  21. coati_payroll/formula_engine/execution/step_executor.py +62 -0
  22. coati_payroll/formula_engine/execution/variable_store.py +59 -0
  23. coati_payroll/formula_engine/novelty_codes.py +206 -0
  24. coati_payroll/formula_engine/results/__init__.py +20 -0
  25. coati_payroll/formula_engine/results/execution_result.py +59 -0
  26. coati_payroll/formula_engine/steps/__init__.py +30 -0
  27. coati_payroll/formula_engine/steps/assignment_step.py +71 -0
  28. coati_payroll/formula_engine/steps/base_step.py +48 -0
  29. coati_payroll/formula_engine/steps/calculation_step.py +42 -0
  30. coati_payroll/formula_engine/steps/conditional_step.py +122 -0
  31. coati_payroll/formula_engine/steps/step_factory.py +58 -0
  32. coati_payroll/formula_engine/steps/tax_lookup_step.py +45 -0
  33. coati_payroll/formula_engine/tables/__init__.py +24 -0
  34. coati_payroll/formula_engine/tables/bracket_calculator.py +51 -0
  35. coati_payroll/formula_engine/tables/table_lookup.py +161 -0
  36. coati_payroll/formula_engine/tables/tax_table.py +32 -0
  37. coati_payroll/formula_engine/validation/__init__.py +24 -0
  38. coati_payroll/formula_engine/validation/schema_validator.py +37 -0
  39. coati_payroll/formula_engine/validation/security_validator.py +52 -0
  40. coati_payroll/formula_engine/validation/tax_table_validator.py +205 -0
  41. coati_payroll/formula_engine_examples.py +153 -0
  42. coati_payroll/i18n.py +54 -0
  43. coati_payroll/initial_data.py +613 -0
  44. coati_payroll/interes_engine.py +450 -0
  45. coati_payroll/liquidacion_engine/__init__.py +25 -0
  46. coati_payroll/liquidacion_engine/engine.py +267 -0
  47. coati_payroll/locale_config.py +165 -0
  48. coati_payroll/log.py +138 -0
  49. coati_payroll/model.py +2410 -0
  50. coati_payroll/nomina_engine/__init__.py +87 -0
  51. coati_payroll/nomina_engine/calculators/__init__.py +30 -0
  52. coati_payroll/nomina_engine/calculators/benefit_calculator.py +79 -0
  53. coati_payroll/nomina_engine/calculators/concept_calculator.py +254 -0
  54. coati_payroll/nomina_engine/calculators/deduction_calculator.py +105 -0
  55. coati_payroll/nomina_engine/calculators/exchange_rate_calculator.py +51 -0
  56. coati_payroll/nomina_engine/calculators/perception_calculator.py +75 -0
  57. coati_payroll/nomina_engine/calculators/salary_calculator.py +86 -0
  58. coati_payroll/nomina_engine/domain/__init__.py +27 -0
  59. coati_payroll/nomina_engine/domain/calculation_items.py +52 -0
  60. coati_payroll/nomina_engine/domain/employee_calculation.py +53 -0
  61. coati_payroll/nomina_engine/domain/payroll_context.py +44 -0
  62. coati_payroll/nomina_engine/engine.py +188 -0
  63. coati_payroll/nomina_engine/processors/__init__.py +28 -0
  64. coati_payroll/nomina_engine/processors/accounting_processor.py +171 -0
  65. coati_payroll/nomina_engine/processors/accumulation_processor.py +90 -0
  66. coati_payroll/nomina_engine/processors/loan_processor.py +227 -0
  67. coati_payroll/nomina_engine/processors/novelty_processor.py +42 -0
  68. coati_payroll/nomina_engine/processors/vacation_processor.py +67 -0
  69. coati_payroll/nomina_engine/repositories/__init__.py +32 -0
  70. coati_payroll/nomina_engine/repositories/acumulado_repository.py +83 -0
  71. coati_payroll/nomina_engine/repositories/base_repository.py +40 -0
  72. coati_payroll/nomina_engine/repositories/config_repository.py +102 -0
  73. coati_payroll/nomina_engine/repositories/employee_repository.py +34 -0
  74. coati_payroll/nomina_engine/repositories/exchange_rate_repository.py +58 -0
  75. coati_payroll/nomina_engine/repositories/novelty_repository.py +54 -0
  76. coati_payroll/nomina_engine/repositories/planilla_repository.py +52 -0
  77. coati_payroll/nomina_engine/results/__init__.py +24 -0
  78. coati_payroll/nomina_engine/results/error_result.py +28 -0
  79. coati_payroll/nomina_engine/results/payroll_result.py +53 -0
  80. coati_payroll/nomina_engine/results/validation_result.py +39 -0
  81. coati_payroll/nomina_engine/services/__init__.py +22 -0
  82. coati_payroll/nomina_engine/services/accounting_voucher_service.py +708 -0
  83. coati_payroll/nomina_engine/services/employee_processing_service.py +173 -0
  84. coati_payroll/nomina_engine/services/payroll_execution_service.py +374 -0
  85. coati_payroll/nomina_engine/services/snapshot_service.py +295 -0
  86. coati_payroll/nomina_engine/validators/__init__.py +31 -0
  87. coati_payroll/nomina_engine/validators/base_validator.py +48 -0
  88. coati_payroll/nomina_engine/validators/currency_validator.py +50 -0
  89. coati_payroll/nomina_engine/validators/employee_validator.py +87 -0
  90. coati_payroll/nomina_engine/validators/period_validator.py +44 -0
  91. coati_payroll/nomina_engine/validators/planilla_validator.py +136 -0
  92. coati_payroll/plugin_manager.py +176 -0
  93. coati_payroll/queue/__init__.py +33 -0
  94. coati_payroll/queue/driver.py +127 -0
  95. coati_payroll/queue/drivers/__init__.py +22 -0
  96. coati_payroll/queue/drivers/dramatiq_driver.py +268 -0
  97. coati_payroll/queue/drivers/huey_driver.py +390 -0
  98. coati_payroll/queue/drivers/noop_driver.py +54 -0
  99. coati_payroll/queue/selector.py +121 -0
  100. coati_payroll/queue/tasks.py +764 -0
  101. coati_payroll/rate_limiting.py +83 -0
  102. coati_payroll/rbac.py +183 -0
  103. coati_payroll/report_engine.py +512 -0
  104. coati_payroll/report_export.py +208 -0
  105. coati_payroll/schema_validator.py +167 -0
  106. coati_payroll/security.py +77 -0
  107. coati_payroll/static/styles.css +1044 -0
  108. coati_payroll/system_reports.py +573 -0
  109. coati_payroll/templates/auth/login.html +189 -0
  110. coati_payroll/templates/base.html +283 -0
  111. coati_payroll/templates/index.html +227 -0
  112. coati_payroll/templates/macros.html +146 -0
  113. coati_payroll/templates/modules/calculation_rule/form.html +78 -0
  114. coati_payroll/templates/modules/calculation_rule/index.html +102 -0
  115. coati_payroll/templates/modules/calculation_rule/schema_editor.html +1159 -0
  116. coati_payroll/templates/modules/carga_inicial_prestacion/form.html +170 -0
  117. coati_payroll/templates/modules/carga_inicial_prestacion/index.html +170 -0
  118. coati_payroll/templates/modules/carga_inicial_prestacion/reporte.html +193 -0
  119. coati_payroll/templates/modules/config_calculos/index.html +44 -0
  120. coati_payroll/templates/modules/configuracion/index.html +90 -0
  121. coati_payroll/templates/modules/currency/form.html +47 -0
  122. coati_payroll/templates/modules/currency/index.html +64 -0
  123. coati_payroll/templates/modules/custom_field/form.html +62 -0
  124. coati_payroll/templates/modules/custom_field/index.html +78 -0
  125. coati_payroll/templates/modules/deduccion/form.html +1 -0
  126. coati_payroll/templates/modules/deduccion/index.html +1 -0
  127. coati_payroll/templates/modules/employee/form.html +254 -0
  128. coati_payroll/templates/modules/employee/index.html +76 -0
  129. coati_payroll/templates/modules/empresa/form.html +74 -0
  130. coati_payroll/templates/modules/empresa/index.html +71 -0
  131. coati_payroll/templates/modules/exchange_rate/form.html +47 -0
  132. coati_payroll/templates/modules/exchange_rate/import.html +93 -0
  133. coati_payroll/templates/modules/exchange_rate/index.html +114 -0
  134. coati_payroll/templates/modules/liquidacion/index.html +58 -0
  135. coati_payroll/templates/modules/liquidacion/nueva.html +51 -0
  136. coati_payroll/templates/modules/liquidacion/ver.html +91 -0
  137. coati_payroll/templates/modules/payroll_concepts/audit_log.html +146 -0
  138. coati_payroll/templates/modules/percepcion/form.html +1 -0
  139. coati_payroll/templates/modules/percepcion/index.html +1 -0
  140. coati_payroll/templates/modules/planilla/config.html +190 -0
  141. coati_payroll/templates/modules/planilla/config_deducciones.html +129 -0
  142. coati_payroll/templates/modules/planilla/config_empleados.html +116 -0
  143. coati_payroll/templates/modules/planilla/config_percepciones.html +113 -0
  144. coati_payroll/templates/modules/planilla/config_prestaciones.html +118 -0
  145. coati_payroll/templates/modules/planilla/config_reglas.html +120 -0
  146. coati_payroll/templates/modules/planilla/ejecutar_nomina.html +106 -0
  147. coati_payroll/templates/modules/planilla/form.html +197 -0
  148. coati_payroll/templates/modules/planilla/index.html +144 -0
  149. coati_payroll/templates/modules/planilla/listar_nominas.html +91 -0
  150. coati_payroll/templates/modules/planilla/log_nomina.html +135 -0
  151. coati_payroll/templates/modules/planilla/novedades/form.html +177 -0
  152. coati_payroll/templates/modules/planilla/novedades/index.html +170 -0
  153. coati_payroll/templates/modules/planilla/ver_nomina.html +477 -0
  154. coati_payroll/templates/modules/planilla/ver_nomina_empleado.html +231 -0
  155. coati_payroll/templates/modules/plugins/index.html +71 -0
  156. coati_payroll/templates/modules/prestacion/form.html +1 -0
  157. coati_payroll/templates/modules/prestacion/index.html +1 -0
  158. coati_payroll/templates/modules/prestacion_management/dashboard.html +150 -0
  159. coati_payroll/templates/modules/prestacion_management/initial_balance_bulk.html +195 -0
  160. coati_payroll/templates/modules/prestamo/approve.html +156 -0
  161. coati_payroll/templates/modules/prestamo/condonacion.html +249 -0
  162. coati_payroll/templates/modules/prestamo/detail.html +443 -0
  163. coati_payroll/templates/modules/prestamo/form.html +203 -0
  164. coati_payroll/templates/modules/prestamo/index.html +150 -0
  165. coati_payroll/templates/modules/prestamo/pago_extraordinario.html +211 -0
  166. coati_payroll/templates/modules/prestamo/tabla_pago_pdf.html +181 -0
  167. coati_payroll/templates/modules/report/admin_index.html +125 -0
  168. coati_payroll/templates/modules/report/detail.html +129 -0
  169. coati_payroll/templates/modules/report/execute.html +266 -0
  170. coati_payroll/templates/modules/report/index.html +95 -0
  171. coati_payroll/templates/modules/report/permissions.html +64 -0
  172. coati_payroll/templates/modules/settings/index.html +274 -0
  173. coati_payroll/templates/modules/shared/concept_form.html +201 -0
  174. coati_payroll/templates/modules/shared/concept_index.html +145 -0
  175. coati_payroll/templates/modules/tipo_planilla/form.html +70 -0
  176. coati_payroll/templates/modules/tipo_planilla/index.html +68 -0
  177. coati_payroll/templates/modules/user/form.html +65 -0
  178. coati_payroll/templates/modules/user/index.html +76 -0
  179. coati_payroll/templates/modules/user/profile.html +81 -0
  180. coati_payroll/templates/modules/vacation/account_detail.html +149 -0
  181. coati_payroll/templates/modules/vacation/account_form.html +52 -0
  182. coati_payroll/templates/modules/vacation/account_index.html +68 -0
  183. coati_payroll/templates/modules/vacation/dashboard.html +156 -0
  184. coati_payroll/templates/modules/vacation/initial_balance_bulk.html +149 -0
  185. coati_payroll/templates/modules/vacation/initial_balance_form.html +93 -0
  186. coati_payroll/templates/modules/vacation/leave_request_detail.html +158 -0
  187. coati_payroll/templates/modules/vacation/leave_request_form.html +61 -0
  188. coati_payroll/templates/modules/vacation/leave_request_index.html +98 -0
  189. coati_payroll/templates/modules/vacation/policy_detail.html +176 -0
  190. coati_payroll/templates/modules/vacation/policy_form.html +152 -0
  191. coati_payroll/templates/modules/vacation/policy_index.html +79 -0
  192. coati_payroll/templates/modules/vacation/register_taken_form.html +178 -0
  193. coati_payroll/translations/en/LC_MESSAGES/messages.mo +0 -0
  194. coati_payroll/translations/en/LC_MESSAGES/messages.po +7283 -0
  195. coati_payroll/translations/es/LC_MESSAGES/messages.mo +0 -0
  196. coati_payroll/translations/es/LC_MESSAGES/messages.po +7374 -0
  197. coati_payroll/vacation_service.py +451 -0
  198. coati_payroll/version.py +18 -0
  199. coati_payroll/vistas/__init__.py +64 -0
  200. coati_payroll/vistas/calculation_rule.py +307 -0
  201. coati_payroll/vistas/carga_inicial_prestacion.py +423 -0
  202. coati_payroll/vistas/config_calculos.py +72 -0
  203. coati_payroll/vistas/configuracion.py +87 -0
  204. coati_payroll/vistas/constants.py +17 -0
  205. coati_payroll/vistas/currency.py +112 -0
  206. coati_payroll/vistas/custom_field.py +120 -0
  207. coati_payroll/vistas/employee.py +305 -0
  208. coati_payroll/vistas/empresa.py +153 -0
  209. coati_payroll/vistas/exchange_rate.py +341 -0
  210. coati_payroll/vistas/liquidacion.py +205 -0
  211. coati_payroll/vistas/payroll_concepts.py +580 -0
  212. coati_payroll/vistas/planilla/__init__.py +38 -0
  213. coati_payroll/vistas/planilla/association_routes.py +238 -0
  214. coati_payroll/vistas/planilla/config_routes.py +158 -0
  215. coati_payroll/vistas/planilla/export_routes.py +175 -0
  216. coati_payroll/vistas/planilla/helpers/__init__.py +34 -0
  217. coati_payroll/vistas/planilla/helpers/association_helpers.py +161 -0
  218. coati_payroll/vistas/planilla/helpers/excel_helpers.py +29 -0
  219. coati_payroll/vistas/planilla/helpers/form_helpers.py +97 -0
  220. coati_payroll/vistas/planilla/nomina_routes.py +488 -0
  221. coati_payroll/vistas/planilla/novedad_routes.py +227 -0
  222. coati_payroll/vistas/planilla/routes.py +145 -0
  223. coati_payroll/vistas/planilla/services/__init__.py +26 -0
  224. coati_payroll/vistas/planilla/services/export_service.py +687 -0
  225. coati_payroll/vistas/planilla/services/nomina_service.py +233 -0
  226. coati_payroll/vistas/planilla/services/novedad_service.py +126 -0
  227. coati_payroll/vistas/planilla/services/planilla_service.py +34 -0
  228. coati_payroll/vistas/planilla/validators/__init__.py +18 -0
  229. coati_payroll/vistas/planilla/validators/planilla_validators.py +40 -0
  230. coati_payroll/vistas/plugins.py +45 -0
  231. coati_payroll/vistas/prestacion.py +272 -0
  232. coati_payroll/vistas/prestamo.py +808 -0
  233. coati_payroll/vistas/report.py +432 -0
  234. coati_payroll/vistas/settings.py +29 -0
  235. coati_payroll/vistas/tipo_planilla.py +134 -0
  236. coati_payroll/vistas/user.py +172 -0
  237. coati_payroll/vistas/vacation.py +1045 -0
  238. coati_payroll-0.0.2.dist-info/LICENSE +201 -0
  239. coati_payroll-0.0.2.dist-info/METADATA +581 -0
  240. coati_payroll-0.0.2.dist-info/RECORD +243 -0
  241. coati_payroll-0.0.2.dist-info/WHEEL +5 -0
  242. coati_payroll-0.0.2.dist-info/entry_points.txt +2 -0
  243. coati_payroll-0.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,268 @@
1
+ # Copyright 2025 BMO Soluciones, S.A.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Dramatiq driver for high-performance queue processing with Redis."""
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, Callable
19
+
20
+ from coati_payroll.log import log
21
+ from coati_payroll.queue.driver import QueueDriver
22
+
23
+
24
+ class DramatiqDriver(QueueDriver):
25
+ """Queue driver using Dramatiq with Redis backend.
26
+
27
+ This driver provides high-performance, distributed job processing
28
+ suitable for production environments with Redis available.
29
+
30
+ Features:
31
+ - Multi-threaded workers
32
+ - Automatic retries with exponential backoff
33
+ - Distributed processing across multiple workers
34
+ - Results backend (optional)
35
+ """
36
+
37
+ def __init__(self, redis_url: str | None = None):
38
+ """Initialize Dramatiq driver.
39
+
40
+ Args:
41
+ redis_url: Redis connection URL (default: redis://localhost:6379/0)
42
+ """
43
+ self._redis_url = redis_url or "redis://localhost:6379/0"
44
+ self._broker = None
45
+ self._tasks = {}
46
+ self._available = self._initialize_broker()
47
+
48
+ def _initialize_broker(self) -> bool:
49
+ """Initialize the Dramatiq broker.
50
+
51
+ Returns:
52
+ True if broker initialized successfully, False otherwise
53
+ """
54
+ try:
55
+ import dramatiq
56
+ from dramatiq.brokers.redis import RedisBroker
57
+ from dramatiq.middleware import (
58
+ AgeLimit,
59
+ Retries,
60
+ TimeLimit,
61
+ )
62
+
63
+ # Test Redis connection
64
+ import redis
65
+
66
+ client = redis.from_url(self._redis_url, socket_connect_timeout=2)
67
+ client.ping()
68
+
69
+ # Configure broker with middleware
70
+ self._broker = RedisBroker(url=self._redis_url)
71
+
72
+ # Add middleware for retries, age limits, and time limits
73
+ self._broker.add_middleware(Retries(max_retries=3, min_backoff=15000, max_backoff=86400000))
74
+ self._broker.add_middleware(TimeLimit(time_limit=3600000)) # 1 hour max
75
+ self._broker.add_middleware(AgeLimit(max_age=86400000)) # 24 hours max age
76
+
77
+ # Set as default broker
78
+ dramatiq.set_broker(self._broker)
79
+
80
+ log.info(f"Dramatiq driver initialized with Redis at {self._redis_url}")
81
+ return True
82
+
83
+ except ImportError as e:
84
+ log.warning(f"Dramatiq not available: {e}")
85
+ return False
86
+ except Exception as e:
87
+ log.warning(f"Failed to connect to Redis for Dramatiq: {e}")
88
+ return False
89
+
90
+ def enqueue(self, task_name: str, *args: Any, delay: int | None = None, **kwargs: Any) -> Any:
91
+ """Enqueue a task for background processing.
92
+
93
+ Args:
94
+ task_name: Name of the registered task
95
+ *args: Positional arguments for the task
96
+ delay: Optional delay in seconds before execution
97
+ **kwargs: Keyword arguments for the task
98
+
99
+ Returns:
100
+ Dramatiq message object
101
+
102
+ Raises:
103
+ ValueError: If task is not registered
104
+ RuntimeError: If driver is not available
105
+ """
106
+ if not self._available:
107
+ raise RuntimeError("Dramatiq driver is not available")
108
+
109
+ if task_name not in self._tasks:
110
+ raise ValueError(f"Task '{task_name}' not registered")
111
+
112
+ task = self._tasks[task_name]
113
+
114
+ if delay:
115
+ # Convert seconds to milliseconds for Dramatiq
116
+ return task.send_with_options(args=args, kwargs=kwargs, delay=delay * 1000)
117
+ else:
118
+ return task.send(*args, **kwargs)
119
+
120
+ def register_task(
121
+ self,
122
+ func: Callable,
123
+ name: str | None = None,
124
+ max_retries: int = 3,
125
+ min_backoff: int = 15000,
126
+ max_backoff: int = 86400000,
127
+ ) -> Callable:
128
+ """Register a function as a Dramatiq actor.
129
+
130
+ Args:
131
+ func: Function to register
132
+ name: Optional task name (defaults to function name)
133
+ max_retries: Maximum retry attempts
134
+ min_backoff: Minimum backoff in milliseconds
135
+ max_backoff: Maximum backoff in milliseconds
136
+
137
+ Returns:
138
+ Dramatiq actor that can be called or enqueued
139
+ """
140
+ if not self._available:
141
+ log.warning(f"Cannot register task '{name or func.__name__}': Dramatiq not available")
142
+ return func
143
+
144
+ try:
145
+ import dramatiq
146
+
147
+ task_name = name or func.__name__
148
+
149
+ # Decorate with dramatiq.actor
150
+ actor = dramatiq.actor(
151
+ func,
152
+ actor_name=task_name,
153
+ max_retries=max_retries,
154
+ min_backoff=min_backoff,
155
+ max_backoff=max_backoff,
156
+ )
157
+
158
+ self._tasks[task_name] = actor
159
+ log.debug(f"Registered Dramatiq task: {task_name}")
160
+
161
+ return actor
162
+
163
+ except Exception as e:
164
+ log.error(f"Failed to register task '{name or func.__name__}': {e}")
165
+ return func
166
+
167
+ def is_available(self) -> bool:
168
+ """Check if Dramatiq driver is available.
169
+
170
+ Returns:
171
+ True if driver initialized successfully
172
+ """
173
+ return self._available
174
+
175
+ def get_stats(self) -> dict[str, Any]:
176
+ """Get queue statistics from Redis.
177
+
178
+ Returns:
179
+ Dictionary with queue statistics
180
+ """
181
+ if not self._available or not self._broker:
182
+ return {"error": "Dramatiq driver not available"}
183
+
184
+ try:
185
+ import redis
186
+
187
+ client = redis.from_url(self._redis_url)
188
+
189
+ # Get basic stats from Redis
190
+ stats = {
191
+ "driver": "dramatiq",
192
+ "backend": "redis",
193
+ "available": True,
194
+ "registered_tasks": list(self._tasks.keys()),
195
+ }
196
+
197
+ # Try to get queue lengths
198
+ try:
199
+ keys = client.keys("dramatiq:*:msgs")
200
+ stats["queues"] = {}
201
+ for key in keys:
202
+ queue_name = key.decode("utf-8").split(":")[1]
203
+ length = client.llen(key)
204
+ stats["queues"][queue_name] = length
205
+ except Exception as e:
206
+ log.debug(f"Could not fetch queue lengths: {e}")
207
+
208
+ return stats
209
+
210
+ except Exception as e:
211
+ log.error(f"Failed to get Dramatiq stats: {e}")
212
+ return {"error": str(e)}
213
+
214
+ def get_task_result(self, task_id: Any) -> dict[str, Any]:
215
+ """Get the result of a task by its ID.
216
+
217
+ Note: Dramatiq doesn't have built-in result storage by default.
218
+ This returns limited information based on message ID.
219
+
220
+ Args:
221
+ task_id: Dramatiq message object
222
+
223
+ Returns:
224
+ Dictionary with task status (limited in Dramatiq without results backend)
225
+ """
226
+ if not self._available:
227
+ return {"status": "error", "error": "Dramatiq driver not available"}
228
+
229
+ try:
230
+ # Dramatiq messages don't have built-in result tracking
231
+ # unless using Results middleware with a backend
232
+ return {
233
+ "status": "pending",
234
+ "message": "Dramatiq task enqueued. Result tracking requires Results middleware.",
235
+ "task_id": str(task_id) if task_id else None,
236
+ }
237
+ except Exception as e:
238
+ log.error(f"Failed to get task result: {e}")
239
+ return {"status": "error", "error": str(e)}
240
+
241
+ def get_bulk_results(self, task_ids: list[Any]) -> dict[str, Any]:
242
+ """Get results for multiple tasks (for bulk feedback: x of y completed).
243
+
244
+ Note: Without Results middleware, Dramatiq cannot track task completion.
245
+ This returns estimated status based on queue inspection.
246
+
247
+ Args:
248
+ task_ids: List of Dramatiq message objects
249
+
250
+ Returns:
251
+ Dictionary with aggregated status (limited without Results backend)
252
+ """
253
+ if not self._available:
254
+ return {"error": "Dramatiq driver not available"}
255
+
256
+ total = len(task_ids)
257
+
258
+ # Without Results middleware, we can only provide limited feedback
259
+ return {
260
+ "total": total,
261
+ "completed": 0,
262
+ "failed": 0,
263
+ "pending": total, # Assume all pending without result tracking
264
+ "processing": 0,
265
+ "tasks": {},
266
+ "message": "Bulk result tracking requires Results middleware with a backend.",
267
+ "progress_percentage": 0,
268
+ }
@@ -0,0 +1,390 @@
1
+ # Copyright 2025 BMO Soluciones, S.A.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Huey driver for filesystem-based queue processing (fallback mode)."""
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from pathlib import Path
20
+ from typing import Any, Callable
21
+
22
+ from coati_payroll.log import log
23
+ from coati_payroll.queue.driver import QueueDriver
24
+
25
+
26
+ class HueyDriver(QueueDriver):
27
+ """Queue driver using Huey with filesystem backend.
28
+
29
+ This driver provides a fallback queue implementation that works
30
+ without Redis or any database, using only the filesystem for
31
+ persistence. Suitable for small deployments or development.
32
+
33
+ Features:
34
+ - No external dependencies (Redis, databases)
35
+ - Persistent queue using filesystem
36
+ - Thread-safe execution
37
+ - Multiple workers support (local only)
38
+
39
+ Limitations:
40
+ - Cannot scale horizontally (single-server only)
41
+ - Lower performance than Dramatiq+Redis
42
+ """
43
+
44
+ def __init__(self, storage_path: str | None = None):
45
+ """Initialize Huey driver with filesystem backend.
46
+
47
+ Args:
48
+ storage_path: Path to store queue files (default: /var/lib/coati/queue)
49
+ """
50
+ self._storage_path = storage_path or self._get_default_storage_path()
51
+ self._huey = None
52
+ self._tasks = {}
53
+ self._available = self._initialize_huey()
54
+
55
+ def _get_default_storage_path(self) -> str:
56
+ """Get default storage path for queue files.
57
+
58
+ Ensures proper permissions are available for reading and writing queue files.
59
+
60
+ Returns:
61
+ Path to queue storage directory
62
+ """
63
+ # Try to use /var/lib/coati/queue if writable, otherwise use user directory
64
+ # Try standard system and user directories first (secure locations)
65
+ paths_to_try = [
66
+ "/var/lib/coati/queue",
67
+ os.path.expanduser("~/.local/share/coati-payroll/queue"),
68
+ ]
69
+
70
+ for path in paths_to_try:
71
+ try:
72
+ Path(path).mkdir(parents=True, exist_ok=True)
73
+ # Test read/write permissions
74
+ test_file = Path(path) / ".test_permissions"
75
+ test_file.write_text("test")
76
+ content = test_file.read_text()
77
+ test_file.unlink()
78
+
79
+ if content == "test":
80
+ log.info(f"Queue storage path verified with read/write access: {path}")
81
+ return path
82
+ except (OSError, PermissionError) as e:
83
+ log.debug(f"Cannot use path {path}: {e}")
84
+ continue
85
+
86
+ # Try current working directory as last resort (with warning about security)
87
+ try:
88
+ cwd_path = os.path.join(os.getcwd(), ".coati_queue")
89
+ Path(cwd_path).mkdir(parents=True, exist_ok=True)
90
+ test_file = Path(cwd_path) / ".test_permissions"
91
+ test_file.write_text("test")
92
+ test_file.read_text()
93
+ test_file.unlink()
94
+ log.warning(
95
+ f"Using current working directory for queue storage: {cwd_path}. "
96
+ f"This may be insecure if running in a public directory. "
97
+ f"Consider setting COATI_QUEUE_PATH to a secure location."
98
+ )
99
+ return cwd_path
100
+ except (OSError, PermissionError):
101
+ pass
102
+
103
+ # Fallback to temp directory
104
+ import tempfile
105
+
106
+ path = os.path.join(tempfile.gettempdir(), "coati_queue")
107
+ Path(path).mkdir(parents=True, exist_ok=True)
108
+ log.warning(
109
+ f"Using temporary directory for queue storage: {path}. " f"Queue data will be lost on system reboot."
110
+ )
111
+ return path
112
+
113
+ def _initialize_huey(self) -> bool:
114
+ """Initialize Huey with filesystem backend.
115
+
116
+ Validates read/write permissions before initialization to ensure
117
+ the process has necessary access to queue files.
118
+
119
+ Returns:
120
+ True if Huey initialized successfully, False otherwise
121
+ """
122
+ try:
123
+ from huey import FileHuey
124
+
125
+ # Ensure storage directory exists with proper permissions
126
+ Path(self._storage_path).mkdir(parents=True, exist_ok=True)
127
+
128
+ # Validate read/write permissions
129
+ test_file = Path(self._storage_path) / ".huey_permissions_test"
130
+ try:
131
+ test_file.write_text("permission_test")
132
+ if test_file.read_text() != "permission_test":
133
+ raise PermissionError("Cannot verify read access")
134
+ test_file.unlink()
135
+ except (OSError, PermissionError) as e:
136
+ log.error(
137
+ f"Insufficient permissions for queue storage at {self._storage_path}: {e}. "
138
+ f"Please ensure the process has read/write access to this directory."
139
+ )
140
+ return False
141
+
142
+ # Initialize FileHuey with filesystem storage
143
+ # Note: FileHuey accepts storage_kwargs which are passed to FileStorage
144
+ self._huey = FileHuey(
145
+ name="coati_payroll",
146
+ path=self._storage_path, # Path for FileStorage
147
+ immediate=False, # Don't execute tasks immediately
148
+ results=True, # Store results for feedback
149
+ )
150
+
151
+ log.info(
152
+ f"Huey driver initialized with filesystem storage at {self._storage_path}. "
153
+ f"Read/write permissions verified."
154
+ )
155
+ return True
156
+
157
+ except ImportError as e:
158
+ log.warning(f"Huey not available: {e}")
159
+ return False
160
+ except Exception as e:
161
+ log.error(f"Failed to initialize Huey: {e}")
162
+ return False
163
+
164
+ def enqueue(self, task_name: str, *args: Any, delay: int | None = None, **kwargs: Any) -> Any:
165
+ """Enqueue a task for background processing.
166
+
167
+ Args:
168
+ task_name: Name of the registered task
169
+ *args: Positional arguments for the task
170
+ delay: Optional delay in seconds before execution
171
+ **kwargs: Keyword arguments for the task
172
+
173
+ Returns:
174
+ Huey result object
175
+
176
+ Raises:
177
+ ValueError: If task is not registered
178
+ RuntimeError: If driver is not available
179
+ """
180
+ if not self._available:
181
+ raise RuntimeError("Huey driver is not available")
182
+
183
+ if task_name not in self._tasks:
184
+ raise ValueError(f"Task '{task_name}' not registered")
185
+
186
+ task = self._tasks[task_name]
187
+
188
+ if delay:
189
+ return task.schedule(args=args, kwargs=kwargs, delay=delay)
190
+ else:
191
+ return task(*args, **kwargs)
192
+
193
+ def register_task(
194
+ self,
195
+ func: Callable,
196
+ name: str | None = None,
197
+ max_retries: int = 3,
198
+ min_backoff: int = 15000,
199
+ max_backoff: int = 86400000,
200
+ ) -> Callable:
201
+ """Register a function as a Huey task.
202
+
203
+ Args:
204
+ func: Function to register
205
+ name: Optional task name (defaults to function name)
206
+ max_retries: Maximum retry attempts
207
+ min_backoff: Minimum backoff in milliseconds (converted to seconds)
208
+ max_backoff: Maximum backoff in milliseconds (not used by Huey)
209
+
210
+ Returns:
211
+ Huey task that can be called or enqueued
212
+ """
213
+ if not self._available or not self._huey:
214
+ log.warning(f"Cannot register task '{name or func.__name__}': Huey not available")
215
+ return func
216
+
217
+ try:
218
+ task_name = name or func.__name__
219
+
220
+ # Convert milliseconds to seconds for retry delay
221
+ retry_delay = min_backoff / 1000
222
+
223
+ # Decorate with huey.task
224
+ task = self._huey.task(
225
+ name=task_name,
226
+ retries=max_retries,
227
+ retry_delay=int(retry_delay),
228
+ )(func)
229
+
230
+ self._tasks[task_name] = task
231
+ log.debug(f"Registered Huey task: {task_name}")
232
+
233
+ return task
234
+
235
+ except Exception as e:
236
+ log.error(f"Failed to register task '{name or func.__name__}': {e}")
237
+ return func
238
+
239
+ def is_available(self) -> bool:
240
+ """Check if Huey driver is available.
241
+
242
+ Returns:
243
+ True if driver initialized successfully
244
+ """
245
+ return self._available
246
+
247
+ def get_stats(self) -> dict[str, Any]:
248
+ """Get queue statistics.
249
+
250
+ Returns:
251
+ Dictionary with queue statistics
252
+ """
253
+ if not self._available or not self._huey:
254
+ return {"error": "Huey driver not available"}
255
+
256
+ try:
257
+ stats = {
258
+ "driver": "huey",
259
+ "backend": "filesystem",
260
+ "storage_path": self._storage_path,
261
+ "available": True,
262
+ "registered_tasks": list(self._tasks.keys()),
263
+ }
264
+
265
+ # Try to get pending task count
266
+ try:
267
+ pending = len(self._huey.pending())
268
+ stats["pending_tasks"] = pending
269
+ except Exception as e:
270
+ log.debug(f"Could not fetch pending tasks: {e}")
271
+
272
+ # Try to get scheduled task count
273
+ try:
274
+ scheduled = len(self._huey.scheduled())
275
+ stats["scheduled_tasks"] = scheduled
276
+ except Exception as e:
277
+ log.debug(f"Could not fetch scheduled tasks: {e}")
278
+
279
+ return stats
280
+
281
+ except Exception as e:
282
+ log.error(f"Failed to get Huey stats: {e}")
283
+ return {"error": str(e)}
284
+
285
+ def get_task_result(self, task_id: Any) -> dict[str, Any]:
286
+ """Get the result of a task by its ID.
287
+
288
+ Huey supports result storage when results=True is enabled.
289
+
290
+ Args:
291
+ task_id: Huey result object (must be callable with no args)
292
+
293
+ Returns:
294
+ Dictionary with task status and result
295
+ """
296
+ if not self._available or not self._huey:
297
+ return {"status": "error", "error": "Huey driver not available"}
298
+
299
+ try:
300
+ # Verify task_id is a Huey result object (callable with no args)
301
+ if not callable(task_id):
302
+ return {
303
+ "status": "error",
304
+ "error": "Invalid task_id: expected Huey result object (callable)",
305
+ }
306
+
307
+ # Try to get the result (non-blocking check)
308
+ # Huey results raise TaskException or DataStoreTimeout if not ready
309
+ try:
310
+ from huey.exceptions import TaskException
311
+
312
+ result = task_id()
313
+ return {
314
+ "status": "completed",
315
+ "result": result,
316
+ }
317
+ except TaskException:
318
+ # Task is not ready yet or failed
319
+ return {
320
+ "status": "pending",
321
+ "message": "Task is still processing",
322
+ }
323
+ except Exception as e:
324
+ # Actual error during result retrieval
325
+ log.error(f"Error retrieving task result: {e}")
326
+ return {
327
+ "status": "failed",
328
+ "error": str(e),
329
+ }
330
+
331
+ except Exception as e:
332
+ log.error(f"Failed to get task result: {e}")
333
+ return {"status": "error", "error": str(e)}
334
+
335
+ def get_bulk_results(self, task_ids: list[Any]) -> dict[str, Any]:
336
+ """Get results for multiple tasks (for bulk feedback: x of y completed).
337
+
338
+ This provides feedback on bulk operations like parallel payroll processing.
339
+
340
+ Args:
341
+ task_ids: List of Huey result objects
342
+
343
+ Returns:
344
+ Dictionary with aggregated status
345
+ """
346
+ if not self._available or not self._huey:
347
+ return {"error": "Huey driver not available"}
348
+
349
+ total = len(task_ids)
350
+ completed = 0
351
+ failed = 0
352
+ pending = 0
353
+ tasks = {}
354
+
355
+ for i, task_id in enumerate(task_ids):
356
+ try:
357
+ result_info = self.get_task_result(task_id)
358
+ status = result_info.get("status", "unknown")
359
+
360
+ tasks[f"task_{i}"] = result_info
361
+
362
+ if status == "completed":
363
+ completed += 1
364
+ elif status == "error" or status == "failed":
365
+ failed += 1
366
+ else:
367
+ pending += 1
368
+
369
+ except Exception as e:
370
+ log.debug(f"Error checking task {i}: {e}")
371
+ failed += 1
372
+ tasks[f"task_{i}"] = {"status": "error", "error": str(e)}
373
+
374
+ return {
375
+ "total": total,
376
+ "completed": completed,
377
+ "failed": failed,
378
+ "pending": pending,
379
+ "processing": 0, # Huey doesn't distinguish pending from processing
380
+ "tasks": tasks,
381
+ "progress_percentage": round((completed / total * 100) if total > 0 else 0, 2),
382
+ }
383
+
384
+ def get_huey_instance(self):
385
+ """Get the underlying Huey instance for advanced usage.
386
+
387
+ Returns:
388
+ Huey instance or None if not initialized
389
+ """
390
+ return self._huey