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,136 @@
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
+ """Validator for Planilla."""
15
+
16
+ from __future__ import annotations
17
+
18
+ from sqlalchemy import or_, and_
19
+
20
+ from coati_payroll.audit_helpers import obtener_conceptos_en_borrador
21
+ from coati_payroll.model import Nomina
22
+ from coati_payroll.enums import NominaEstado
23
+ from ..domain.payroll_context import PayrollContext
24
+ from ..results.validation_result import ValidationResult
25
+ from ..validators.base_validator import BaseValidator
26
+ from ..repositories.planilla_repository import PlanillaRepository
27
+
28
+
29
+ class PlanillaValidator(BaseValidator):
30
+ """Validates that a planilla is ready for execution."""
31
+
32
+ def __init__(self, planilla_repository: PlanillaRepository):
33
+ self.planilla_repo = planilla_repository
34
+
35
+ def validate(self, context: PayrollContext) -> ValidationResult:
36
+ """Validate planilla."""
37
+ result = ValidationResult()
38
+
39
+ planilla = self.planilla_repo.get_by_id(context.planilla_id)
40
+ if not planilla:
41
+ result.add_error("La planilla no existe.")
42
+ return result
43
+
44
+ if not planilla.activo:
45
+ result.add_error("La planilla no está activa.")
46
+
47
+ if not planilla.planilla_empleados:
48
+ result.add_error("La planilla no tiene empleados asignados.")
49
+
50
+ if not planilla.tipo_planilla:
51
+ result.add_error("La planilla no tiene tipo de planilla configurado.")
52
+
53
+ if not planilla.moneda:
54
+ result.add_error("La planilla no tiene moneda configurada.")
55
+
56
+ # Validate period not duplicated
57
+ if not self._validate_periodo_no_duplicado(planilla, context):
58
+ result.add_error("El período se solapa con una nómina existente.")
59
+
60
+ # Check for draft concepts and add warnings (not errors - allow test runs)
61
+ self._check_draft_concepts(planilla, result)
62
+
63
+ return result
64
+
65
+ def _validate_periodo_no_duplicado(self, planilla, context: PayrollContext) -> bool:
66
+ """Validate that period doesn't overlap with existing nominas."""
67
+ from sqlalchemy import select
68
+
69
+ existing = (
70
+ self.planilla_repo.session.execute(
71
+ select(Nomina).filter(
72
+ Nomina.planilla_id == planilla.id,
73
+ Nomina.estado.in_(
74
+ [
75
+ NominaEstado.GENERADO,
76
+ NominaEstado.APROBADO,
77
+ NominaEstado.APLICADO,
78
+ NominaEstado.PAGADO,
79
+ ]
80
+ ),
81
+ or_(
82
+ # Existing start falls within our period
83
+ and_(
84
+ Nomina.periodo_inicio >= context.periodo_inicio,
85
+ Nomina.periodo_inicio <= context.periodo_fin,
86
+ ),
87
+ # Existing end falls within our period
88
+ and_(
89
+ Nomina.periodo_fin >= context.periodo_inicio,
90
+ Nomina.periodo_fin <= context.periodo_fin,
91
+ ),
92
+ # Our period is completely within existing period
93
+ and_(
94
+ Nomina.periodo_inicio <= context.periodo_inicio,
95
+ Nomina.periodo_fin >= context.periodo_fin,
96
+ ),
97
+ ),
98
+ )
99
+ )
100
+ .scalars()
101
+ .first()
102
+ )
103
+
104
+ return existing is None
105
+
106
+ def _check_draft_concepts(self, planilla, result: ValidationResult) -> None:
107
+ """Check for draft concepts and add warnings.
108
+
109
+ Draft concepts are allowed in payroll runs (for testing), but we warn
110
+ the user to carefully validate results.
111
+ """
112
+ conceptos_borrador = obtener_conceptos_en_borrador(planilla.id)
113
+
114
+ if conceptos_borrador["percepciones"]:
115
+ percepciones_nombres = [p.nombre for p in conceptos_borrador["percepciones"]]
116
+ result.add_warning(
117
+ f"ADVERTENCIA: {len(percepciones_nombres)} percepción(es) en estado BORRADOR: "
118
+ f"{', '.join(percepciones_nombres)}. "
119
+ "Valide cuidadosamente los resultados de la nómina."
120
+ )
121
+
122
+ if conceptos_borrador["deducciones"]:
123
+ deducciones_nombres = [d.nombre for d in conceptos_borrador["deducciones"]]
124
+ result.add_warning(
125
+ f"ADVERTENCIA: {len(deducciones_nombres)} deducción(es) en estado BORRADOR: "
126
+ f"{', '.join(deducciones_nombres)}. "
127
+ "Valide cuidadosamente los resultados de la nómina."
128
+ )
129
+
130
+ if conceptos_borrador["prestaciones"]:
131
+ prestaciones_nombres = [p.nombre for p in conceptos_borrador["prestaciones"]]
132
+ result.add_warning(
133
+ f"ADVERTENCIA: {len(prestaciones_nombres)} prestación(es) en estado BORRADOR: "
134
+ f"{', '.join(prestaciones_nombres)}. "
135
+ "Valide cuidadosamente los resultados de la nómina."
136
+ )
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from importlib import import_module
5
+ from importlib.metadata import distributions
6
+
7
+ from flask import Flask
8
+
9
+ from coati_payroll.log import log
10
+ from coati_payroll.model import PluginRegistry, db
11
+
12
+
13
+ PLUGIN_DISTRIBUTION_PREFIX = "coati-payroll-plugin-"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class DiscoveredPlugin:
18
+ distribution_name: str
19
+ plugin_id: str
20
+ version: str | None
21
+
22
+
23
+ def _distribution_to_plugin_id(distribution_name: str) -> str:
24
+ if not distribution_name.startswith(PLUGIN_DISTRIBUTION_PREFIX):
25
+ return distribution_name
26
+ return distribution_name[len(PLUGIN_DISTRIBUTION_PREFIX) :]
27
+
28
+
29
+ def _plugin_id_to_module_name(plugin_id: str) -> str:
30
+ return f"coati_payroll_plugin_{plugin_id.replace('-', '_')}"
31
+
32
+
33
+ def discover_installed_plugins() -> list[DiscoveredPlugin]:
34
+ found: list[DiscoveredPlugin] = []
35
+
36
+ for dist in distributions():
37
+ name = (dist.metadata.get("Name") or "").strip()
38
+ if not name or not name.startswith(PLUGIN_DISTRIBUTION_PREFIX):
39
+ continue
40
+
41
+ plugin_id = _distribution_to_plugin_id(name)
42
+ version = (dist.version or "").strip() or None
43
+ found.append(DiscoveredPlugin(distribution_name=name, plugin_id=plugin_id, version=version))
44
+
45
+ found.sort(key=lambda p: p.distribution_name)
46
+ return found
47
+
48
+
49
+ def sync_plugin_registry() -> None:
50
+ installed = {p.distribution_name: p for p in discover_installed_plugins()}
51
+
52
+ rows = db.session.execute(db.select(PluginRegistry)).scalars().all()
53
+ by_name = {r.distribution_name: r for r in rows}
54
+
55
+ changed = False
56
+
57
+ for name, plugin in installed.items():
58
+ if name in by_name:
59
+ if by_name[name].plugin_id != plugin.plugin_id:
60
+ by_name[name].plugin_id = plugin.plugin_id
61
+ changed = True
62
+ if by_name[name].version != plugin.version:
63
+ by_name[name].version = plugin.version
64
+ changed = True
65
+ if by_name[name].installed is not True:
66
+ by_name[name].installed = True
67
+ changed = True
68
+ continue
69
+
70
+ record = PluginRegistry()
71
+ record.distribution_name = name
72
+ record.plugin_id = plugin.plugin_id
73
+ record.version = plugin.version
74
+ record.active = False
75
+ record.installed = True
76
+ db.session.add(record)
77
+ changed = True
78
+
79
+ for name, record in by_name.items():
80
+ if name in installed:
81
+ continue
82
+ if record.installed is not False:
83
+ record.installed = False
84
+ changed = True
85
+ if record.active:
86
+ record.active = False
87
+ changed = True
88
+
89
+ if changed:
90
+ db.session.commit()
91
+
92
+
93
+ def _load_plugin_module(distribution_name: str, plugin_id: str):
94
+ module_name = _plugin_id_to_module_name(plugin_id)
95
+ try:
96
+ return import_module(module_name)
97
+ except ModuleNotFoundError as exc:
98
+ raise ModuleNotFoundError(
99
+ f"Plugin '{distribution_name}' is installed but does not provide module '{module_name}'."
100
+ ) from exc
101
+
102
+
103
+ def load_plugin_module(plugin_id: str):
104
+ module_name = _plugin_id_to_module_name(plugin_id)
105
+ return import_module(module_name)
106
+
107
+
108
+ def register_active_plugins(app: Flask) -> None:
109
+ active_plugins = (
110
+ db.session.execute(db.select(PluginRegistry).filter_by(active=True, installed=True)).scalars().all()
111
+ )
112
+
113
+ for plugin in active_plugins:
114
+ try:
115
+ module = _load_plugin_module(plugin.distribution_name, plugin.plugin_id)
116
+
117
+ register = getattr(module, "register_blueprints", None)
118
+ if register is None or not callable(register):
119
+ raise AttributeError("Missing callable 'register_blueprints(app)'")
120
+
121
+ register(app)
122
+ except (ModuleNotFoundError, AttributeError) as exc:
123
+ log.warning(f"Plugin '{plugin.distribution_name}' could not be registered: {exc}")
124
+ plugin.active = False
125
+ plugin.installed = False
126
+ try:
127
+ db.session.commit()
128
+ except Exception:
129
+ db.session.rollback()
130
+ except Exception as exc:
131
+ log.warning(f"Plugin '{plugin.distribution_name}' failed during registration: {exc}")
132
+ plugin.active = False
133
+ try:
134
+ db.session.commit()
135
+ except Exception:
136
+ db.session.rollback()
137
+
138
+
139
+ def get_active_plugins_menu_entries() -> list[dict]:
140
+ active_plugins = (
141
+ db.session.execute(db.select(PluginRegistry).filter_by(active=True, installed=True)).scalars().all()
142
+ )
143
+
144
+ entries: list[dict] = []
145
+ for plugin in active_plugins:
146
+ try:
147
+ module = _load_plugin_module(plugin.distribution_name, plugin.plugin_id)
148
+
149
+ getter = getattr(module, "get_menu_entry", None)
150
+ if callable(getter):
151
+ entry = getter()
152
+ else:
153
+ entry = getattr(module, "MENU_ENTRY", None)
154
+
155
+ if not isinstance(entry, dict):
156
+ continue
157
+
158
+ label = entry.get("label")
159
+ icon = entry.get("icon")
160
+ url = entry.get("url")
161
+ if not label or not url:
162
+ continue
163
+
164
+ entries.append(
165
+ {
166
+ "distribution_name": plugin.distribution_name,
167
+ "plugin_id": plugin.plugin_id,
168
+ "label": label,
169
+ "icon": icon,
170
+ "url": url,
171
+ }
172
+ )
173
+ except Exception as exc:
174
+ log.warning(f"Plugin '{plugin.distribution_name}' menu entry could not be loaded: {exc}")
175
+
176
+ return entries
@@ -0,0 +1,33 @@
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
+ """Queue module for background job processing.
15
+
16
+ This module provides a unified interface for background job processing
17
+ with automatic backend selection:
18
+ - Dramatiq + Redis (production/high-scale deployments)
19
+ - Huey + Filesystem (fallback for environments without Redis)
20
+
21
+ Usage:
22
+ from coati_payroll.queue import get_queue_driver
23
+
24
+ queue = get_queue_driver()
25
+ queue.enqueue('calculate_employee_payroll', employee_id=123, payroll_id=456)
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from coati_payroll.queue.driver import QueueDriver
31
+ from coati_payroll.queue.selector import get_queue_driver
32
+
33
+ __all__ = ["QueueDriver", "get_queue_driver"]
@@ -0,0 +1,127 @@
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
+ """Abstract base class for queue drivers."""
15
+
16
+ from __future__ import annotations
17
+
18
+ from abc import ABC, abstractmethod
19
+ from typing import Any, Callable
20
+
21
+
22
+ class QueueDriver(ABC):
23
+ """Abstract base class for queue drivers.
24
+
25
+ All queue implementations (Dramatiq, Huey) must implement this interface
26
+ to provide a consistent API for background job processing.
27
+ """
28
+
29
+ @abstractmethod
30
+ def enqueue(self, task_name: str, *args: Any, delay: int | None = None, **kwargs: Any) -> Any:
31
+ """Enqueue a task for background processing.
32
+
33
+ Args:
34
+ task_name: Name of the task function to execute
35
+ *args: Positional arguments to pass to the task
36
+ delay: Optional delay in seconds before task execution
37
+ **kwargs: Keyword arguments to pass to the task
38
+
39
+ Returns:
40
+ Task identifier or result promise (implementation-specific)
41
+
42
+ Raises:
43
+ NotImplementedError: Must be implemented by subclass
44
+ """
45
+ raise NotImplementedError
46
+
47
+ @abstractmethod
48
+ def register_task(
49
+ self,
50
+ func: Callable,
51
+ name: str | None = None,
52
+ max_retries: int = 3,
53
+ min_backoff: int = 15000,
54
+ max_backoff: int = 86400000,
55
+ ) -> Callable:
56
+ """Register a function as a background task.
57
+
58
+ Args:
59
+ func: Function to register as a task
60
+ name: Optional name for the task (defaults to function name)
61
+ max_retries: Maximum number of retry attempts
62
+ min_backoff: Minimum backoff time in milliseconds
63
+ max_backoff: Maximum backoff time in milliseconds
64
+
65
+ Returns:
66
+ Decorated function that can be called normally or enqueued
67
+
68
+ Raises:
69
+ NotImplementedError: Must be implemented by subclass
70
+ """
71
+ raise NotImplementedError
72
+
73
+ @abstractmethod
74
+ def is_available(self) -> bool:
75
+ """Check if this queue driver is available and ready to use.
76
+
77
+ Returns:
78
+ True if driver is available, False otherwise
79
+ """
80
+ raise NotImplementedError
81
+
82
+ @abstractmethod
83
+ def get_stats(self) -> dict[str, Any]:
84
+ """Get queue statistics.
85
+
86
+ Returns:
87
+ Dictionary with queue statistics (pending, processing, completed, etc.)
88
+ """
89
+ raise NotImplementedError
90
+
91
+ @abstractmethod
92
+ def get_task_result(self, task_id: Any) -> dict[str, Any]:
93
+ """Get the result of a task by its ID.
94
+
95
+ Args:
96
+ task_id: Task identifier returned by enqueue()
97
+
98
+ Returns:
99
+ Dictionary with task status and result:
100
+ {
101
+ "status": "pending" | "processing" | "completed" | "failed",
102
+ "result": Any (if completed),
103
+ "error": str (if failed),
104
+ "progress": dict (if driver supports it)
105
+ }
106
+ """
107
+ raise NotImplementedError
108
+
109
+ @abstractmethod
110
+ def get_bulk_results(self, task_ids: list[Any]) -> dict[str, Any]:
111
+ """Get results for multiple tasks (for bulk feedback: x of y completed).
112
+
113
+ Args:
114
+ task_ids: List of task identifiers
115
+
116
+ Returns:
117
+ Dictionary with aggregated status:
118
+ {
119
+ "total": int,
120
+ "completed": int,
121
+ "failed": int,
122
+ "pending": int,
123
+ "processing": int,
124
+ "tasks": dict[task_id, status]
125
+ }
126
+ """
127
+ raise NotImplementedError
@@ -0,0 +1,22 @@
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
+ """Queue driver implementations."""
15
+
16
+ from __future__ import annotations
17
+
18
+ from coati_payroll.queue.drivers.dramatiq_driver import DramatiqDriver
19
+ from coati_payroll.queue.drivers.huey_driver import HueyDriver
20
+ from coati_payroll.queue.drivers.noop_driver import NoopQueueDriver
21
+
22
+ __all__ = ["DramatiqDriver", "HueyDriver", "NoopQueueDriver"]