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
coati_payroll/cli.py ADDED
@@ -0,0 +1,1318 @@
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
+ """Command line interface for Coati Payroll system administration."""
15
+
16
+ from __future__ import annotations
17
+
18
+ # <-------------------------------------------------------------------------> #
19
+ # Standard library
20
+ # <-------------------------------------------------------------------------> #
21
+ import sys
22
+ import os
23
+ import json as json_module
24
+ import getpass
25
+ import subprocess
26
+ import shutil
27
+ import sqlite3
28
+ from datetime import datetime
29
+ from pathlib import Path
30
+ from urllib.parse import urlparse
31
+
32
+ # <-------------------------------------------------------------------------> #
33
+ # Third party libraries
34
+ # <-------------------------------------------------------------------------> #
35
+ import click
36
+ from flask import current_app
37
+ from flask.cli import with_appcontext
38
+
39
+ # <-------------------------------------------------------------------------> #
40
+ # Local modules
41
+ # <-------------------------------------------------------------------------> #
42
+ from coati_payroll.model import db, Usuario
43
+ from coati_payroll.auth import proteger_passwd
44
+ from coati_payroll.log import log
45
+ from coati_payroll.plugin_manager import discover_installed_plugins, load_plugin_module
46
+
47
+
48
+ # Global context to store CLI options
49
+ class CLIContext:
50
+ def __init__(self):
51
+ self.environment = None
52
+ self.json_output = False
53
+ self.auto_yes = False
54
+
55
+
56
+ pass_context = click.make_pass_decorator(CLIContext, ensure=True)
57
+
58
+
59
+ def output_result(ctx, message, data=None, success=True):
60
+ """Output result in appropriate format (JSON or text)."""
61
+ if ctx.json_output:
62
+ result = {"success": success, "message": message}
63
+ if data:
64
+ result["data"] = data
65
+ click.echo(json_module.dumps(result, indent=2))
66
+ else:
67
+ symbol = "✓" if success else "✗"
68
+ click.echo(f"{symbol} {message}")
69
+
70
+
71
+ class PluginsCommand(click.MultiCommand):
72
+ def list_commands(self, cli_ctx):
73
+ try:
74
+ return [p.plugin_id for p in discover_installed_plugins()]
75
+ except Exception:
76
+ return []
77
+
78
+ def get_command(self, cli_ctx, name):
79
+ try:
80
+ module = load_plugin_module(name)
81
+ except Exception as exc:
82
+ message = str(exc)
83
+
84
+ def _missing():
85
+ raise click.ClickException(message)
86
+
87
+ return click.Command(name, callback=lambda: _missing())
88
+
89
+ @click.group(name=name)
90
+ def plugin_group():
91
+ """Empty group function that serves as a container for plugin subcommands.
92
+
93
+ Subcommands (init, update) are dynamically added below.
94
+ """
95
+ pass
96
+
97
+ @plugin_group.command("init")
98
+ @with_appcontext
99
+ @pass_context
100
+ def plugin_init(ctx):
101
+ init_fn = getattr(module, "init", None)
102
+ if init_fn is None or not callable(init_fn):
103
+ raise click.ClickException("Plugin does not provide callable 'init()'")
104
+
105
+ try:
106
+ init_fn()
107
+ db.create_all()
108
+ output_result(ctx, f"Plugin '{name}' initialized")
109
+ except Exception as exc:
110
+ log.exception("Plugin init failed")
111
+ output_result(ctx, f"Plugin '{name}' init failed: {exc}", None, False)
112
+ raise click.ClickException(str(exc))
113
+
114
+ @plugin_group.command("update")
115
+ @with_appcontext
116
+ @pass_context
117
+ def plugin_update(ctx):
118
+ update_fn = getattr(module, "update", None)
119
+ if update_fn is None or not callable(update_fn):
120
+ raise click.ClickException("Plugin does not provide callable 'update()'")
121
+
122
+ try:
123
+ update_fn()
124
+ db.create_all()
125
+ output_result(ctx, f"Plugin '{name}' updated")
126
+ except Exception as exc:
127
+ log.exception("Plugin update failed")
128
+ output_result(ctx, f"Plugin '{name}' update failed: {exc}", None, False)
129
+ raise click.ClickException(str(exc))
130
+
131
+ return plugin_group
132
+
133
+
134
+ plugins = PluginsCommand(name="plugins")
135
+
136
+
137
+ # ============================================================================
138
+ # SYSTEM COMMANDS
139
+ # ============================================================================
140
+
141
+
142
+ def _system_status():
143
+ """Get system status data.
144
+
145
+ Returns:
146
+ dict: Dictionary containing database status, admin user status, and app mode.
147
+ """
148
+ # Check database
149
+ db.session.execute(db.text("SELECT 1"))
150
+ db_status = "connected"
151
+
152
+ # Check admin user
153
+ admin = db.session.execute(db.select(Usuario).filter_by(tipo="admin", activo=True)).scalar_one_or_none()
154
+ admin_status = "active" if admin else "none"
155
+
156
+ # Get app mode
157
+ app_mode = os.environ.get("FLASK_ENV", "production")
158
+
159
+ return {"database": db_status, "admin_user": admin_status, "mode": app_mode}
160
+
161
+
162
+ @click.group()
163
+ def system():
164
+ """System-level operations."""
165
+ pass
166
+
167
+
168
+ @system.command("status")
169
+ @with_appcontext
170
+ @pass_context
171
+ def system_status(ctx):
172
+ """Show system status."""
173
+ try:
174
+ data = _system_status()
175
+
176
+ if ctx.json_output:
177
+ output_result(ctx, "System status", data, True)
178
+ else:
179
+ click.echo("System Status:")
180
+ click.echo(f" Database: {data['database']}")
181
+ click.echo(f" Admin User: {data['admin_user']}")
182
+ click.echo(f" Mode: {data['mode']}")
183
+
184
+ except Exception as e:
185
+ output_result(ctx, f"Failed to get system status: {e}", None, False)
186
+ sys.exit(1)
187
+
188
+
189
+ def _system_check():
190
+ """Run system checks and return results.
191
+
192
+ Returns:
193
+ list: List of check results with name, status, and optional error/missing data.
194
+ """
195
+ checks = []
196
+
197
+ # Check database connection
198
+ try:
199
+ db.session.execute(db.text("SELECT 1"))
200
+ checks.append({"name": "Database connection", "status": "OK"})
201
+ except Exception as e:
202
+ checks.append({"name": "Database connection", "status": "FAILED", "error": str(e)})
203
+
204
+ # Check admin user
205
+ try:
206
+ admin = db.session.execute(db.select(Usuario).filter_by(tipo="admin", activo=True)).scalar_one_or_none()
207
+ if admin:
208
+ checks.append({"name": "Active admin user", "status": "OK", "user": admin.usuario})
209
+ else:
210
+ checks.append({"name": "Active admin user", "status": "WARNING", "error": "No active admin"})
211
+ except Exception as e:
212
+ checks.append({"name": "Active admin user", "status": "FAILED", "error": str(e)})
213
+
214
+ # Check required tables
215
+ from sqlalchemy import inspect
216
+
217
+ inspector = inspect(db.engine)
218
+ tables = inspector.get_table_names()
219
+ required_tables = ["usuario", "moneda", "empleado", "planilla", "nomina"]
220
+ missing = [t for t in required_tables if t not in tables]
221
+
222
+ if missing:
223
+ checks.append({"name": "Required tables", "status": "WARNING", "missing": missing})
224
+ else:
225
+ checks.append({"name": "Required tables", "status": "OK", "count": len(required_tables)})
226
+
227
+ return checks
228
+
229
+
230
+ @system.command("check")
231
+ @with_appcontext
232
+ @pass_context
233
+ def system_check(ctx):
234
+ """Run system checks."""
235
+ try:
236
+ checks = _system_check()
237
+
238
+ if ctx.json_output:
239
+ output_result(ctx, "System checks completed", {"checks": checks}, True)
240
+ else:
241
+ click.echo("Running system checks...")
242
+ click.echo()
243
+ for check in checks:
244
+ status_symbol = "✓" if check["status"] == "OK" else ("⚠" if check["status"] == "WARNING" else "✗")
245
+ click.echo(f"{status_symbol} {check['name']}: {check['status']}")
246
+ if "error" in check:
247
+ click.echo(f" Error: {check['error']}")
248
+ if "missing" in check:
249
+ click.echo(f" Missing: {', '.join(check['missing'])}")
250
+
251
+ except Exception as e:
252
+ output_result(ctx, f"System check failed: {e}", None, False)
253
+ sys.exit(1)
254
+
255
+
256
+ def _system_info():
257
+ """Get system information.
258
+
259
+ Returns:
260
+ dict: Dictionary containing version, python version, database URI, and flask version.
261
+ """
262
+ from coati_payroll.version import __version__
263
+
264
+ info = {
265
+ "version": __version__,
266
+ "python": sys.version.split()[0],
267
+ "database_uri": "***" if "@" in str(db.engine.url) else str(db.engine.url),
268
+ }
269
+
270
+ try:
271
+ from importlib.metadata import version as get_version
272
+
273
+ info["flask"] = get_version("flask")
274
+ except Exception:
275
+ pass
276
+
277
+ return info
278
+
279
+
280
+ @system.command("info")
281
+ @with_appcontext
282
+ @pass_context
283
+ def system_info(ctx):
284
+ """Show system information."""
285
+ try:
286
+ info = _system_info()
287
+
288
+ if ctx.json_output:
289
+ output_result(ctx, "System information", info, True)
290
+ else:
291
+ click.echo("System Information:")
292
+ click.echo(f" Coati Payroll: {info['version']}")
293
+ click.echo(f" Python: {info['python']}")
294
+ if "flask" in info:
295
+ click.echo(f" Flask: {info['flask']}")
296
+ click.echo(f" Database: {info['database_uri']}")
297
+
298
+ except Exception as e:
299
+ output_result(ctx, f"Failed to get system info: {e}", None, False)
300
+ sys.exit(1)
301
+
302
+
303
+ def _system_env():
304
+ """Get environment variables.
305
+
306
+ Returns:
307
+ dict: Dictionary containing relevant environment variables.
308
+ """
309
+ return {
310
+ "FLASK_APP": os.environ.get("FLASK_APP", "not set"),
311
+ "FLASK_ENV": os.environ.get("FLASK_ENV", "not set"),
312
+ "DATABASE_URL": "***" if os.environ.get("DATABASE_URL") else "not set",
313
+ "ADMIN_USER": os.environ.get("ADMIN_USER", "not set"),
314
+ "COATI_LANG": os.environ.get("COATI_LANG", "not set"),
315
+ }
316
+
317
+
318
+ @system.command("env")
319
+ @pass_context
320
+ def system_env(ctx):
321
+ """Show environment variables."""
322
+ env_vars = _system_env()
323
+
324
+ if ctx.json_output:
325
+ output_result(ctx, "Environment variables", env_vars, True)
326
+ else:
327
+ click.echo("Environment Variables:")
328
+ for key, value in env_vars.items():
329
+ click.echo(f" {key}: {value}")
330
+
331
+
332
+ # ============================================================================
333
+ # DATABASE COMMANDS
334
+ # ============================================================================
335
+
336
+
337
+ def _database_status():
338
+ """Get database status.
339
+
340
+ Returns:
341
+ dict: Dictionary containing table count, table names, and record counts.
342
+ """
343
+ from sqlalchemy import inspect
344
+
345
+ inspector = inspect(db.engine)
346
+ tables = inspector.get_table_names()
347
+
348
+ # Count records in key tables
349
+ counts = {}
350
+ for table in ["usuario", "empleado", "nomina"]:
351
+ if table in tables:
352
+ result = db.session.execute(db.text(f"SELECT COUNT(*) FROM {table}"))
353
+ counts[table] = result.scalar()
354
+
355
+ return {"tables": len(tables), "table_names": tables[:10], "record_counts": counts}
356
+
357
+
358
+ @click.group()
359
+ def database():
360
+ """Database management commands."""
361
+ pass
362
+
363
+
364
+ @database.command("status")
365
+ @with_appcontext
366
+ @pass_context
367
+ def database_status(ctx):
368
+ """Show database status."""
369
+ try:
370
+ data = _database_status()
371
+
372
+ if ctx.json_output:
373
+ output_result(ctx, "Database status", data, True)
374
+ else:
375
+ click.echo("Database Status:")
376
+ click.echo(f" Tables: {data['tables']}")
377
+ click.echo(" Records:")
378
+ for table, count in data["record_counts"].items():
379
+ click.echo(f" {table}: {count}")
380
+
381
+ except Exception as e:
382
+ output_result(ctx, f"Failed to get database status: {e}", None, False)
383
+ sys.exit(1)
384
+
385
+
386
+ def _database_init(app):
387
+ """Initialize database tables and admin user.
388
+
389
+ Args:
390
+ app: Flask application instance
391
+
392
+ Returns:
393
+ str: Admin username that was created/initialized
394
+ """
395
+ from coati_payroll import ensure_database_initialized
396
+
397
+ db.create_all()
398
+ ensure_database_initialized(app)
399
+
400
+ return os.environ.get("ADMIN_USER", "coati-admin")
401
+
402
+
403
+ @database.command("init")
404
+ @with_appcontext
405
+ @pass_context
406
+ def database_init(ctx):
407
+ """Initialize database tables and create admin user."""
408
+ try:
409
+ click.echo("Initializing database...")
410
+
411
+ admin_user = _database_init(current_app)
412
+
413
+ output_result(ctx, "Database tables created")
414
+ output_result(ctx, f"Administrator user '{admin_user}' is ready")
415
+
416
+ if not ctx.json_output:
417
+ click.echo()
418
+ click.echo("Database initialization complete!")
419
+
420
+ except Exception as e:
421
+ output_result(ctx, f"Failed to initialize database: {e}", None, False)
422
+ log.exception("Failed to initialize database")
423
+ sys.exit(1)
424
+
425
+
426
+ def _database_seed():
427
+ """Seed database with initial data."""
428
+ from coati_payroll.initial_data import load_initial_data
429
+
430
+ db.create_all()
431
+ load_initial_data()
432
+
433
+
434
+ @database.command("seed")
435
+ @with_appcontext
436
+ @pass_context
437
+ def database_seed(ctx):
438
+ """Create tables if needed and load initial data."""
439
+ try:
440
+ click.echo("Seeding database with initial data...")
441
+
442
+ _database_seed()
443
+
444
+ output_result(ctx, "Database tables verified")
445
+ output_result(ctx, "Initial data loaded")
446
+
447
+ if not ctx.json_output:
448
+ click.echo()
449
+ click.echo("Database seeding complete!")
450
+
451
+ except Exception as e:
452
+ output_result(ctx, f"Failed to seed database: {e}", None, False)
453
+ log.exception("Failed to seed database")
454
+ sys.exit(1)
455
+
456
+
457
+ def _database_drop():
458
+ """Drop all database tables."""
459
+ db.drop_all()
460
+
461
+
462
+ @database.command("drop")
463
+ @click.confirmation_option(prompt="Are you sure you want to drop all tables? This will DELETE ALL DATA!")
464
+ @with_appcontext
465
+ @pass_context
466
+ def database_drop(ctx):
467
+ """Remove all database tables."""
468
+ try:
469
+ click.echo("Dropping all database tables...")
470
+ _database_drop()
471
+ output_result(ctx, "All database tables have been dropped")
472
+
473
+ if not ctx.json_output:
474
+ click.echo()
475
+ click.echo("Database drop complete!")
476
+
477
+ except Exception as e:
478
+ output_result(ctx, f"Failed to drop database: {e}", None, False)
479
+ log.exception("Failed to drop database")
480
+ sys.exit(1)
481
+
482
+
483
+ def _backup_sqlite(db_url_str, output=None):
484
+ """Backup SQLite database.
485
+
486
+ Args:
487
+ db_url_str: Database URL string
488
+ output: Optional output file path
489
+
490
+ Returns:
491
+ Path: Output file path
492
+ """
493
+ db_path = db_url_str.replace("sqlite:///", "").replace("sqlite://", "")
494
+
495
+ # Remove query parameters if present (e.g., ?check_same_thread=False)
496
+ if "?" in db_path:
497
+ db_path = db_path.split("?")[0]
498
+
499
+ if output is None:
500
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
501
+ output = f"coati_backup_{timestamp}.db"
502
+
503
+ output_path = Path(output)
504
+
505
+ if db_path == ":memory:":
506
+ source_conn = db.engine.raw_connection()
507
+ dest_conn = sqlite3.connect(str(output_path))
508
+ source_conn.backup(dest_conn)
509
+ dest_conn.close()
510
+ else:
511
+ shutil.copy2(db_path, output_path)
512
+
513
+ return output_path
514
+
515
+
516
+ def _backup_postgresql(db_url_str, output=None):
517
+ """Backup PostgreSQL database.
518
+
519
+ Args:
520
+ db_url_str: Database URL string
521
+ output: Optional output file path
522
+
523
+ Returns:
524
+ Path: Output file path
525
+ """
526
+ parsed = urlparse(db_url_str)
527
+
528
+ if output is None:
529
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
530
+ output = f"coati_backup_{timestamp}.sql"
531
+
532
+ output_path = Path(output)
533
+
534
+ cmd = ["pg_dump"]
535
+
536
+ if parsed.hostname:
537
+ cmd.extend(["-h", parsed.hostname])
538
+ if parsed.port:
539
+ cmd.extend(["-p", str(parsed.port)])
540
+ if parsed.username:
541
+ cmd.extend(["-U", parsed.username])
542
+
543
+ db_name = parsed.path.lstrip("/")
544
+ cmd.append(db_name)
545
+
546
+ env = os.environ.copy()
547
+ if parsed.password:
548
+ env["PGPASSWORD"] = parsed.password
549
+
550
+ with output_path.open("w") as f:
551
+ result = subprocess.run(cmd, stdout=f, stderr=subprocess.PIPE, env=env, text=True)
552
+
553
+ if result.returncode != 0:
554
+ raise RuntimeError(f"pg_dump failed: {result.stderr}")
555
+
556
+ return output_path
557
+
558
+
559
+ def _backup_mysql(db_url_str, output=None):
560
+ """Backup MySQL database.
561
+
562
+ Args:
563
+ db_url_str: Database URL string
564
+ output: Optional output file path
565
+
566
+ Returns:
567
+ Path: Output file path
568
+ """
569
+ parsed = urlparse(db_url_str)
570
+
571
+ if output is None:
572
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
573
+ output = f"coati_backup_{timestamp}.sql"
574
+
575
+ output_path = Path(output)
576
+
577
+ cmd = ["mysqldump"]
578
+
579
+ if parsed.hostname:
580
+ cmd.extend(["-h", parsed.hostname])
581
+ if parsed.port:
582
+ cmd.extend(["-P", str(parsed.port)])
583
+ if parsed.username:
584
+ cmd.extend(["-u", parsed.username])
585
+
586
+ db_name = parsed.path.lstrip("/")
587
+ cmd.append(db_name)
588
+
589
+ env = os.environ.copy()
590
+ if parsed.password:
591
+ env["MYSQL_PWD"] = parsed.password
592
+
593
+ with output_path.open("w") as f:
594
+ result = subprocess.run(cmd, stdout=f, stderr=subprocess.PIPE, env=env, text=True)
595
+
596
+ if result.returncode != 0:
597
+ raise RuntimeError(f"mysqldump failed: {result.stderr}")
598
+
599
+ return output_path
600
+
601
+
602
+ @database.command("backup")
603
+ @click.option("--output", "-o", default=None, help="Output file path (default: auto-generated with timestamp)")
604
+ @with_appcontext
605
+ @pass_context
606
+ def database_backup(ctx, output):
607
+ """Create a database backup using native database tools."""
608
+ try:
609
+ db_url_str = str(db.engine.url)
610
+
611
+ if db_url_str.startswith("sqlite"):
612
+ db_path = db_url_str.replace("sqlite:///", "").replace("sqlite://", "")
613
+
614
+ click.echo("Creating SQLite backup...")
615
+ if db_path == ":memory:":
616
+ click.echo("Source: in-memory database")
617
+ else:
618
+ click.echo(f"Source: {db_path}")
619
+
620
+ output_path = _backup_sqlite(db_url_str, output)
621
+
622
+ click.echo()
623
+ output_result(ctx, "Backup completed successfully!")
624
+ click.echo(" Database type: SQLite")
625
+ click.echo(f" Output file: {output_path.absolute()}")
626
+
627
+ elif "postgresql" in db_url_str or "postgres" in db_url_str:
628
+ parsed = urlparse(db_url_str)
629
+
630
+ click.echo("Creating PostgreSQL backup...")
631
+ click.echo(f"Database: {parsed.path.lstrip('/')}")
632
+
633
+ output_path = _backup_postgresql(db_url_str, output)
634
+
635
+ click.echo()
636
+ output_result(ctx, "Backup completed successfully!")
637
+ click.echo(" Database type: PostgreSQL")
638
+ click.echo(f" Output file: {output_path.absolute()}")
639
+
640
+ elif "mysql" in db_url_str:
641
+ parsed = urlparse(db_url_str)
642
+
643
+ click.echo("Creating MySQL backup...")
644
+ click.echo(f"Database: {parsed.path.lstrip('/')}")
645
+
646
+ output_path = _backup_mysql(db_url_str, output)
647
+
648
+ click.echo()
649
+ output_result(ctx, "Backup completed successfully!")
650
+ click.echo(" Database type: MySQL")
651
+ click.echo(f" Output file: {output_path.absolute()}")
652
+
653
+ else:
654
+ click.echo(f"Error: Unsupported database type: {db_url_str}", err=True)
655
+ click.echo("Supported databases: SQLite, PostgreSQL, MySQL")
656
+ sys.exit(1)
657
+
658
+ except Exception as e:
659
+ output_result(ctx, f"Failed to create backup: {e}", None, False)
660
+ log.exception("Failed to create database backup")
661
+ sys.exit(1)
662
+
663
+
664
+ def _database_restore_sqlite(backup_file, db_url_str):
665
+ """Restore SQLite database from backup.
666
+
667
+ Args:
668
+ backup_file: Path to backup file
669
+ db_url_str: Database URL string
670
+ """
671
+ backup_path = Path(backup_file)
672
+ if not backup_path.exists():
673
+ raise FileNotFoundError(f"Backup file not found: {backup_file}")
674
+
675
+ db_path = db_url_str.replace("sqlite:///", "").replace("sqlite://", "")
676
+
677
+ if db_path == ":memory:":
678
+ raise ValueError("Cannot restore to in-memory database")
679
+
680
+ shutil.copy2(backup_path, db_path)
681
+
682
+
683
+ @database.command("restore")
684
+ @click.argument("backup_file")
685
+ @click.option("--yes", is_flag=True, help="Skip confirmation")
686
+ @with_appcontext
687
+ @pass_context
688
+ def database_restore(ctx, backup_file, yes):
689
+ """Restore database from backup file."""
690
+ if not yes and not ctx.auto_yes:
691
+ if not click.confirm("This will overwrite the current database. Continue?"):
692
+ click.echo("Restore cancelled.")
693
+ return
694
+
695
+ try:
696
+ db_url_str = str(db.engine.url)
697
+
698
+ if db_url_str.startswith("sqlite"):
699
+ click.echo(f"Restoring SQLite database from: {backup_file}")
700
+ _database_restore_sqlite(backup_file, db_url_str)
701
+ output_result(ctx, "Database restored successfully!")
702
+
703
+ else:
704
+ output_result(ctx, "Restore only supported for SQLite currently", None, False)
705
+ sys.exit(1)
706
+
707
+ except Exception as e:
708
+ output_result(ctx, f"Failed to restore database: {e}", None, False)
709
+ log.exception("Failed to restore database")
710
+ sys.exit(1)
711
+
712
+
713
+ @database.command("migrate")
714
+ @with_appcontext
715
+ @pass_context
716
+ def database_migrate(ctx):
717
+ """Generate database migration."""
718
+ try:
719
+ # Try to use flask-migrate
720
+ try:
721
+ from flask_migrate import Migrate, init, migrate # noqa: F401
722
+
723
+ click.echo("Generating database migration...")
724
+ # This would need proper setup
725
+ output_result(ctx, "Migration support requires flask-migrate setup")
726
+
727
+ except ImportError:
728
+ output_result(ctx, "flask-migrate not installed. Run: pip install flask-migrate", None, False)
729
+ sys.exit(1)
730
+
731
+ except Exception as e:
732
+ output_result(ctx, f"Failed to generate migration: {e}", None, False)
733
+ sys.exit(1)
734
+
735
+
736
+ @database.command("upgrade")
737
+ @with_appcontext
738
+ @pass_context
739
+ def database_upgrade(ctx):
740
+ """Apply database migrations."""
741
+ try:
742
+ try:
743
+ from flask_migrate import upgrade # noqa: F401
744
+
745
+ click.echo("Applying database migrations...")
746
+ output_result(ctx, "Migration support requires flask-migrate setup")
747
+
748
+ except ImportError:
749
+ output_result(ctx, "flask-migrate not installed. Run: pip install flask-migrate", None, False)
750
+ sys.exit(1)
751
+
752
+ except Exception as e:
753
+ output_result(ctx, f"Failed to apply migrations: {e}", None, False)
754
+ sys.exit(1)
755
+
756
+
757
+ # ============================================================================
758
+ # USER/USERS COMMANDS
759
+ # ============================================================================
760
+
761
+
762
+ def _users_list():
763
+ """Get list of all users.
764
+
765
+ Returns:
766
+ list: List of user dictionaries with username, name, type, active status, and email.
767
+ """
768
+ all_users = db.session.execute(db.select(Usuario)).scalars().all()
769
+
770
+ return [
771
+ {
772
+ "username": user.usuario,
773
+ "name": f"{user.nombre} {user.apellido}".strip(),
774
+ "type": user.tipo,
775
+ "active": user.activo,
776
+ "email": user.correo_electronico,
777
+ }
778
+ for user in all_users
779
+ ]
780
+
781
+
782
+ @click.group()
783
+ def users():
784
+ """User management commands."""
785
+ pass
786
+
787
+
788
+ @users.command("list")
789
+ @with_appcontext
790
+ @pass_context
791
+ def users_list(ctx):
792
+ """List all users."""
793
+ try:
794
+ users_data = _users_list()
795
+
796
+ if ctx.json_output:
797
+ output_result(ctx, "Users retrieved", {"count": len(users_data), "users": users_data}, True)
798
+ else:
799
+ click.echo(f"Users ({len(users_data)}):")
800
+ click.echo()
801
+ for user_data in users_data:
802
+ status = "active" if user_data["active"] else "inactive"
803
+ click.echo(f" {user_data['username']} ({user_data['type']}) - {status}")
804
+ if user_data["name"]:
805
+ click.echo(f" Name: {user_data['name']}")
806
+ if user_data["email"]:
807
+ click.echo(f" Email: {user_data['email']}")
808
+
809
+ except Exception as e:
810
+ output_result(ctx, f"Failed to list users: {e}", None, False)
811
+ sys.exit(1)
812
+
813
+
814
+ def _users_create(username, password, name, email, user_type):
815
+ """Create a new user.
816
+
817
+ Args:
818
+ username: Username for the new user
819
+ password: Password for the new user
820
+ name: Full name of the user
821
+ email: Email address (optional)
822
+ user_type: Type of user (admin or operador)
823
+
824
+ Raises:
825
+ ValueError: If user already exists
826
+ """
827
+ existing = db.session.execute(db.select(Usuario).filter_by(usuario=username)).scalar_one_or_none()
828
+
829
+ if existing:
830
+ raise ValueError(f"User '{username}' already exists")
831
+
832
+ user = Usuario()
833
+ user.usuario = username
834
+ user.acceso = proteger_passwd(password)
835
+
836
+ # Split name into first and last
837
+ name_parts = name.split(maxsplit=1)
838
+ user.nombre = name_parts[0]
839
+ user.apellido = name_parts[1] if len(name_parts) > 1 else ""
840
+
841
+ user.correo_electronico = email
842
+ user.tipo = user_type
843
+ user.activo = True
844
+
845
+ db.session.add(user)
846
+ db.session.commit()
847
+
848
+
849
+ @users.command("create")
850
+ @click.option("--username", prompt=True, help="Username")
851
+ @click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True, help="Password")
852
+ @click.option("--name", prompt=True, help="Full name")
853
+ @click.option("--email", default=None, help="Email address")
854
+ @click.option("--type", "user_type", type=click.Choice(["admin", "operador"]), default="operador", help="User type")
855
+ @with_appcontext
856
+ @pass_context
857
+ def users_create(ctx, username, password, name, email, user_type):
858
+ """Create a new user."""
859
+ try:
860
+ _users_create(username, password, name, email, user_type)
861
+ output_result(ctx, f"User '{username}' created successfully!")
862
+
863
+ except Exception as e:
864
+ db.session.rollback()
865
+ output_result(ctx, f"Failed to create user: {e}", None, False)
866
+ sys.exit(1)
867
+
868
+
869
+ def _users_disable(username):
870
+ """Disable a user.
871
+
872
+ Args:
873
+ username: Username to disable
874
+
875
+ Raises:
876
+ ValueError: If user not found
877
+ """
878
+ user = db.session.execute(db.select(Usuario).filter_by(usuario=username)).scalar_one_or_none()
879
+
880
+ if not user:
881
+ raise ValueError(f"User '{username}' not found")
882
+
883
+ user.activo = False
884
+ db.session.commit()
885
+
886
+
887
+ @users.command("disable")
888
+ @click.argument("username")
889
+ @with_appcontext
890
+ @pass_context
891
+ def users_disable(ctx, username):
892
+ """Disable a user."""
893
+ try:
894
+ _users_disable(username)
895
+ output_result(ctx, f"User '{username}' disabled successfully!")
896
+
897
+ except Exception as e:
898
+ db.session.rollback()
899
+ output_result(ctx, f"Failed to disable user: {e}", None, False)
900
+ sys.exit(1)
901
+
902
+
903
+ def _users_reset_password(username, password):
904
+ """Reset user password.
905
+
906
+ Args:
907
+ username: Username to reset password for
908
+ password: New password
909
+
910
+ Raises:
911
+ ValueError: If user not found
912
+ """
913
+ user = db.session.execute(db.select(Usuario).filter_by(usuario=username)).scalar_one_or_none()
914
+
915
+ if not user:
916
+ raise ValueError(f"User '{username}' not found")
917
+
918
+ user.acceso = proteger_passwd(password)
919
+ db.session.commit()
920
+
921
+
922
+ @users.command("reset-password")
923
+ @click.argument("username")
924
+ @click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True, help="New password")
925
+ @with_appcontext
926
+ @pass_context
927
+ def users_reset_password(ctx, username, password):
928
+ """Reset user password."""
929
+ try:
930
+ _users_reset_password(username, password)
931
+ output_result(ctx, f"Password reset for user '{username}'!")
932
+
933
+ except Exception as e:
934
+ db.session.rollback()
935
+ output_result(ctx, f"Failed to reset password: {e}", None, False)
936
+ sys.exit(1)
937
+
938
+
939
+ def _users_set_admin(username, password):
940
+ """Create or update an administrator user.
941
+
942
+ Args:
943
+ username: Username for the admin
944
+ password: Password for the admin
945
+
946
+ Returns:
947
+ tuple: (is_new_user, existing_admin_count) - whether user was created or updated,
948
+ and how many existing admins were deactivated
949
+ """
950
+ admins = db.session.execute(db.select(Usuario).filter_by(tipo="admin")).scalars().all()
951
+ deactivated_count = 0
952
+
953
+ if admins:
954
+ for admin in admins:
955
+ admin.activo = False
956
+ deactivated_count += 1
957
+
958
+ existing_user = db.session.execute(db.select(Usuario).filter_by(usuario=username)).scalar_one_or_none()
959
+
960
+ if existing_user:
961
+ existing_user.acceso = proteger_passwd(password)
962
+ existing_user.tipo = "admin"
963
+ existing_user.activo = True
964
+ db.session.commit()
965
+ return False, deactivated_count
966
+ else:
967
+ new_user = Usuario()
968
+ new_user.usuario = username
969
+ new_user.acceso = proteger_passwd(password)
970
+ new_user.nombre = "Administrator"
971
+ new_user.apellido = ""
972
+ new_user.correo_electronico = None
973
+ new_user.tipo = "admin"
974
+ new_user.activo = True
975
+
976
+ db.session.add(new_user)
977
+ db.session.commit()
978
+ return True, deactivated_count
979
+
980
+
981
+ @users.command("set-admin")
982
+ @with_appcontext
983
+ @pass_context
984
+ def users_set_admin(ctx):
985
+ """Create or update an administrator user (legacy command)."""
986
+ click.echo("=== Set Administrator User ===")
987
+ click.echo()
988
+
989
+ username = click.prompt("Enter username", type=str)
990
+
991
+ if not username or not username.strip():
992
+ click.echo("Error: Username cannot be empty", err=True)
993
+ sys.exit(1)
994
+
995
+ username = username.strip()
996
+
997
+ password = getpass.getpass("Enter password: ")
998
+ password_confirm = getpass.getpass("Confirm password: ")
999
+
1000
+ if password != password_confirm:
1001
+ click.echo("Error: Passwords do not match", err=True)
1002
+ sys.exit(1)
1003
+
1004
+ if not password:
1005
+ click.echo("Error: Password cannot be empty", err=True)
1006
+ sys.exit(1)
1007
+
1008
+ try:
1009
+ is_new, deactivated_count = _users_set_admin(username, password)
1010
+
1011
+ if deactivated_count > 0:
1012
+ click.echo(f"Deactivated {deactivated_count} existing admin user(s)")
1013
+
1014
+ if is_new:
1015
+ output_result(ctx, f"Successfully created user '{username}' as administrator")
1016
+ else:
1017
+ output_result(ctx, f"Successfully updated user '{username}' as administrator")
1018
+
1019
+ click.echo()
1020
+ click.echo("All other admin users have been deactivated.")
1021
+
1022
+ except Exception as e:
1023
+ db.session.rollback()
1024
+ click.echo(f"Error: Failed to set administrator user: {e}", err=True)
1025
+ log.exception("Failed to set administrator user")
1026
+ sys.exit(1)
1027
+
1028
+
1029
+ # ============================================================================
1030
+ # CACHE COMMANDS
1031
+ # ============================================================================
1032
+
1033
+
1034
+ def _cache_clear():
1035
+ """Clear application caches."""
1036
+ from coati_payroll.locale_config import invalidate_language_cache
1037
+
1038
+ invalidate_language_cache()
1039
+
1040
+
1041
+ def _cache_warm():
1042
+ """Warm up caches.
1043
+
1044
+ Returns:
1045
+ str: Language code that was cached
1046
+ """
1047
+ from coati_payroll.locale_config import get_language_from_db
1048
+
1049
+ return get_language_from_db()
1050
+
1051
+
1052
+ def _cache_status():
1053
+ """Get cache status.
1054
+
1055
+ Returns:
1056
+ dict: Cache status information
1057
+ """
1058
+ from coati_payroll.locale_config import _language_cache
1059
+
1060
+ return {"language_cache": "populated" if _language_cache else "empty"}
1061
+
1062
+
1063
+ @click.group()
1064
+ def cache():
1065
+ """Cache and temporary data management."""
1066
+ pass
1067
+
1068
+
1069
+ @cache.command("clear")
1070
+ @with_appcontext
1071
+ @pass_context
1072
+ def cache_clear(ctx):
1073
+ """Clear application caches."""
1074
+ try:
1075
+ click.echo("Clearing application caches...")
1076
+
1077
+ _cache_clear()
1078
+ output_result(ctx, "Language cache cleared")
1079
+
1080
+ if not ctx.json_output:
1081
+ click.echo()
1082
+ click.echo("✓ Cache cleared successfully!")
1083
+
1084
+ except Exception as e:
1085
+ output_result(ctx, f"Failed to clear cache: {e}", None, False)
1086
+ log.exception("Failed to clear cache")
1087
+ sys.exit(1)
1088
+
1089
+
1090
+ @cache.command("warm")
1091
+ @with_appcontext
1092
+ @pass_context
1093
+ def cache_warm(ctx):
1094
+ """Warm up caches."""
1095
+ try:
1096
+ lang = _cache_warm()
1097
+ output_result(ctx, f"Language cache warmed ({lang})")
1098
+
1099
+ if not ctx.json_output:
1100
+ click.echo("✓ Cache warmed successfully!")
1101
+
1102
+ except Exception as e:
1103
+ output_result(ctx, f"Failed to warm cache: {e}", None, False)
1104
+ sys.exit(1)
1105
+
1106
+
1107
+ @cache.command("status")
1108
+ @with_appcontext
1109
+ @pass_context
1110
+ def cache_status(ctx):
1111
+ """Show cache status."""
1112
+ try:
1113
+ cache_info = _cache_status()
1114
+
1115
+ if ctx.json_output:
1116
+ output_result(ctx, "Cache status", cache_info, True)
1117
+ else:
1118
+ click.echo("Cache Status:")
1119
+ click.echo(f" Language: {cache_info['language_cache']}")
1120
+
1121
+ except Exception as e:
1122
+ output_result(ctx, f"Failed to get cache status: {e}", None, False)
1123
+ sys.exit(1)
1124
+
1125
+
1126
+ # ============================================================================
1127
+ # MAINTENANCE COMMANDS
1128
+ # ============================================================================
1129
+
1130
+
1131
+ @click.group()
1132
+ def maintenance():
1133
+ """Background jobs and cleanup tasks."""
1134
+ pass
1135
+
1136
+
1137
+ @maintenance.command("cleanup-sessions")
1138
+ @with_appcontext
1139
+ @pass_context
1140
+ def maintenance_cleanup_sessions(ctx):
1141
+ """Clean up expired sessions."""
1142
+ try:
1143
+ # This would clean up Flask-Session data
1144
+ click.echo("Cleaning up expired sessions...")
1145
+ output_result(ctx, "Session cleanup completed")
1146
+
1147
+ except Exception as e:
1148
+ output_result(ctx, f"Failed to cleanup sessions: {e}", None, False)
1149
+ sys.exit(1)
1150
+
1151
+
1152
+ @maintenance.command("cleanup-temp")
1153
+ @with_appcontext
1154
+ @pass_context
1155
+ def maintenance_cleanup_temp(ctx):
1156
+ """Clean up temporary files."""
1157
+ try:
1158
+ click.echo("Cleaning up temporary files...")
1159
+ output_result(ctx, "Temporary file cleanup completed")
1160
+
1161
+ except Exception as e:
1162
+ output_result(ctx, f"Failed to cleanup temp files: {e}", None, False)
1163
+ sys.exit(1)
1164
+
1165
+
1166
+ @maintenance.command("run-jobs")
1167
+ @with_appcontext
1168
+ @pass_context
1169
+ def maintenance_run_jobs(ctx):
1170
+ """Run pending background jobs."""
1171
+ try:
1172
+ click.echo("Running pending background jobs...")
1173
+ output_result(ctx, "Background jobs completed")
1174
+
1175
+ except Exception as e:
1176
+ output_result(ctx, f"Failed to run jobs: {e}", None, False)
1177
+ sys.exit(1)
1178
+
1179
+
1180
+ # ============================================================================
1181
+ # DEBUG COMMANDS
1182
+ # ============================================================================
1183
+
1184
+
1185
+ def _debug_config(app):
1186
+ """Get application configuration.
1187
+
1188
+ Args:
1189
+ app: Flask application instance
1190
+
1191
+ Returns:
1192
+ dict: Configuration data
1193
+ """
1194
+ return {
1195
+ "SQLALCHEMY_DATABASE_URI": "***" if "@" in str(db.engine.url) else str(db.engine.url),
1196
+ "TESTING": app.config.get("TESTING", False),
1197
+ "DEBUG": app.config.get("DEBUG", False),
1198
+ }
1199
+
1200
+
1201
+ def _debug_routes(app):
1202
+ """Get application routes.
1203
+
1204
+ Args:
1205
+ app: Flask application instance
1206
+
1207
+ Returns:
1208
+ list: List of route dictionaries with endpoint, methods, and path
1209
+ """
1210
+ routes = []
1211
+ for rule in app.url_map.iter_rules():
1212
+ routes.append({"endpoint": rule.endpoint, "methods": sorted(rule.methods), "path": str(rule)})
1213
+ return routes
1214
+
1215
+
1216
+ @click.group()
1217
+ def debug():
1218
+ """Diagnostics and troubleshooting."""
1219
+ pass
1220
+
1221
+
1222
+ @debug.command("config")
1223
+ @with_appcontext
1224
+ @pass_context
1225
+ def debug_config(ctx):
1226
+ """Show application configuration."""
1227
+ try:
1228
+ config_data = _debug_config(current_app)
1229
+
1230
+ if ctx.json_output:
1231
+ output_result(ctx, "Configuration", config_data, True)
1232
+ else:
1233
+ click.echo("Application Configuration:")
1234
+ for key, value in config_data.items():
1235
+ click.echo(f" {key}: {value}")
1236
+
1237
+ except Exception as e:
1238
+ output_result(ctx, f"Failed to get config: {e}", None, False)
1239
+ sys.exit(1)
1240
+
1241
+
1242
+ @debug.command("routes")
1243
+ @with_appcontext
1244
+ @pass_context
1245
+ def debug_routes(ctx):
1246
+ """List all application routes."""
1247
+ try:
1248
+ routes = _debug_routes(current_app)
1249
+
1250
+ if ctx.json_output:
1251
+ output_result(ctx, "Routes", {"count": len(routes), "routes": routes}, True)
1252
+ else:
1253
+ click.echo(f"Application Routes ({len(routes)}):")
1254
+ for route in routes[:20]: # Limit display
1255
+ methods = ", ".join(route["methods"])
1256
+ click.echo(f" {route['path']} [{methods}]")
1257
+ if len(routes) > 20:
1258
+ click.echo(f" ... and {len(routes) - 20} more")
1259
+
1260
+ except Exception as e:
1261
+ output_result(ctx, f"Failed to list routes: {e}", None, False)
1262
+ sys.exit(1)
1263
+
1264
+
1265
+ # ============================================================================
1266
+ # REGISTRATION AND MAIN ENTRY POINT
1267
+ # ============================================================================
1268
+
1269
+
1270
+ def register_cli_commands(app):
1271
+ """Register all CLI commands with the Flask app."""
1272
+ app.cli.add_command(system)
1273
+ app.cli.add_command(database)
1274
+ app.cli.add_command(users)
1275
+ app.cli.add_command(cache)
1276
+ app.cli.add_command(maintenance)
1277
+ app.cli.add_command(debug)
1278
+ app.cli.add_command(plugins)
1279
+
1280
+
1281
+ def main():
1282
+ """Entry point for payrollctl CLI tool."""
1283
+ import importlib.util
1284
+ from pathlib import Path as PathlibPath
1285
+
1286
+ flask_app_path = os.environ.get("FLASK_APP", "app:app")
1287
+
1288
+ try:
1289
+ if ":" in flask_app_path:
1290
+ module_name, app_name = flask_app_path.split(":", 1)
1291
+ else:
1292
+ module_name = flask_app_path
1293
+ app_name = "app"
1294
+
1295
+ try:
1296
+ module = __import__(module_name, fromlist=[app_name])
1297
+ flask_app = getattr(module, app_name)
1298
+ except (ImportError, AttributeError):
1299
+ app_file = PathlibPath.cwd() / f"{module_name}.py"
1300
+ if app_file.exists():
1301
+ spec = importlib.util.spec_from_file_location(module_name, app_file)
1302
+ module = importlib.util.module_from_spec(spec)
1303
+ spec.loader.exec_module(module)
1304
+ flask_app = getattr(module, app_name)
1305
+ else:
1306
+ click.echo(f"Error: Could not load Flask app: {flask_app_path}", err=True)
1307
+ click.echo("Set FLASK_APP environment variable to specify the app location.", err=True)
1308
+ sys.exit(1)
1309
+
1310
+ flask_app.cli()
1311
+
1312
+ except Exception as e:
1313
+ click.echo(f"Error: Failed to initialize Flask app: {e}", err=True)
1314
+ sys.exit(1)
1315
+
1316
+
1317
+ if __name__ == "__main__":
1318
+ main()