Backend de Gestiรณn de Asistencia / Fichadas / Novedades para empresas. Modela empleados, horarios, fichadas, novedades (vacaciones, licencias), justificativos y cierres mensuales.
Stack: FastAPI ยท SQLAlchemy 2.0 ยท Pydantic v2 ยท JWT (PyJWT) ยท bcrypt ยท SQLite (dev) / Postgres (prod) ยท Alembic.
- Setup
- Configuraciรณn (env vars)
- Bootstrap del primer admin
- Cรณmo correrlo
- Preview con frontend mรญnimo
- Endpoints โ referencia completa
- Modelo de dominio
- Tests y calidad
- Estructura del proyecto
- Limitaciones conocidas
Requiere Python 3.11+.
python3.11 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # editar valores
alembic upgrade head # crea esquema (o se crea solo en preview)Dependencias clave: fastapi, uvicorn, sqlalchemy, pydantic[email], pydantic-settings, bcrypt, pyjwt, python-multipart, alembic. Ver requirements.txt.
Todas las variables usan prefijo GADS_. Cargadas desde .env (ver .env.example).
| Variable | Default | Descripciรณn |
|---|---|---|
GADS_DATABASE_URL |
sqlite:///./app.db |
URL SQLAlchemy. En prod: postgresql+psycopg://user:pass@host/db. |
GADS_JWT_SECRET |
dev-secret-change-me |
Cambiar en prod. Mรญnimo 32 bytes recomendado (HS256). |
GADS_JWT_ALGORITHM |
HS256 |
Algoritmo JWT. |
GADS_JWT_EXPIRE_MINUTES |
60 |
Vida del access token. |
GADS_DEFAULT_TIMEZONE |
America/Argentina/Buenos_Aires |
Zona horaria para parsear CSVs sin tzinfo. |
GADS_INITIAL_ADMIN_USER |
โ | Bootstrap: nombre de usuario admin si BD vacรญa. |
GADS_INITIAL_ADMIN_PASSWORD |
โ | Bootstrap: contraseรฑa inicial. |
GADS_INITIAL_ADMIN_EMAIL |
โ | Bootstrap: email del admin. |
GADS_INITIAL_EMPRESA_RAZON_SOCIAL |
โ | Bootstrap: razรณn social de la primera empresa. |
GADS_INITIAL_EMPRESA_CUIT |
โ | Bootstrap: CUIT de la primera empresa. |
Dos caminos:
Setea las 5 env vars GADS_INITIAL_* antes de arrancar. Si la BD no tiene admin, el lifespan crea Empresa + Empleado mรญnimo + Usuario admin al iniciar.
Con la BD vacรญa:
curl -X POST http://localhost:8000/auth/register-first-admin \
-H 'Content-Type: application/json' \
-d '{
"nombre_usuario": "admin",
"contrasena": "admin1234",
"email": "admin@local.dev",
"nombre": "Admin",
"apellido": "Inicial",
"dni": "11111111",
"cuil": "20-11111111-1",
"legajo": "ADM-001",
"empresa_razon_social": "Mi Empresa",
"empresa_cuit": "30-12345678-9",
"empresa_email": "contacto@miempresa.com",
"empresa_telefono": "011-1234-5678",
"empresa_direccion": "Av. Siempreviva 742"
}'Despuรฉs del primer admin, este endpoint devuelve 409.
Solo contra SQLite:
python scripts/crear_usuario_admin_alejandro.py --reset-db
# Crea 1 empresa (Nero IT) + 7 empleados/usuarios. Idempotente.python scripts/iniciar_servidor.py # arranque normal con --reload
# o:
uvicorn app.main:app --host 0.0.0.0 --port 8000Despuรฉs: http://localhost:8000/docs para Swagger UI.
Hay un frontend HTML/CSS/JS plano en app/static/ para probar end-to-end sin depender del frontend definitivo (ese vive en otro repo).
python scripts/preview.py
# abrรญ http://127.0.0.1:8000/ui/ ยท login: admin / admin1234El script borra app.db, setea env vars de bootstrap y levanta uvicorn.
Convenciรณn de auth:
- ๐ pรบblico โ sin autenticaciรณn.
- ๐ autenticado โ bearer JWT.
- ๐ admin โ
rol == Administrador. - ๐งฎ admin/contador โ
rol in {Administrador, ContadorExterno}. - ๐ชช propio o admin โ
current.id_usuario == :ido admin.
Todos los endpoints autenticados esperan: Authorization: Bearer <access_token>.
Liveness probe.
Response 200:
{"status": "ok"}Body:
{"nombre_usuario": "admin", "contrasena": "admin1234"}Response 200:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 3600
}Errores: 401 (credenciales invรกlidas o usuario inactivo), 422 (body invรกlido).
Devuelve el usuario autenticado.
Response 200:
{
"id_usuario": 1,
"nombre_usuario": "admin",
"email": "admin@local.dev",
"rol": "Administrador",
"estado": "activo",
"id_empleado": 1
}Errores: 401 (sin token, token invรกlido o expirado, usuario eliminado).
Bootstrap inicial. Solo permitido si no existe ningรบn admin.
Body: ver Bootstrap manual.
Response 201: mismo formato que GET /auth/me.
Errores: 409 (ya hay admin), 422 (body invรกlido โ email malformado, password < 8 chars, etc.).
Admin: lista todas. ContadorExterno/Empleado: solo la propia.
Response 200:
[
{
"id_empresa": 1,
"razon_social": "Nero IT",
"cuit": "30-71888999-1",
"email_contacto": "contacto@neroit.local",
"telefono_contacto": "011-4000-0000",
"direccion": "Buenos Aires, Argentina",
"fecha_alta": "2026-05-10",
"estado": "activo"
}
]Devuelve un CSV (UTF-8, separador coma) con todas las empresas. Header Content-Disposition: attachment; filename="empresas.csv". Columnas: id_empresa, razon_social, cuit, email_contacto, telefono_contacto, direccion, fecha_alta, estado, creado_en, actualizado_en. Ver muestra real en exports/empresas.csv.
Body:
{
"razon_social": "ACME SA",
"cuit": "30-99999999-1",
"email_contacto": "rrhh@acme.com",
"telefono_contacto": "011-5555-5555",
"direccion": "Av. Corrientes 1234",
"fecha_alta": "2026-05-10",
"estado": "activo"
}Response 201: misma forma que el item de GET /empresas con el id_empresa asignado.
Errores: 409 (CUIT duplicado), 403 (no admin), 422.
Admin: cualquiera. No-admin: solo la propia (current.empleado.id_empresa), sino 403.
Response 200: forma EmpresaResponse. 404 si no existe.
Body parcial (solo los campos a cambiar):
{"estado": "inactivo", "telefono_contacto": "011-9999-9999"}Response 200: empresa actualizada. 404 si no existe.
Baja lรณgica: pasa estado a inactivo. La empresa sigue accesible vรญa GET.
Response 204 (sin body). 404 si no existe.
Lista todos los empleados.
Response 200:
[
{
"id_empleado": 1,
"id_empresa": 1,
"legajo": "NERO-001",
"nombre": "Alejandro",
"apellido": "Mabbdet",
"dni": "35111222",
"cuil": "20-35111222-4",
"fecha_ingreso": "2026-05-10",
"categoria_laboral": "administracion",
"tipo_jornada": "completa",
"modalidad_fichada_habilitada": "habilitada",
"estado": "activo"
}
]CSV con todos los empleados. Columnas: id_empleado, id_empresa, legajo, nombre, apellido, dni, cuil, fecha_ingreso, categoria_laboral, tipo_jornada, modalidad_fichada_habilitada, estado, creado_en, actualizado_en. Muestra: exports/empleados.csv.
Body:
{
"legajo": "EMP-100",
"nombre": "Juan",
"apellido": "Pรฉrez",
"dni": "30111222",
"cuil": "20-30111222-3",
"fecha_ingreso": "2026-05-10",
"categoria_laboral": "operaciones",
"tipo_jornada": "completa",
"modalidad_fichada_habilitada": "habilitada",
"id_empresa": 1,
"estado": "activo"
}Enums vรกlidos:
categoria_laboral:operaciones | administracion | contaduriatipo_jornada:completa | parcial | turnosmodalidad_fichada_habilitada:habilitada | deshabilitadaestado:activo | inactivo
Errores: 404 (id_empresa inexistente), 409 (dni/cuil duplicado o (id_empresa, legajo) duplicado), 422.
Response 200: EmpleadoResponse. 404 si no existe.
id_empresa NO es modificable (excluido del schema). Body parcial:
{"estado": "inactivo", "categoria_laboral": "administracion"}Baja lรณgica. 204 sin body.
Response 200: lista sin contrasena_hash.
[
{
"id_usuario": 1,
"nombre_usuario": "admin",
"email": "admin@local.dev",
"estado": "activo",
"ultimo_acceso": "2026-05-10T20:58:54.530602",
"rol": "Administrador",
"id_empleado": 1
}
]CSV sin contrasena_hash. Columnas: id_usuario, nombre_usuario, email, rol, estado, ultimo_acceso, id_empleado, creado_en, actualizado_en. Muestra: exports/usuarios.csv.
Body:
{
"nombre_usuario": "jperez",
"contrasena": "secreta12",
"email": "jperez@empresa.com",
"rol": "Empleado",
"id_empleado": 5,
"estado": "activo"
}rol: Administrador | ContadorExterno | Empleado. La contraseรฑa se hashea con bcrypt antes de persistir.
Errores: 409 (nombre_usuario/email duplicado), 404 (id_empleado inexistente), 403 (no admin), 422 (password < 8 chars).
Admin: cualquiera. No-admin: solo a sรญ mismo (current.id_usuario == :id), sino 403.
Reglas:
current.id_usuario == :ido admin โ puede editaremail,nombre_usuario,contrasena,estado.- Solo admin puede modificar
rol(sino 403). id_rolyid_empleadoNO son modificables.
Body ejemplo:
{"email": "nuevo@empresa.com", "contrasena": "nueva-pass-123"}Baja lรณgica. 204.
Import masivo de fichadas desde CSV.
Multipart:
file: CSV UTF-8 (encodingutf-8-sigtolerado para BOM de Excel).
Query params:
dry_run(bool, defaultfalse) โ sitrue, valida pero hacerollback.id_empresa(int, opcional) โ admin debe indicarla. ContadorExterno la infiere de su empleado.
Headers esperados del CSV:
Fecha, Hora, Forma Registro, Tipo Registro, Legajo, Empleado, Observaciones
Mapeos:
Forma Registro:LocalโOrigenFichada.LOCAL,ManualโOrigenFichada.MANUAL. Otros valores โ error de fila.Tipo Registro:EntradaโTipoFichada.ENTRADA,SalidaโTipoFichada.SALIDA.- Empleado: buscado por
(id_empresa, legajo). Observaciones: si matchea^JUSTIFICACION\s+(Entra|Sale)\s*:\s*(.+)$(case-insensitive), se crea automรกticamente unaNovedadconTipoNovedad(auto-creado si no existe).
Comportamiento de transacciรณn: cada fila usa SAVEPOINT. Errores de fila se acumulan sin abortar el batch.
Curl:
curl -X POST -H "Authorization: Bearer $TOKEN" \
-F "file=@tests/fixtures/planilla_ejemplo.csv" \
"http://localhost:8000/fichadas/import?id_empresa=1"Response 200:
{
"total_filas": 247,
"fichadas_creadas": 247,
"novedades_creadas": 2,
"tipos_novedad_creados": ["vacaciones"],
"origenes_fichada_existentes": ["biometrico", "manual", "qr", "api", "excel", "local"],
"errores": [],
"dry_run": false
}Errores tรญpicos en errores[] (no abortan):
{"fila": 17, "motivo": "Empleado legajo 99 no encontrado en empresa 1", "legajo": "99"}Errores HTTP: 422 (headers faltantes, file vacรญo), 400 (admin sin id_empresa), 403 (rol Empleado plano).
Mรฉtricas agregadas para tableros de status. Auth: admin o contador (excepto /empleados/{id}, que tambiรฉn permite al propio empleado).
Mรฉtricas globales (admin) o de la empresa propia (contador).
Query params (opcionales):
id_empresa(int) โ admin filtra; contador siempre ve la propia (se ignora si se pasa otra).
Response 200:
{
"id_empresa": null,
"empresas": {"total": 1, "activos": 1, "inactivos": 0},
"empleados": {"total": 7, "activos": 7, "inactivos": 0},
"empleados_por_categoria": {"administracion": 1, "contaduria": 2, "operaciones": 4},
"empleados_por_jornada": {"completa": 7},
"usuarios": {"total": 7, "por_rol": {"Administrador": 1, "ContadorExterno": 2, "Empleado": 4}},
"fichadas": {"total": 247, "ultimos_7_dias": 0, "ultimos_30_dias": 0},
"novedades": {"total": 2, "pendientes": 2, "aprobadas": 0, "rechazadas": 0, "anuladas": 0}
}Errores: 403 (rol Empleado plano).
Listado de empleados con mรฉtricas resumidas (รบltima fichada, fichadas de los รบltimos 30 dรญas, novedades pendientes).
Query params (opcionales):
id_empresa(int) โ admin filtra; contador forzado a la propia.estadoโactivo|inactivo.
Response 200:
{
"total": 7,
"items": [
{
"id_empleado": 1,
"id_empresa": 1,
"legajo": "NERO-001",
"nombre": "Alejandro",
"apellido": "Mabbdet",
"estado": "activo",
"categoria_laboral": "administracion",
"tipo_jornada": "completa",
"ultima_fichada": "2026-04-17T17:43:00-03:00",
"fichadas_ultimos_30_dias": 12,
"novedades_pendientes": 0
}
]
}Detalle completo del empleado: fichadas por mes, novedades agrupadas por tipo, primera/รบltima fichada.
Auth: admin (cualquier empleado), contador (solo empleados de su empresa), el propio empleado (current.id_empleado == :id).
Response 200:
{
"id_empleado": 1,
"id_empresa": 1,
"legajo": "NERO-001",
"nombre": "Alejandro",
"apellido": "Mabbdet",
"estado": "activo",
"categoria_laboral": "administracion",
"tipo_jornada": "completa",
"modalidad_fichada_habilitada": "habilitada",
"ultima_fichada": "2026-04-17T17:43:00-03:00",
"primera_fichada": "2026-04-01T08:00:00-03:00",
"fichadas_total": 22,
"fichadas_ultimos_30_dias": 22,
"fichadas_por_mes": {"2026-04": 22},
"novedades_total": 2,
"novedades_pendientes": 2,
"novedades_por_tipo": {"vacaciones": 2}
}Errores: 403 (otro empleado sin permiso), 404 (id inexistente).
Redirige 307 โ /ui/.
Sirve index.html (login). Despuรฉs del login, dashboard estรกtico en /ui/dashboard.html.
Empresa 1โโโ* Empleado 1โโโ? Usuario
โ โ
โโโโ* Asignacion โโโ Horario
โโโโ* Fichada โโโโ OrigenFichada
โโโโ* Novedad โโโโ TipoNovedad
โ
โโโโ? Justificativo
CierreMensual โโโ Empresa
โโโ ResumenMensualEmpleado *โโ Empleado
โโโ Exportacion
DiasEspeciales (independiente)
Auditoria (genรฉrica, registra cualquier entidad)
| Tabla | Notas |
|---|---|
usuarios |
nombre_usuario รบnico, rol SAEnum, estado SAEnum, hash bcrypt. |
empresas |
cuit รบnico, baja lรณgica. |
empleados |
dni y cuil รบnicos globales, (id_empresa, legajo) รบnico compuesto. |
horarios |
Banda, tolerancias, dรญas descanso. |
asignaciones_horario |
Validaciรณn servicio: rango sin solapamientos por empleado. |
fichadas |
fecha_hora aware, tipo_fichada enum (entrada/salida). |
origenes_fichada |
Catรกlogo: biometrico, manual, qr, api, excel, local. |
tipos_novedad |
nombre_tipo รบnico. |
novedades |
id_fichada nullable (vacaciones planificadas no tienen fichada). |
justificativos |
1:1 con novedad, archivo asociado. |
cierres_mensuales |
CHECK mes BETWEEN 1 AND 12, CHECK anio BETWEEN 2000 AND 2100. |
resumenes_mensuales_empleado |
Mรฉtricas por empleado-cierre. |
dias_especiales |
UniqueConstraint (fecha, tipo_dia_especial). |
auditorias |
Genรฉrica. Index compuesto (entidad_afectada, id_registro_afectado). |
Casi todas las tablas heredan TimestampMixin (creado_en / actualizado_en).
Rol, OrigenFichada, TipoFichada, EstadoEntidad, EstadoNovedad, OrigenNovedad, EstadoJustificativo, EstadoExportacion, TipoJornada, ModalidadFichada, CategoriaLaboral, TipoHorario, TipoJustificativo, UnidadMedidaTipoNovedad, EstadoCierreMensual, TipoFormatoExportacion, TipoDiaEspecial, AccionAuditoria. Ver app/models/enums.py.
pytest # 124 tests verdes
pytest --cov=app # con cobertura
ruff check . # lint
mypy app # types strictCobertura por archivo:
| Archivo | Tests |
|---|---|
test_health.py |
4 |
test_auth.py |
13 |
test_bootstrap.py |
5 |
test_usuarios.py |
28 |
test_empleados.py |
16 |
test_empresas.py |
15 |
test_exports.py |
14 |
test_dashboards.py |
13 |
test_fichadas_import.py |
11 |
test_horarios.py |
6 |
test_modelos_constraints.py |
8 |
test_seed.py |
4 |
| Total | 137 |
GADS/
โโโ app/
โ โโโ api/
โ โ โโโ deps.py # get_db, get_current_user, require_rol
โ โ โโโ routers/
โ โ โโโ auth.py # /auth/*
โ โ โโโ empresas.py # /empresas/* (CRUD + /export)
โ โ โโโ empleados.py # /empleados/* (CRUD + /export)
โ โ โโโ usuarios.py # /usuarios/* (CRUD + /export)
โ โ โโโ fichadas.py # /fichadas/import
โ โ โโโ dashboards.py # /dashboards/resumen, /empleados/status, /empleados/{id}
โ โโโ daos/ # acceso a datos (sin commit)
โ โโโ services/ # lรณgica + commit + manejo de IntegrityError
โ โ โโโ auth_service.py # login, JWT, bootstrap
โ โ โโโ usuario_service.py
โ โ โโโ empleado_service.py
โ โ โโโ empresa_service.py
โ โ โโโ horario_service.py # validaciรณn de no-solapamiento
โ โ โโโ fichada_import_service.py # parser CSV + auto-novedades
โ โ โโโ export_service.py # CSVs (empresas/empleados/usuarios)
โ โ โโโ dashboard_service.py # mรฉtricas agregadas
โ โโโ models/ # ORM SQLAlchemy 2.0
โ โโโ schemas/ # Pydantic v2
โ โโโ static/ # frontend preview HTML/CSS/JS
โ โโโ config.py # Settings (pydantic-settings)
โ โโโ database.py # engine, SessionLocal, get_db, init_db
โ โโโ main.py # FastAPI app + lifespan + bootstrap
โโโ exports/ # muestras de export CSV
โ โโโ empresas.csv
โ โโโ empleados.csv
โ โโโ usuarios.csv
โโโ migrations/ # Alembic
โโโ scripts/
โ โโโ iniciar_servidor.py
โ โโโ preview.py # uvicorn + bootstrap env vars
โ โโโ crear_usuario_admin_alejandro.py # seed dev
โโโ tests/
โ โโโ fixtures/planilla_ejemplo.csv # 247 filas, 4 empleados, 2 vacaciones
โ โโโ test_*.py # 124 tests
โโโ pyproject.toml # ruff + mypy
โโโ requirements.txt
โโโ alembic.ini
โโโ .env.example
| Limitaciรณn | Estado / workaround |
|---|---|
| Sin CRUD de horarios desde API (servicio existe). | Pendiente router. |
| Sin CRUD de novedades (solo se crean por import CSV). | Pendiente router. |
| Sin endpoints de cierre mensual / resumen. | Modelo listo, lรณgica no implementada. |
| Sin upload de adjuntos de justificativos. | โ |
| Sin reportes / exportaciรณn de fichadas (solo empresas/empleados/usuarios). | Extender export_service. |
| Sin recuperaciรณn de contraseรฑa. | Admin puede resetear via PATCH /usuarios/{id}. |
| Sin CORS configurado. | Sumar CORSMiddleware cuando frontend definitivo viva en otro dominio. |
| Sin CSP / HSTS / headers de seguridad. | Configurar en proxy reverso (nginx/Caddy). |
JWT en localStorage del preview. |
Vulnerable a XSS โ para prod usar cookie httpOnly + CSRF. |
Alembic baseline simplificada (Base.metadata.create_all). |
Regenerar con alembic revision --autogenerate al primer schema change. |
| Frontend preview sin paginaciรณn / filtros / bรบsqueda. | UI mรญnima de demostraciรณn. |
HTTP_422_UNPROCESSABLE_ENTITY deprecation warning (Starlette). |
Cambiar a HTTP_422_UNPROCESSABLE_CONTENT. |
Ver LICENSE.