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,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
|