Initial commit: mini-erp (backend FastAPI + frontend React/Vite)
Base para el flujo de ramas por cliente. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
ab3c613165
154 files changed
+16872
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,130 @@
|
||||
from dotenv import load_dotenv
|
||||
import logging
|
||||
import requests
|
||||
from requests import Session
|
||||
from typing import Optional, Dict, Any
|
||||
import os
|
||||
from fastapi import HTTPException
|
||||
import urllib3
|
||||
|
||||
load_dotenv(dotenv_path=".env")
|
||||
|
||||
APIGATEWAY_URL = os.getenv("APIGATEWAY_URL", "")
|
||||
APIGATEWAY_USER = os.getenv("APIGATEWAY_USER", "")
|
||||
APIGATEWAY_PASSWORD = os.getenv("APIGATEWAY_PASSWORD", "")
|
||||
APIGATEWAY_COMPANYDB = os.getenv("APIGATEWAY_COMPANYDB", "")
|
||||
APIGATEWAY_DBINSTANCE = os.getenv("APIGATEWAY_DBINSTANCE", "")
|
||||
APIGATEWAY_TIMEOUT = float(os.getenv("APIGATEWAY_TIMEOUT", 30.0))
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
api_gateway_session: Optional[Session] = None
|
||||
|
||||
|
||||
def ensure_api_gateway_session(force_refresh: bool = False) -> Session:
|
||||
global api_gateway_session
|
||||
|
||||
if api_gateway_session and not force_refresh:
|
||||
return api_gateway_session
|
||||
|
||||
logger.info("Iniciando sesión en APIGateway...")
|
||||
login_url = f"{APIGATEWAY_URL}/login"
|
||||
payload = {
|
||||
"CompanyDB": APIGATEWAY_COMPANYDB,
|
||||
"UserName": APIGATEWAY_USER,
|
||||
"Password": APIGATEWAY_PASSWORD,
|
||||
"DBInstance": APIGATEWAY_DBINSTANCE,
|
||||
}
|
||||
|
||||
try:
|
||||
session = Session()
|
||||
session.verify = False
|
||||
|
||||
response = session.post(
|
||||
login_url,
|
||||
json=payload,
|
||||
timeout=APIGATEWAY_TIMEOUT,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Error login APIGateway. Status: {response.status_code}")
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail="Error de autenticación con APIGateway"
|
||||
)
|
||||
|
||||
api_gateway_session = session
|
||||
logger.info(f"Sesión APIGateway establecida. Cookies: {dict(session.cookies)}")
|
||||
return session
|
||||
|
||||
except requests.RequestException as exc:
|
||||
logger.error(f"Error de conexión con APIGateway: {exc}")
|
||||
api_gateway_session = None
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"No se pudo conectar con APIGateway: {exc}"
|
||||
)
|
||||
|
||||
|
||||
def forward_to_api_gateway(
|
||||
method: str,
|
||||
endpoint: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
json_data: Optional[Any] = None,
|
||||
) -> requests.Response:
|
||||
if not method or not endpoint:
|
||||
raise ValueError("method y endpoint son requeridos")
|
||||
|
||||
method = method.upper()
|
||||
session = ensure_api_gateway_session()
|
||||
|
||||
url_destino = f"{APIGATEWAY_URL}/{endpoint}"
|
||||
|
||||
log_extra = {"http_method": method, "endpoint": endpoint}
|
||||
logger.info(f"[APIGateway] [{method}] {endpoint}", extra=log_extra)
|
||||
|
||||
try:
|
||||
response = session.request(
|
||||
method=method,
|
||||
url=url_destino,
|
||||
params=params,
|
||||
json=json_data,
|
||||
timeout=APIGATEWAY_TIMEOUT,
|
||||
)
|
||||
except requests.exceptions.Timeout:
|
||||
logger.error(f"Timeout ({APIGATEWAY_TIMEOUT}s) en APIGateway {endpoint}", extra=log_extra)
|
||||
raise ConnectionError(f"Timeout al conectar con APIGateway: {endpoint}")
|
||||
except requests.RequestException as exc:
|
||||
logger.error(f"Error en solicitud APIGateway: {exc}", extra=log_extra)
|
||||
raise ConnectionError(f"Error en solicitud APIGateway: {exc}")
|
||||
|
||||
if response.status_code == 401:
|
||||
logger.warning("Sesión APIGateway expirada (401). Reautenticando...", extra=log_extra)
|
||||
session = ensure_api_gateway_session(force_refresh=True)
|
||||
try:
|
||||
response = session.request(
|
||||
method=method,
|
||||
url=url_destino,
|
||||
params=params,
|
||||
json=json_data,
|
||||
timeout=APIGATEWAY_TIMEOUT,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise ConnectionError(f"Error en reintento APIGateway: {exc}")
|
||||
|
||||
if response.status_code >= 400:
|
||||
error_msg = response.text[:500]
|
||||
logger.error(
|
||||
f"[{response.status_code}] APIGateway {endpoint}: {error_msg}",
|
||||
extra={**log_extra, "status_code": response.status_code}
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=f"Error en APIGateway: {error_msg}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[{response.status_code}] APIGateway {endpoint}",
|
||||
extra={**log_extra, "status_code": response.status_code}
|
||||
)
|
||||
return response
|
||||
@@ -0,0 +1,123 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from core.sap import forward_to_sapb1
|
||||
|
||||
|
||||
def _build_sap_params(
|
||||
skip: int,
|
||||
top: int,
|
||||
fields: Optional[str],
|
||||
filter: Optional[str],
|
||||
orderby: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
sap_params: Dict[str, Any] = {
|
||||
"$skip": skip,
|
||||
"$top": top,
|
||||
"$inlinecount": "allpages",
|
||||
}
|
||||
if fields:
|
||||
sap_params["$select"] = fields.replace(" ", "")
|
||||
if filter:
|
||||
sap_params["$filter"] = filter
|
||||
if orderby:
|
||||
sap_params["$orderby"] = orderby
|
||||
return sap_params
|
||||
|
||||
|
||||
def _build_list_headers(top: int, filter: Optional[str]) -> Dict[str, str]:
|
||||
headers = {"B1S-PageSize": str(top)}
|
||||
if filter:
|
||||
headers["B1S-CaseInsensitive"] = "true"
|
||||
return headers
|
||||
|
||||
|
||||
def make_crud_router(
|
||||
resource: str,
|
||||
*,
|
||||
tags: List[str],
|
||||
key_field: Optional[str] = None,
|
||||
key_quoted: bool = False,
|
||||
supports_orderby: bool = False,
|
||||
supports_get_by_key: bool = True,
|
||||
supports_create: bool = True,
|
||||
supports_patch: bool = True,
|
||||
patch_headers: Optional[Dict[str, str]] = None,
|
||||
) -> APIRouter:
|
||||
"""
|
||||
Genera el router list/get/create/update repetido antes en cada módulo de
|
||||
routers/ (mismo bloque de $skip/$top/$filter y de forward_to_sapb1, solo
|
||||
cambiaba el nombre del recurso OData). Centralizarlo evita que un
|
||||
copy-paste entre recursos deje, por ejemplo, un PATCH apuntando al
|
||||
endpoint_path de otro recurso.
|
||||
"""
|
||||
router = APIRouter(tags=tags)
|
||||
|
||||
def key_expr(key: str) -> str:
|
||||
return f"{resource}('{key}')" if key_quoted else f"{resource}({key})"
|
||||
|
||||
if supports_orderby:
|
||||
@router.get(f"/api/{resource}", name=f"list_{resource}")
|
||||
async def list_resource(
|
||||
skip: int = 0,
|
||||
top: int = 50,
|
||||
fields: Optional[str] = None,
|
||||
filter: Optional[str] = None,
|
||||
orderby: Optional[str] = None,
|
||||
):
|
||||
sap_params = _build_sap_params(skip, top, fields, filter, orderby)
|
||||
return forward_to_sapb1(
|
||||
method="GET",
|
||||
endpoint_path=resource,
|
||||
params=sap_params,
|
||||
extra_headers=_build_list_headers(top, filter),
|
||||
).json()
|
||||
else:
|
||||
@router.get(f"/api/{resource}", name=f"list_{resource}")
|
||||
async def list_resource(
|
||||
skip: int = 0,
|
||||
top: int = 50,
|
||||
fields: Optional[str] = None,
|
||||
filter: Optional[str] = None,
|
||||
):
|
||||
sap_params = _build_sap_params(skip, top, fields, filter)
|
||||
return forward_to_sapb1(
|
||||
method="GET",
|
||||
endpoint_path=resource,
|
||||
params=sap_params,
|
||||
extra_headers=_build_list_headers(top, filter),
|
||||
).json()
|
||||
|
||||
if supports_get_by_key:
|
||||
@router.get(f"/api/{resource}/{{key}}", name=f"get_{resource}_by_key")
|
||||
async def get_by_key(key: str):
|
||||
return forward_to_sapb1(
|
||||
method="GET",
|
||||
endpoint_path=key_expr(key),
|
||||
).json()
|
||||
|
||||
if supports_create:
|
||||
@router.post(f"/api/{resource}", status_code=201, name=f"create_{resource}")
|
||||
async def create_resource(request: Request):
|
||||
data = await request.json()
|
||||
resp = forward_to_sapb1(
|
||||
method="POST",
|
||||
endpoint_path=resource,
|
||||
json_data=data,
|
||||
)
|
||||
created = resp.json()
|
||||
return {key_field: created[key_field]}
|
||||
|
||||
if supports_patch:
|
||||
@router.patch(f"/api/{resource}/{{key}}", status_code=204, name=f"update_{resource}")
|
||||
async def update_resource(key: str, request: Request):
|
||||
data = await request.json()
|
||||
forward_to_sapb1(
|
||||
method="PATCH",
|
||||
endpoint_path=key_expr(key),
|
||||
json_data=data,
|
||||
extra_headers=patch_headers,
|
||||
)
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,44 @@
|
||||
import os
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import HTTPException
|
||||
from hdbcli import dbapi
|
||||
|
||||
# ============================================================================
|
||||
load_dotenv(dotenv_path=".env")
|
||||
|
||||
HANA_HOST = os.getenv("HANA_HOST", "")
|
||||
HANA_PORT = int(os.getenv("HANA_PORT", 30015))
|
||||
HANA_USER = os.getenv("HANA_USER", "")
|
||||
HANA_PASSWORD = os.getenv("HANA_PASSWORD", "")
|
||||
HANA_SCHEMA = os.getenv("HANA_SCHEMA", "")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
@contextmanager
|
||||
def hana_connection() -> Iterator[dbapi.Connection]:
|
||||
"""
|
||||
Abre una conexión a SAP HANA para un query puntual (reportes Jinja/HTML)
|
||||
y la cierra al salir del bloque `with`.
|
||||
"""
|
||||
try:
|
||||
conn = dbapi.connect(
|
||||
address=HANA_HOST,
|
||||
port=HANA_PORT,
|
||||
user=HANA_USER,
|
||||
password=HANA_PASSWORD,
|
||||
currentSchema=HANA_SCHEMA,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"Error al conectar con HANA: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"No se pudo conectar con la base de datos: {exc}")
|
||||
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
# ============================================================================
|
||||
@@ -0,0 +1,200 @@
|
||||
|
||||
from dotenv import load_dotenv
|
||||
import logging
|
||||
import requests
|
||||
from typing import Optional, Dict, Any
|
||||
import os
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
import json
|
||||
import urllib3
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CONFIGURACIÓN
|
||||
# ============================================================================
|
||||
|
||||
# Cargar variables de entorno desde .env
|
||||
load_dotenv(dotenv_path=".env")
|
||||
|
||||
SAPB1_SL_URL = os.getenv("SAPB1_SL_URL", "")
|
||||
SAPB1_SL_USER = os.getenv("SAPB1_SL_USER", "")
|
||||
SAPB1_SL_PASSWORD = os.getenv("SAPB1_SL_PASSWORD", "")
|
||||
SAPB1_SL_COMPANYDB = os.getenv("SAPB1_SL_COMPANYDB", "")
|
||||
# Tiempo de espera y verificación SSL
|
||||
SAPB1_SL_TIMEOUT = float(os.getenv("SAPB1_SL_TIMEOUT", 15.0))
|
||||
SAPB1_SL_VERIFY_SSL = os.getenv("SAPB1_SL_VERIFY_SSL", "False").lower() == "true"
|
||||
|
||||
# ============================================================================
|
||||
# Obtiene el logger específico de este archivo
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# 1. Diccionario global para persistir las cabeceras de autenticación de SAP
|
||||
sap_session_store: dict[str, Optional[dict[str, str]]] = {
|
||||
"headers": None
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
def _log_response(
|
||||
method: str,
|
||||
endpoint: str,
|
||||
response: requests.Response,
|
||||
request_data: Optional[Dict] = None
|
||||
) -> None:
|
||||
"""Registra detalles de la respuesta"""
|
||||
extra = {"http_method": method, "endpoint": endpoint, "status_code": response.status_code}
|
||||
if response.status_code >= 400:
|
||||
try:
|
||||
error_detail = response.json()
|
||||
respuesta = json.dumps(error_detail, indent=2)[:500]
|
||||
except Exception:
|
||||
respuesta = response.text[:500]
|
||||
logger.error(
|
||||
f"[{response.status_code}] {method} {endpoint} - "
|
||||
f"Datos enviados: {json.dumps(request_data, indent=2, default=str)[:500]} - "
|
||||
f"Respuesta: {respuesta}",
|
||||
extra=extra
|
||||
)
|
||||
else:
|
||||
logger.info(f"[{response.status_code}] {method} {endpoint}", extra=extra)
|
||||
|
||||
# ============================================================================
|
||||
def ensure_sap_session(force_refresh: bool = False) -> dict:
|
||||
"""
|
||||
Verifica si existe una sesión global de SAP B1 (B1SESSION).
|
||||
Si no existe o si force_refresh es True, realiza el Login con la información del .env.
|
||||
"""
|
||||
if sap_session_store["headers"] and not force_refresh:
|
||||
return sap_session_store["headers"]
|
||||
|
||||
logger.info("Iniciando sesión global en SAP como Manager...")
|
||||
auth_url = f"{SAPB1_SL_URL}/Login"
|
||||
sap_auth_payload = {
|
||||
"CompanyDB": SAPB1_SL_COMPANYDB,
|
||||
"UserName": SAPB1_SL_USER,
|
||||
"Password": SAPB1_SL_PASSWORD
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
auth_url,
|
||||
json=sap_auth_payload,
|
||||
timeout=SAPB1_SL_TIMEOUT,
|
||||
verify=SAPB1_SL_VERIFY_SSL
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Error crítico: No se pudo conectar el usuario Manager a SAP. Status: {response.status_code}")
|
||||
sap_session_store["headers"] = None
|
||||
raise HTTPException(status_code=response.status_code, detail="Conexión fallida con el servidor SAP")
|
||||
|
||||
sap_session = response.json()
|
||||
b1_session_id = sap_session.get("SessionId")
|
||||
route_id = response.cookies.get("ROUTEID")
|
||||
|
||||
headers = {
|
||||
"Cookie": f"B1SESSION={b1_session_id}; ROUTEID={route_id}"
|
||||
}
|
||||
sap_session_store["headers"] = headers
|
||||
logger.info(f"Sesión global establecida. B1SESSION: {b1_session_id[:10]}...")
|
||||
return headers
|
||||
except requests.RequestException as exc:
|
||||
logger.error(f"Error de conexión al autenticar contra SAP: {exc}")
|
||||
sap_session_store["headers"] = None
|
||||
raise HTTPException(status_code=500, detail=f"Error al conectar con SAP Service Layer: {exc}")
|
||||
|
||||
# ============================================================================
|
||||
# FUNCIÓN PROXY CENTRALIZADA
|
||||
# ============================================================================
|
||||
def forward_to_sapb1(
|
||||
method: str,
|
||||
endpoint_path: str,
|
||||
json_data: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None
|
||||
) -> requests.Response:
|
||||
"""
|
||||
Función proxy centralizada para todas las llamadas a SAP B1 Service Layer.
|
||||
Maneja autenticación transparentemente con sesión única y reintento.
|
||||
"""
|
||||
if not method or not endpoint_path:
|
||||
raise ValueError("method y endpoint_path son requeridos")
|
||||
|
||||
method = method.upper()
|
||||
|
||||
# Obtener cabeceras con B1SESSION
|
||||
headers = ensure_sap_session()
|
||||
|
||||
# Preparar URL
|
||||
url_destino = f"{SAPB1_SL_URL}/{endpoint_path}"
|
||||
|
||||
# Preparar cabeceras de la petición
|
||||
req_headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
req_headers.update(headers)
|
||||
if extra_headers:
|
||||
req_headers.update(extra_headers)
|
||||
|
||||
log_extra = {"http_method": method, "endpoint": endpoint_path}
|
||||
logger.info(f"[{method}] {endpoint_path}", extra=log_extra)
|
||||
|
||||
# Realizar solicitud
|
||||
try:
|
||||
response = requests.request(
|
||||
method=method,
|
||||
url=url_destino,
|
||||
json=json_data,
|
||||
params=params,
|
||||
headers=req_headers,
|
||||
timeout=SAPB1_SL_TIMEOUT,
|
||||
verify=SAPB1_SL_VERIFY_SSL
|
||||
)
|
||||
except requests.exceptions.Timeout:
|
||||
logger.error(f"Timeout ({SAPB1_SL_TIMEOUT}s) en {endpoint_path}", extra=log_extra)
|
||||
raise ConnectionError(f"Timeout al conectar con {endpoint_path}")
|
||||
except requests.exceptions.ConnectionError as exc:
|
||||
logger.error(f"Error de conexión: {exc}", extra=log_extra)
|
||||
raise ConnectionError(f"No se pudo conectar con SAP B1: {exc}")
|
||||
except requests.RequestException as exc:
|
||||
logger.error(f"Error en solicitud: {exc}", extra=log_extra)
|
||||
raise ConnectionError(f"Error en solicitud HTTP: {exc}")
|
||||
|
||||
# Manejo de 401 - Sesión expirada: reintentar UNA sola vez
|
||||
if response.status_code == 401:
|
||||
logger.warning("Sesión expirada (401). Reautenticando e intentando una sola vez...", extra=log_extra)
|
||||
try:
|
||||
# Forzar actualización de sesión
|
||||
headers = ensure_sap_session(force_refresh=True)
|
||||
req_headers.update(headers)
|
||||
|
||||
logger.info("Reintentando solicitud [RETRY]", extra=log_extra)
|
||||
response = requests.request(
|
||||
method=method,
|
||||
url=url_destino,
|
||||
json=json_data,
|
||||
params=params,
|
||||
headers=req_headers,
|
||||
timeout=SAPB1_SL_TIMEOUT,
|
||||
verify=SAPB1_SL_VERIFY_SSL
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"Error en reintento: {exc}", extra=log_extra)
|
||||
raise ConnectionError(f"Error en reintento tras reautenticación: {exc}")
|
||||
|
||||
# Logging de respuesta
|
||||
_log_response(method, endpoint_path, response, json_data)
|
||||
|
||||
# Extraer error SAP y propagar al frontend
|
||||
if response.status_code >= 400:
|
||||
error_msg = response.text
|
||||
try:
|
||||
err = response.json()
|
||||
error_msg = err.get("error", {}).get("message", {}).get("value", error_msg)
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(status_code=response.status_code, detail=error_msg)
|
||||
|
||||
return response
|
||||
# ============================================================================
|
||||
@@ -0,0 +1,74 @@
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# ============================================================================
|
||||
SESSION_TIMEOUT_MINUTES = 30
|
||||
|
||||
employee_sessions: dict[str, dict] = {}
|
||||
|
||||
active_tokens: dict[str, str] = {}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
def create_employee_session(webUser: str, emp_id, first_name: str, last_name: str) -> str:
|
||||
for token, wu in list(active_tokens.items()):
|
||||
if wu == webUser:
|
||||
del active_tokens[token]
|
||||
logger.info(f"Sesión anterior removida para el usuario {webUser} (nuevo login)")
|
||||
break
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
active_tokens[token] = webUser
|
||||
employee_sessions[webUser] = {
|
||||
"last_activity": datetime.now(),
|
||||
"emp_id": emp_id,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name
|
||||
}
|
||||
logger.info(f"Sesión creada para el usuario {webUser} - {first_name} {last_name}")
|
||||
return token
|
||||
|
||||
# ============================================================================
|
||||
def validate_and_refresh_session(token: str) -> str | None:
|
||||
webUser = active_tokens.get(token)
|
||||
if not webUser:
|
||||
return None
|
||||
|
||||
session = employee_sessions.get(webUser)
|
||||
if not session:
|
||||
active_tokens.pop(token, None)
|
||||
return None
|
||||
|
||||
now = datetime.now()
|
||||
if now - session["last_activity"] > timedelta(minutes=SESSION_TIMEOUT_MINUTES):
|
||||
logger.info(f"Sesión expirada para el usuario {webUser} (inactivo > {SESSION_TIMEOUT_MINUTES} min)")
|
||||
active_tokens.pop(token, None)
|
||||
employee_sessions.pop(webUser, None)
|
||||
return None
|
||||
|
||||
session["last_activity"] = now
|
||||
return webUser
|
||||
|
||||
# ============================================================================
|
||||
def remove_employee_session_by_token(token: str):
|
||||
webUser = active_tokens.pop(token, None)
|
||||
if webUser:
|
||||
employee_sessions.pop(webUser, None)
|
||||
logger.info(f"Sesión cerrada para el usuario {webUser}")
|
||||
|
||||
# ============================================================================
|
||||
def get_employee_name(webUser: str) -> str:
|
||||
session = employee_sessions.get(webUser)
|
||||
if not session:
|
||||
return "-"
|
||||
return f"{session.get('first_name', '')} {session.get('last_name', '')}".strip()
|
||||
|
||||
# ============================================================================
|
||||
def get_emp_id(webUser: str):
|
||||
session = employee_sessions.get(webUser)
|
||||
if not session:
|
||||
return "-"
|
||||
return session.get("emp_id", "-")
|
||||
# ============================================================================
|
||||
Reference in new issue
Block a user