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
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()
|