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.
- coati_payroll/__init__.py +415 -0
- coati_payroll/app.py +95 -0
- coati_payroll/audit_helpers.py +904 -0
- coati_payroll/auth.py +123 -0
- coati_payroll/cli.py +1318 -0
- coati_payroll/config.py +219 -0
- coati_payroll/demo_data.py +813 -0
- coati_payroll/enums.py +278 -0
- coati_payroll/forms.py +1769 -0
- coati_payroll/formula_engine/__init__.py +81 -0
- coati_payroll/formula_engine/ast/__init__.py +110 -0
- coati_payroll/formula_engine/ast/ast_visitor.py +259 -0
- coati_payroll/formula_engine/ast/expression_evaluator.py +228 -0
- coati_payroll/formula_engine/ast/safe_operators.py +131 -0
- coati_payroll/formula_engine/ast/type_converter.py +172 -0
- coati_payroll/formula_engine/data_sources.py +752 -0
- coati_payroll/formula_engine/engine.py +247 -0
- coati_payroll/formula_engine/exceptions.py +52 -0
- coati_payroll/formula_engine/execution/__init__.py +24 -0
- coati_payroll/formula_engine/execution/execution_context.py +52 -0
- coati_payroll/formula_engine/execution/step_executor.py +62 -0
- coati_payroll/formula_engine/execution/variable_store.py +59 -0
- coati_payroll/formula_engine/novelty_codes.py +206 -0
- coati_payroll/formula_engine/results/__init__.py +20 -0
- coati_payroll/formula_engine/results/execution_result.py +59 -0
- coati_payroll/formula_engine/steps/__init__.py +30 -0
- coati_payroll/formula_engine/steps/assignment_step.py +71 -0
- coati_payroll/formula_engine/steps/base_step.py +48 -0
- coati_payroll/formula_engine/steps/calculation_step.py +42 -0
- coati_payroll/formula_engine/steps/conditional_step.py +122 -0
- coati_payroll/formula_engine/steps/step_factory.py +58 -0
- coati_payroll/formula_engine/steps/tax_lookup_step.py +45 -0
- coati_payroll/formula_engine/tables/__init__.py +24 -0
- coati_payroll/formula_engine/tables/bracket_calculator.py +51 -0
- coati_payroll/formula_engine/tables/table_lookup.py +161 -0
- coati_payroll/formula_engine/tables/tax_table.py +32 -0
- coati_payroll/formula_engine/validation/__init__.py +24 -0
- coati_payroll/formula_engine/validation/schema_validator.py +37 -0
- coati_payroll/formula_engine/validation/security_validator.py +52 -0
- coati_payroll/formula_engine/validation/tax_table_validator.py +205 -0
- coati_payroll/formula_engine_examples.py +153 -0
- coati_payroll/i18n.py +54 -0
- coati_payroll/initial_data.py +613 -0
- coati_payroll/interes_engine.py +450 -0
- coati_payroll/liquidacion_engine/__init__.py +25 -0
- coati_payroll/liquidacion_engine/engine.py +267 -0
- coati_payroll/locale_config.py +165 -0
- coati_payroll/log.py +138 -0
- coati_payroll/model.py +2410 -0
- coati_payroll/nomina_engine/__init__.py +87 -0
- coati_payroll/nomina_engine/calculators/__init__.py +30 -0
- coati_payroll/nomina_engine/calculators/benefit_calculator.py +79 -0
- coati_payroll/nomina_engine/calculators/concept_calculator.py +254 -0
- coati_payroll/nomina_engine/calculators/deduction_calculator.py +105 -0
- coati_payroll/nomina_engine/calculators/exchange_rate_calculator.py +51 -0
- coati_payroll/nomina_engine/calculators/perception_calculator.py +75 -0
- coati_payroll/nomina_engine/calculators/salary_calculator.py +86 -0
- coati_payroll/nomina_engine/domain/__init__.py +27 -0
- coati_payroll/nomina_engine/domain/calculation_items.py +52 -0
- coati_payroll/nomina_engine/domain/employee_calculation.py +53 -0
- coati_payroll/nomina_engine/domain/payroll_context.py +44 -0
- coati_payroll/nomina_engine/engine.py +188 -0
- coati_payroll/nomina_engine/processors/__init__.py +28 -0
- coati_payroll/nomina_engine/processors/accounting_processor.py +171 -0
- coati_payroll/nomina_engine/processors/accumulation_processor.py +90 -0
- coati_payroll/nomina_engine/processors/loan_processor.py +227 -0
- coati_payroll/nomina_engine/processors/novelty_processor.py +42 -0
- coati_payroll/nomina_engine/processors/vacation_processor.py +67 -0
- coati_payroll/nomina_engine/repositories/__init__.py +32 -0
- coati_payroll/nomina_engine/repositories/acumulado_repository.py +83 -0
- coati_payroll/nomina_engine/repositories/base_repository.py +40 -0
- coati_payroll/nomina_engine/repositories/config_repository.py +102 -0
- coati_payroll/nomina_engine/repositories/employee_repository.py +34 -0
- coati_payroll/nomina_engine/repositories/exchange_rate_repository.py +58 -0
- coati_payroll/nomina_engine/repositories/novelty_repository.py +54 -0
- coati_payroll/nomina_engine/repositories/planilla_repository.py +52 -0
- coati_payroll/nomina_engine/results/__init__.py +24 -0
- coati_payroll/nomina_engine/results/error_result.py +28 -0
- coati_payroll/nomina_engine/results/payroll_result.py +53 -0
- coati_payroll/nomina_engine/results/validation_result.py +39 -0
- coati_payroll/nomina_engine/services/__init__.py +22 -0
- coati_payroll/nomina_engine/services/accounting_voucher_service.py +708 -0
- coati_payroll/nomina_engine/services/employee_processing_service.py +173 -0
- coati_payroll/nomina_engine/services/payroll_execution_service.py +374 -0
- coati_payroll/nomina_engine/services/snapshot_service.py +295 -0
- coati_payroll/nomina_engine/validators/__init__.py +31 -0
- coati_payroll/nomina_engine/validators/base_validator.py +48 -0
- coati_payroll/nomina_engine/validators/currency_validator.py +50 -0
- coati_payroll/nomina_engine/validators/employee_validator.py +87 -0
- coati_payroll/nomina_engine/validators/period_validator.py +44 -0
- coati_payroll/nomina_engine/validators/planilla_validator.py +136 -0
- coati_payroll/plugin_manager.py +176 -0
- coati_payroll/queue/__init__.py +33 -0
- coati_payroll/queue/driver.py +127 -0
- coati_payroll/queue/drivers/__init__.py +22 -0
- coati_payroll/queue/drivers/dramatiq_driver.py +268 -0
- coati_payroll/queue/drivers/huey_driver.py +390 -0
- coati_payroll/queue/drivers/noop_driver.py +54 -0
- coati_payroll/queue/selector.py +121 -0
- coati_payroll/queue/tasks.py +764 -0
- coati_payroll/rate_limiting.py +83 -0
- coati_payroll/rbac.py +183 -0
- coati_payroll/report_engine.py +512 -0
- coati_payroll/report_export.py +208 -0
- coati_payroll/schema_validator.py +167 -0
- coati_payroll/security.py +77 -0
- coati_payroll/static/styles.css +1044 -0
- coati_payroll/system_reports.py +573 -0
- coati_payroll/templates/auth/login.html +189 -0
- coati_payroll/templates/base.html +283 -0
- coati_payroll/templates/index.html +227 -0
- coati_payroll/templates/macros.html +146 -0
- coati_payroll/templates/modules/calculation_rule/form.html +78 -0
- coati_payroll/templates/modules/calculation_rule/index.html +102 -0
- coati_payroll/templates/modules/calculation_rule/schema_editor.html +1159 -0
- coati_payroll/templates/modules/carga_inicial_prestacion/form.html +170 -0
- coati_payroll/templates/modules/carga_inicial_prestacion/index.html +170 -0
- coati_payroll/templates/modules/carga_inicial_prestacion/reporte.html +193 -0
- coati_payroll/templates/modules/config_calculos/index.html +44 -0
- coati_payroll/templates/modules/configuracion/index.html +90 -0
- coati_payroll/templates/modules/currency/form.html +47 -0
- coati_payroll/templates/modules/currency/index.html +64 -0
- coati_payroll/templates/modules/custom_field/form.html +62 -0
- coati_payroll/templates/modules/custom_field/index.html +78 -0
- coati_payroll/templates/modules/deduccion/form.html +1 -0
- coati_payroll/templates/modules/deduccion/index.html +1 -0
- coati_payroll/templates/modules/employee/form.html +254 -0
- coati_payroll/templates/modules/employee/index.html +76 -0
- coati_payroll/templates/modules/empresa/form.html +74 -0
- coati_payroll/templates/modules/empresa/index.html +71 -0
- coati_payroll/templates/modules/exchange_rate/form.html +47 -0
- coati_payroll/templates/modules/exchange_rate/import.html +93 -0
- coati_payroll/templates/modules/exchange_rate/index.html +114 -0
- coati_payroll/templates/modules/liquidacion/index.html +58 -0
- coati_payroll/templates/modules/liquidacion/nueva.html +51 -0
- coati_payroll/templates/modules/liquidacion/ver.html +91 -0
- coati_payroll/templates/modules/payroll_concepts/audit_log.html +146 -0
- coati_payroll/templates/modules/percepcion/form.html +1 -0
- coati_payroll/templates/modules/percepcion/index.html +1 -0
- coati_payroll/templates/modules/planilla/config.html +190 -0
- coati_payroll/templates/modules/planilla/config_deducciones.html +129 -0
- coati_payroll/templates/modules/planilla/config_empleados.html +116 -0
- coati_payroll/templates/modules/planilla/config_percepciones.html +113 -0
- coati_payroll/templates/modules/planilla/config_prestaciones.html +118 -0
- coati_payroll/templates/modules/planilla/config_reglas.html +120 -0
- coati_payroll/templates/modules/planilla/ejecutar_nomina.html +106 -0
- coati_payroll/templates/modules/planilla/form.html +197 -0
- coati_payroll/templates/modules/planilla/index.html +144 -0
- coati_payroll/templates/modules/planilla/listar_nominas.html +91 -0
- coati_payroll/templates/modules/planilla/log_nomina.html +135 -0
- coati_payroll/templates/modules/planilla/novedades/form.html +177 -0
- coati_payroll/templates/modules/planilla/novedades/index.html +170 -0
- coati_payroll/templates/modules/planilla/ver_nomina.html +477 -0
- coati_payroll/templates/modules/planilla/ver_nomina_empleado.html +231 -0
- coati_payroll/templates/modules/plugins/index.html +71 -0
- coati_payroll/templates/modules/prestacion/form.html +1 -0
- coati_payroll/templates/modules/prestacion/index.html +1 -0
- coati_payroll/templates/modules/prestacion_management/dashboard.html +150 -0
- coati_payroll/templates/modules/prestacion_management/initial_balance_bulk.html +195 -0
- coati_payroll/templates/modules/prestamo/approve.html +156 -0
- coati_payroll/templates/modules/prestamo/condonacion.html +249 -0
- coati_payroll/templates/modules/prestamo/detail.html +443 -0
- coati_payroll/templates/modules/prestamo/form.html +203 -0
- coati_payroll/templates/modules/prestamo/index.html +150 -0
- coati_payroll/templates/modules/prestamo/pago_extraordinario.html +211 -0
- coati_payroll/templates/modules/prestamo/tabla_pago_pdf.html +181 -0
- coati_payroll/templates/modules/report/admin_index.html +125 -0
- coati_payroll/templates/modules/report/detail.html +129 -0
- coati_payroll/templates/modules/report/execute.html +266 -0
- coati_payroll/templates/modules/report/index.html +95 -0
- coati_payroll/templates/modules/report/permissions.html +64 -0
- coati_payroll/templates/modules/settings/index.html +274 -0
- coati_payroll/templates/modules/shared/concept_form.html +201 -0
- coati_payroll/templates/modules/shared/concept_index.html +145 -0
- coati_payroll/templates/modules/tipo_planilla/form.html +70 -0
- coati_payroll/templates/modules/tipo_planilla/index.html +68 -0
- coati_payroll/templates/modules/user/form.html +65 -0
- coati_payroll/templates/modules/user/index.html +76 -0
- coati_payroll/templates/modules/user/profile.html +81 -0
- coati_payroll/templates/modules/vacation/account_detail.html +149 -0
- coati_payroll/templates/modules/vacation/account_form.html +52 -0
- coati_payroll/templates/modules/vacation/account_index.html +68 -0
- coati_payroll/templates/modules/vacation/dashboard.html +156 -0
- coati_payroll/templates/modules/vacation/initial_balance_bulk.html +149 -0
- coati_payroll/templates/modules/vacation/initial_balance_form.html +93 -0
- coati_payroll/templates/modules/vacation/leave_request_detail.html +158 -0
- coati_payroll/templates/modules/vacation/leave_request_form.html +61 -0
- coati_payroll/templates/modules/vacation/leave_request_index.html +98 -0
- coati_payroll/templates/modules/vacation/policy_detail.html +176 -0
- coati_payroll/templates/modules/vacation/policy_form.html +152 -0
- coati_payroll/templates/modules/vacation/policy_index.html +79 -0
- coati_payroll/templates/modules/vacation/register_taken_form.html +178 -0
- coati_payroll/translations/en/LC_MESSAGES/messages.mo +0 -0
- coati_payroll/translations/en/LC_MESSAGES/messages.po +7283 -0
- coati_payroll/translations/es/LC_MESSAGES/messages.mo +0 -0
- coati_payroll/translations/es/LC_MESSAGES/messages.po +7374 -0
- coati_payroll/vacation_service.py +451 -0
- coati_payroll/version.py +18 -0
- coati_payroll/vistas/__init__.py +64 -0
- coati_payroll/vistas/calculation_rule.py +307 -0
- coati_payroll/vistas/carga_inicial_prestacion.py +423 -0
- coati_payroll/vistas/config_calculos.py +72 -0
- coati_payroll/vistas/configuracion.py +87 -0
- coati_payroll/vistas/constants.py +17 -0
- coati_payroll/vistas/currency.py +112 -0
- coati_payroll/vistas/custom_field.py +120 -0
- coati_payroll/vistas/employee.py +305 -0
- coati_payroll/vistas/empresa.py +153 -0
- coati_payroll/vistas/exchange_rate.py +341 -0
- coati_payroll/vistas/liquidacion.py +205 -0
- coati_payroll/vistas/payroll_concepts.py +580 -0
- coati_payroll/vistas/planilla/__init__.py +38 -0
- coati_payroll/vistas/planilla/association_routes.py +238 -0
- coati_payroll/vistas/planilla/config_routes.py +158 -0
- coati_payroll/vistas/planilla/export_routes.py +175 -0
- coati_payroll/vistas/planilla/helpers/__init__.py +34 -0
- coati_payroll/vistas/planilla/helpers/association_helpers.py +161 -0
- coati_payroll/vistas/planilla/helpers/excel_helpers.py +29 -0
- coati_payroll/vistas/planilla/helpers/form_helpers.py +97 -0
- coati_payroll/vistas/planilla/nomina_routes.py +488 -0
- coati_payroll/vistas/planilla/novedad_routes.py +227 -0
- coati_payroll/vistas/planilla/routes.py +145 -0
- coati_payroll/vistas/planilla/services/__init__.py +26 -0
- coati_payroll/vistas/planilla/services/export_service.py +687 -0
- coati_payroll/vistas/planilla/services/nomina_service.py +233 -0
- coati_payroll/vistas/planilla/services/novedad_service.py +126 -0
- coati_payroll/vistas/planilla/services/planilla_service.py +34 -0
- coati_payroll/vistas/planilla/validators/__init__.py +18 -0
- coati_payroll/vistas/planilla/validators/planilla_validators.py +40 -0
- coati_payroll/vistas/plugins.py +45 -0
- coati_payroll/vistas/prestacion.py +272 -0
- coati_payroll/vistas/prestamo.py +808 -0
- coati_payroll/vistas/report.py +432 -0
- coati_payroll/vistas/settings.py +29 -0
- coati_payroll/vistas/tipo_planilla.py +134 -0
- coati_payroll/vistas/user.py +172 -0
- coati_payroll/vistas/vacation.py +1045 -0
- coati_payroll-0.0.2.dist-info/LICENSE +201 -0
- coati_payroll-0.0.2.dist-info/METADATA +581 -0
- coati_payroll-0.0.2.dist-info/RECORD +243 -0
- coati_payroll-0.0.2.dist-info/WHEEL +5 -0
- coati_payroll-0.0.2.dist-info/entry_points.txt +2 -0
- 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"]
|