Bakend en flask

This commit is contained in:
Marcos Elias Rios Nuñez 2026-06-01 10:07:00 -03:00
parent 4f6966a400
commit a5bf51eb47
24 changed files with 1049 additions and 0 deletions

16
api/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM python:3.11-slim
WORKDIR /app
# Instalar dependencias del sistema necesarias para PostgreSQL y manejo de imágenes
RUN apt-get update && apt-get install -y \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "run.py"]

139
api/app/commands.py Normal file
View File

@ -0,0 +1,139 @@
import click
from flask.cli import with_appcontext
from app.database import db
from app.models import Role, User
from app.models.biometria import (
Persona, OrigenHuella, TiposF, Subclasificacion,
Huella, PuntoCaracteristico, HuellaPuntoCaracteristico, FichaClasificacion
)
from datetime import datetime
@click.command(name="seed") # Definimos explícitamente el nombre del comando CLI
@with_appcontext
def seed_db():
"""Siembra de roles, usuarios administrativos y datos iniciales del S.I.P"""
click.echo("Iniciando el sembrado de datos en PostgreSQL...")
# ---------------------------------------------------------
# [ROLES Y USUARIOS]
# ---------------------------------------------------------
admin_role = Role.query.filter_by(name='admin').first()
if not admin_role:
admin_role = Role(name='admin', permissions=['read:users', 'write:users', 'manage:biometria'])
db.session.add(admin_role)
db.session.commit()
investigador_role = Role.query.filter_by(name='investigador').first()
if not investigador_role:
investigador_role = Role(name='investigador', permissions=['manage:biometria'])
db.session.add(investigador_role)
db.session.commit()
admin_user = User.query.filter_by(username='admin_root').first()
if not admin_user:
admin_user = User(username='admin_root', email='admin@uader.edu.ar', role_id=admin_role.id)
admin_user.set_password('admin1234')
db.session.add(admin_user)
db.session.commit()
investigador_user = User.query.filter_by(username='investigador_demo').first()
if not investigador_user:
investigador_user = User(username='investigador_demo', email='investigador@uader.edu.ar', role_id=investigador_role.id)
investigador_user.set_password('investigador1234')
db.session.add(investigador_user)
db.session.commit()
# ---------------------------------------------------------
# 1. TABLA MAESTRA: PERSONAS
# ---------------------------------------------------------
if Persona.query.count() == 0:
personas_data = [
Persona(cod_id=1, cod_nombre='Juan Pérez', fecha_nacimiento=datetime.strptime('1990-05-20', '%Y-%m-%d').date(), sexo='M'),
Persona(cod_id=2, cod_nombre='Ana Gómez', fecha_nacimiento=datetime.strptime('1985-12-01', '%Y-%m-%d').date(), sexo='F'),
Persona(cod_id=3, cod_nombre='Carlos López', fecha_nacimiento=datetime.strptime('2000-07-15', '%Y-%m-%d').date(), sexo='M')
]
db.session.bulk_save_objects(personas_data)
# ---------------------------------------------------------
# 2. TABLA MAESTRA: ORIGEN DE HUELLAS
# ---------------------------------------------------------
if OrigenHuella.query.count() == 0:
origenes_data = [
OrigenHuella(id=1, descripcion='Índice derecho', lado='derecho'),
OrigenHuella(id=2, descripcion='Índice izquierdo', lado='izquierdo'),
OrigenHuella(id=3, descripcion='Pulgar derecho', lado='derecho')
]
db.session.bulk_save_objects(origenes_data)
# ---------------------------------------------------------
# 3. TABLA MAESTRA: TIPOS F
# ---------------------------------------------------------
if TiposF.query.count() == 0:
tipos_data = [
TiposF(id=1, nombre='Arco', descripcion='Huella en forma de arco', foto=None),
TiposF(id=2, nombre='Presilla', descripcion='Huella en forma de presilla', foto=None),
TiposF(id=3, nombre='Verticilo', descripcion='Huella en forma de espiral', foto=None)
]
db.session.bulk_save_objects(tipos_data)
db.session.commit()
# ---------------------------------------------------------
# 4. SUBTABLA DEPENDIENTE: SUBCLASIFICACIÓN
# ---------------------------------------------------------
if Subclasificacion.query.count() == 0:
sub_data = [
Subclasificacion(id=1, nombre='Arco simple', tipo_f_id=1),
Subclasificacion(id=2, nombre='Presilla derecha', tipo_f_id=2),
Subclasificacion(id=3, nombre='Verticilo central', tipo_f_id=3)
]
db.session.bulk_save_objects(sub_data)
# ---------------------------------------------------------
# 5. TABLA MAESTRA: PUNTOS CARACTERÍSTICOS
# ---------------------------------------------------------
if PuntoCaracteristico.query.count() == 0:
puntos_data = [
PuntoCaracteristico(id=1, nombre='Bifurcación', foto=None),
PuntoCaracteristico(id=2, nombre='Final de cresta', foto=None),
PuntoCaracteristico(id=3, nombre='Punto de isla', foto=None)
]
db.session.bulk_save_objects(puntos_data)
db.session.commit()
# ---------------------------------------------------------
# 6. NÚCLEO BIOMÉTRICO: HUELLAS
# ---------------------------------------------------------
if Huella.query.count() == 0:
huellas_data = [
Huella(id=1, descripcion='Huella digital clara', origen_hd='Escáner', foto=None, persona_cod_id=1, origen_huella_id=1),
Huella(id=2, descripcion='Huella con ligera distorsión', origen_hd='Tinta', foto=None, persona_cod_id=2, origen_huella_id=2),
Huella(id=3, descripcion='Huella poco definida', origen_hd='Escáner', foto=None, persona_cod_id=3, origen_huella_id=3)
]
db.session.bulk_save_objects(huellas_data)
db.session.commit()
# ---------------------------------------------------------
# 7. TABLA PIVOTE: HUELLA / PUNTOS
# ---------------------------------------------------------
if HuellaPuntoCaracteristico.query.count() == 0:
coordenadas_data = [
HuellaPuntoCaracteristico(huella_id=1, punto_id=1, x=12.345678, y=45.678912, angulo=30.00),
HuellaPuntoCaracteristico(huella_id=1, punto_id=2, x=15.111111, y=50.222222, angulo=45.00),
HuellaPuntoCaracteristico(huella_id=2, punto_id=3, x=20.333333, y=60.444444, angulo=60.00)
]
db.session.bulk_save_objects(coordenadas_data)
# ---------------------------------------------------------
# 8. FICHA DE CLASIFICACIÓN
# ---------------------------------------------------------
if FichaClasificacion.query.count() == 0:
fichas_data = [
FichaClasificacion(id=1, huella_id=1, subclasificacion_id=1, tipos_f_id=1, fecha=datetime.strptime('2025-09-30', '%Y-%m-%d').date()),
FichaClasificacion(id=2, huella_id=2, subclasificacion_id=2, tipos_f_id=2, fecha=datetime.strptime('2025-09-30', '%Y-%m-%d').date()),
FichaClasificacion(id=3, huella_id=3, subclasificacion_id=3, tipos_f_id=3, fecha=datetime.strptime('2025-09-30', '%Y-%m-%d').date())
]
db.session.bulk_save_objects(fichas_data)
db.session.commit()
click.echo("¡Base de datos del S.I.P sembrada e integrada al 100%!")

0
api/app/config.py Normal file
View File

5
api/app/database.py Normal file
View File

@ -0,0 +1,5 @@
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
db = SQLAlchemy()
migrate = Migrate()

View File

@ -0,0 +1,33 @@
from functools import wraps
from flask import request, jsonify
import jwt
import os
def token_required(f):
@wraps(f)
def decorated(*args, **kwargs):
# ⬇️ MOVE DE ACÁ LA IMPORTACIÓN DEL MODELO ⬇️
from app.models.user import User
token = request.headers.get('Authorization')
if not token:
return jsonify({'message': 'Token faltante'}), 401
try:
token = token.split(" ")[1] if " " in token else token
data = jwt.decode(token, os.environ.get('JWT_SECRET_KEY'), algorithms=["HS256"])
current_user = User.query.get(data['user_id'])
except Exception as e:
return jsonify({'message': 'Token inválido o expirado'}), 401
return f(current_user, *args, **kwargs)
return decorated
def has_permission(required_permission):
def decorator(f):
@wraps(f)
def decorated_function(current_user, *args, **kwargs):
if not current_user.role or required_permission not in current_user.role.permissions:
return jsonify({'message': 'No tenés permisos para realizar esta acción'}), 403
return f(current_user, *args, **kwargs)
return decorated_function
return decorator

View File

@ -0,0 +1,12 @@
# api/app/models/__init__.py
from app.models.user import User, Role
from app.models.biometria import (
Persona, OrigenHuella, TiposF, Subclasificacion,
Huella, PuntoCaracteristico, HuellaPuntoCaracteristico, FichaClasificacion
)
__all__ = [
'User', 'Role', 'Persona', 'OrigenHuella', 'TiposF',
'Subclasificacion', 'Huella', 'PuntoCaracteristico',
'HuellaPuntoCaracteristico', 'FichaClasificacion'
]

View File

@ -0,0 +1,92 @@
from app.database import db
class Persona(db.Model):
__tablename__ = 'personas'
cod_id = db.Column(db.Integer, primary_key=True)
cod_nombre = db.Column(db.String(255), nullable=False)
fecha_nacimiento = db.Column(db.Date, nullable=True)
sexo = db.Column(db.String(1), nullable=True) # 'M', 'F' u otros
# Relación inversa: Una persona puede tener varias huellas registradas
huellas = db.relationship('Huella', backref='persona', cascade='all, delete-orphan', lazy=True)
class OrigenHuella(db.Model):
__tablename__ = 'origen_huellas'
id = db.Column(db.Integer, primary_key=True)
descripcion = db.Column(db.String(255), nullable=False) # Ej: Índice, pulgar
lado = db.Column(db.String(50), nullable=True) # Ej: Derecho, izquierdo
huellas = db.relationship('Huella', backref='origen', lazy=True)
#Tipo fundamental para la clasificación dactilar, con relaciones a subclasificaciones y fichas de clasificación
class TiposF(db.Model):
__tablename__ = 'tipos_f'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
nombre = db.Column(db.String(255), nullable=False) # Ej: Arco, Presilla, Verticilo
foto = db.Column(db.LargeBinary, nullable=True) # Almacenamiento binario eficiente en Postgres
descripcion = db.Column(db.Text, nullable=True)
subclasificaciones = db.relationship('Subclasificacion', backref='tipo_f', lazy=True)
fichas = db.relationship('FichaClasificacion', backref='tipo_f', lazy=True)
class Subclasificacion(db.Model):
__tablename__ = 'subclasificaciones'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
nombre = db.Column(db.String(255), nullable=False) # Ej: Arco simple, Presilla derecha
tipo_f_id = db.Column(db.Integer, db.ForeignKey('tipos_f.id', ondelete='RESTRICT'), nullable=False)
fichas = db.relationship('FichaClasificacion', backref='subclasificacion', lazy=True)
class Huella(db.Model):
__tablename__ = 'huellas'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
descripcion = db.Column(db.String(255), nullable=True)
origen_hd = db.Column(db.String(255), nullable=True) # Ej: Escáner, Tinta
foto = db.Column(db.LargeBinary, nullable=True) # Imagen de la huella dactilar (Fichero RAW/WSQ)
persona_cod_id = db.Column(db.Integer, db.ForeignKey('personas.cod_id', ondelete='CASCADE'), nullable=False)
origen_huella_id = db.Column(db.Integer, db.ForeignKey('origen_huellas.id', ondelete='RESTRICT'), nullable=False)
# Relaciones Muchos a Muchos mediante tabla intermedia con atributos compuestos
puntos_caracteristicos = db.relationship('HuellaPuntoCaracteristico', backref='huella', cascade='all, delete-orphan', lazy=True)
fichas = db.relationship('FichaClasificacion', backref='huella', cascade='all, delete-orphan', lazy=True)
class PuntoCaracteristico(db.Model):
__tablename__ = 'puntos_caracteristicos'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
nombre = db.Column(db.String(255), nullable=False) # Ej: Bifurcación, Final de cresta
foto = db.Column(db.LargeBinary, nullable=True) # Gráfico patrón del punto
huellas_asociadas = db.relationship('HuellaPuntoCaracteristico', backref='punto', cascade='all, delete-orphan', lazy=True)
class HuellaPuntoCaracteristico(db.Model):
""" Tabla pivote relacional con atributos de coordenadas geo-espaciales (X, Y) y ángulo de la minucia """
__tablename__ = 'huella_punto_caracteristico'
huella_id = db.Column(db.Integer, db.ForeignKey('huellas.id', ondelete='CASCADE'), primary_key=True)
punto_id = db.Column(db.Integer, db.ForeignKey('puntos_caracteristicos.id', ondelete='CASCADE'), primary_key=True)
# Coordenadas numéricas precisas para algoritmos de correspondencia (Matching)
x = db.Column(db.Numeric(10, 6), nullable=False)
y = db.Column(db.Numeric(10, 6), nullable=False)
angulo = db.Column(db.Numeric(5, 2), nullable=False)
class FichaClasificacion(db.Model):
__tablename__ = 'fichas_clasificacion'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
huella_id = db.Column(db.Integer, db.ForeignKey('huellas.id', ondelete='CASCADE'), nullable=False)
subclasificacion_id = db.Column(db.Integer, db.ForeignKey('subclasificaciones.id', ondelete='RESTRICT'), nullable=False)
tipos_f_id = db.Column(db.Integer, db.ForeignKey('tipos_f.id', ondelete='RESTRICT'), nullable=False)
fecha = db.Column(db.Date, nullable=False, default=db.func.current_date())

View File

@ -0,0 +1,23 @@
from flask import Blueprint, request, jsonify
from app.database import db
from app.models.fingerprint import Fingerprint # Asumiendo que creás este modelo
from app.middlewares.auth_middleware import token_required, has_permission
fingerprints_bp = Blueprint('fingerprints', __name__)
@fingerprints_bp.route('/api/fingerprints', methods=['POST'])
@token_required
@has_permission('write:fingerprints')
def register_fingerprint(current_user):
data = request.get_json()
# Lógica de persistencia equivalente a: Fingerprint::create([...])
new_print = Fingerprint(
patient_id=data.get('patient_id'),
image_path=data.get('image_path'), # Ruta local o S3
researcher_id=current_user.id
)
db.session.add(new_print)
db.session.commit()
return jsonify({'status': 'Huella registrada con éxito', 'id': new_print.id}), 21

0
api/app/models/report.py Normal file
View File

33
api/app/models/user.py Normal file
View File

@ -0,0 +1,33 @@
from app.database import db
import bcrypt
class Role(db.Model):
__tablename__ = 'roles'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), unique=True, nullable=False)
permissions = db.Column(db.JSON, nullable=False)
users = db.relationship('User', backref='role', lazy=True)
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
role_id = db.Column(db.Integer, db.ForeignKey('roles.id'), nullable=True)
def set_password(self, password):
self.password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def check_password(self, password):
return bcrypt.checkpw(password.encode('utf-8'), self.password_hash.encode('utf-8'))
def to_dict(self):
return {
'id': self.id,
'username': self.username,
'email': self.email,
'role': self.role.name if self.role else None,
'permissions': self.role.permissions if self.role else []
}

View File

69
api/app/routes/auth.py Normal file
View File

@ -0,0 +1,69 @@
from flask import Blueprint, request, jsonify
from app.database import db
# ⬇️ Cambiamos esta línea para importar desde el módulo raíz de modelos ⬇️
from app.models import User, Role
from app.middlewares.auth_middleware import token_required
import jwt
import datetime
import os
# (El resto del código del archivo auth.py queda exactamente igual)
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/register', methods=['POST'])
def register():
data = request.get_json()
if not data or not data.get('username') or not data.get('password') or not data.get('email'):
return jsonify({'message': 'Datos incompletos'}), 400
if User.query.filter((User.username == data['username']) | (User.email == data['email'])).first():
return jsonify({'message': 'El usuario o email ya existe'}), 400
# Asignar un rol por defecto si no se especifica (ej: el ID del rol investigador)
default_role = Role.query.filter_by(name='investigador').first()
new_user = User(
username=data['username'],
email=data['email'],
role_id=default_role.id if default_role else None
)
new_user.set_password(data['password'])
db.session.add(new_user)
db.session.commit()
return jsonify({'message': 'Usuario registrado con éxito'}), 201
@auth_bp.route('/login', methods=['POST'])
def login():
data = request.get_json()
if not data or not data.get('username') or not data.get('password'):
return jsonify({'message': 'Credenciales requeridas'}), 400
user = User.query.filter_by(username=data['username']).first()
if not user or not user.check_password(data['password']):
return jsonify({'message': 'Usuario o contraseña incorrectos'}), 401
# Generación del JWT Token
payload = {
'user_id': user.id,
'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=2)
}
token = jwt.encode(payload, os.environ.get('JWT_SECRET_KEY', 'secret'), algorithm='HS256')
return jsonify({
'token': token,
'user': user.to_dict()
}), 200
@auth_bp.route('/me', methods=['GET'])
@token_required
def me(current_user):
# 'current_user' es inyectado automáticamente por nuestro middleware 'token_required'
return jsonify(current_user.to_dict()), 200

View File

View File

60
api/app/routes/roles.py Normal file
View File

@ -0,0 +1,60 @@
from flask import Blueprint, request, jsonify
from app.database import db
from app.models.user import Role
from app.middlewares.auth_middleware import token_required, has_permission
roles_bp = Blueprint('roles', __name__)
# INDEX: Listar todos los roles
@roles_bp.route('/roles', methods=['GET'])
@token_required
@has_permission('read:users') # Permiso administrativo general
def index(current_user):
roles = Role.query.all()
return jsonify([{'id': r.id, 'name': r.name, 'permissions': r.permissions} for r in roles]), 200
# STORE: Crear un nuevo rol
@roles_bp.route('/roles', methods=['POST'])
@token_required
@has_permission('write:users')
def store(current_user):
data = request.get_json()
if not data or not data.get('name') or not data.get('permissions'):
return jsonify({'message': 'Datos incompletos'}), 400
if Role.query.filter_by(name=data['name']).first():
return jsonify({'message': 'El rol ya existe'}), 400
new_role = Role(name=data['name'], permissions=data['permissions']) # Recibe lista de strings
db.session.add(new_role)
db.session.commit()
return jsonify({'message': 'Rol creado con éxito', 'id': new_role.id}), 201
# UPDATE: Modificar un rol existente
@roles_bp.route('/roles/<int:id>', methods=['PUT'])
@token_required
@has_permission('write:users')
def update(current_user, id):
role = Role.query.get_or_404(id)
data = request.get_json()
role.name = data.get('name', role.name)
role.permissions = data.get('permissions', role.permissions)
db.session.commit()
return jsonify({'message': 'Rol actualizado con éxito'}), 200
# DESTROY: Eliminar un rol
@roles_bp.route('/roles/<int:id>', methods=['DELETE'])
@token_required
@has_permission('write:users')
def destroy(current_user, id):
role = Role.query.get_or_404(id)
# Evitar romper restricciones de clave foránea si hay usuarios usándolo
if len(role.users) > 0:
return jsonify({'message': 'No se puede eliminar un rol asignado a usuarios activos'}), 400
db.session.delete(role)
db.session.commit()
return jsonify({'message': 'Rol eliminado con éxito'}), 200

12
api/app/routes/users.py Normal file
View File

@ -0,0 +1,12 @@
from flask import Blueprint, jsonify
from app.models import User
from app.middlewares.auth_middleware import token_required, has_permission
users_bp = Blueprint('users', __name__)
@users_bp.route('/users', methods=['GET'])
@token_required
@has_permission('read:users')
def index(current_user):
users = User.query.all()
return jsonify([user.to_dict() for user in users]), 200

1
api/migrations/README Normal file
View File

@ -0,0 +1 @@
Single-database configuration for Flask.

View File

@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

113
api/migrations/env.py Normal file
View File

@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,46 @@
"""crear_tablas_iniciales
Revision ID: 04e47df0b26f
Revises:
Create Date: 2026-05-21 16:20:06.426487
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '04e47df0b26f'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('roles',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=50), nullable=False),
sa.Column('permissions', sa.JSON(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=80), nullable=False),
sa.Column('email', sa.String(length=120), nullable=False),
sa.Column('password_hash', sa.String(length=128), nullable=False),
sa.Column('role_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email'),
sa.UniqueConstraint('username')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('users')
op.drop_table('roles')
# ### end Alembic commands ###

View File

@ -0,0 +1,99 @@
"""migracion_sistema_papiloscopico
Revision ID: a946075b6c2c
Revises: 04e47df0b26f
Create Date: 2026-05-21 19:06:40.586192
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a946075b6c2c'
down_revision = '04e47df0b26f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('origen_huellas',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('descripcion', sa.String(length=255), nullable=False),
sa.Column('lado', sa.String(length=50), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('personas',
sa.Column('cod_id', sa.Integer(), nullable=False),
sa.Column('cod_nombre', sa.String(length=255), nullable=False),
sa.Column('fecha_nacimiento', sa.Date(), nullable=True),
sa.Column('sexo', sa.String(length=1), nullable=True),
sa.PrimaryKeyConstraint('cod_id')
)
op.create_table('puntos_caracteristicos',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('nombre', sa.String(length=255), nullable=False),
sa.Column('foto', sa.LargeBinary(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('tipos_f',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('nombre', sa.String(length=255), nullable=False),
sa.Column('foto', sa.LargeBinary(), nullable=True),
sa.Column('descripcion', sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('huellas',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('descripcion', sa.String(length=255), nullable=True),
sa.Column('origen_hd', sa.String(length=255), nullable=True),
sa.Column('foto', sa.LargeBinary(), nullable=True),
sa.Column('persona_cod_id', sa.Integer(), nullable=False),
sa.Column('origen_huella_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['origen_huella_id'], ['origen_huellas.id'], ondelete='RESTRICT'),
sa.ForeignKeyConstraint(['persona_cod_id'], ['personas.cod_id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('subclasificaciones',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('nombre', sa.String(length=255), nullable=False),
sa.Column('tipo_f_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['tipo_f_id'], ['tipos_f.id'], ondelete='RESTRICT'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('fichas_clasificacion',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('huella_id', sa.Integer(), nullable=False),
sa.Column('subclasificacion_id', sa.Integer(), nullable=False),
sa.Column('tipos_f_id', sa.Integer(), nullable=False),
sa.Column('fecha', sa.Date(), nullable=False),
sa.ForeignKeyConstraint(['huella_id'], ['huellas.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['subclasificacion_id'], ['subclasificaciones.id'], ondelete='RESTRICT'),
sa.ForeignKeyConstraint(['tipos_f_id'], ['tipos_f.id'], ondelete='RESTRICT'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('huella_punto_caracteristico',
sa.Column('huella_id', sa.Integer(), nullable=False),
sa.Column('punto_id', sa.Integer(), nullable=False),
sa.Column('x', sa.Numeric(precision=10, scale=6), nullable=False),
sa.Column('y', sa.Numeric(precision=10, scale=6), nullable=False),
sa.Column('angulo', sa.Numeric(precision=5, scale=2), nullable=False),
sa.ForeignKeyConstraint(['huella_id'], ['huellas.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['punto_id'], ['puntos_caracteristicos.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('huella_id', 'punto_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('huella_punto_caracteristico')
op.drop_table('fichas_clasificacion')
op.drop_table('subclasificaciones')
op.drop_table('huellas')
op.drop_table('tipos_f')
op.drop_table('puntos_caracteristicos')
op.drop_table('personas')
op.drop_table('origen_huellas')
# ### end Alembic commands ###

7
api/requirements.txt Normal file
View File

@ -0,0 +1,7 @@
Flask==3.0.2
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.7
psycopg2-binary==2.9.9
PyJWT==2.8.0
bcrypt==4.1.2
Flask-CORS==4.0.0

215
api/run.py Normal file
View File

@ -0,0 +1,215 @@
import click
from flask import Flask
from flask.cli import with_appcontext
from flask_migrate import Migrate
from datetime import datetime
import os
from flask_cors import CORS
# Importamos la base de datos y los modelos del S.I.P
from app.database import db
from app.models import Role, User
from app.models.biometria import (
Persona, OrigenHuella, TiposF, Subclasificacion,
Huella, PuntoCaracteristico, HuellaPuntoCaracteristico, FichaClasificacion
)
# =========================================================
# 1. COMANDO CLI DE CLICK (Configurado Correctamente)
# =========================================================
@click.command(name="seed")
@with_appcontext
def seed_db_command():
"""Siembra de roles, usuarios administrativos y datos iniciales del S.I.P"""
click.echo("Iniciando el sembrado de datos en PostgreSQL...")
# --- [ROLES Y USUARIOS] ---
admin_role = Role.query.filter_by(name='admin').first()
if not admin_role:
admin_role = Role(name='admin', permissions=['read:users', 'write:users', 'manage:biometria'])
db.session.add(admin_role)
db.session.commit()
investigador_role = Role.query.filter_by(name='investigador').first()
if not investigador_role:
investigador_role = Role(name='investigador', permissions=['manage:biometria'])
db.session.add(investigador_role)
db.session.commit()
admin_user = User.query.filter_by(username='admin_root').first()
if not admin_user:
admin_user = User(username='admin_root', email='admin@uader.edu.ar', role_id=admin_role.id)
admin_user.set_password('admin1234')
db.session.add(admin_user)
db.session.commit()
investigador_user = User.query.filter_by(username='investigador_demo').first()
if not investigador_user:
investigador_user = User(username='investigador_demo', email='investigador@uader.edu.ar', role_id=investigador_role.id)
investigador_user.set_password('investigador1234')
db.session.add(investigador_user)
db.session.commit()
# --- [1. PERSONAS] ---
if Persona.query.count() == 0:
personas_data = [
Persona(cod_id=1, cod_nombre='Juan Pérez', fecha_nacimiento=datetime.strptime('1990-05-20', '%Y-%m-%d').date(), sexo='M'),
Persona(cod_id=2, cod_nombre='Ana Gómez', fecha_nacimiento=datetime.strptime('1985-12-01', '%Y-%m-%d').date(), sexo='F'),
Persona(cod_id=3, cod_nombre='Carlos López', fecha_nacimiento=datetime.strptime('2000-07-15', '%Y-%m-%d').date(), sexo='M')
]
db.session.bulk_save_objects(personas_data)
# --- [2. ORIGEN DE HUELLAS] ---
if OrigenHuella.query.count() == 0:
origenes_data = [
OrigenHuella(id=1, descripcion='Índice derecho', lado='derecho'),
OrigenHuella(id=2, descripcion='Índice izquierdo', lado='izquierdo'),
OrigenHuella(id=3, descripcion='Pulgar derecho', lado='derecho')
]
db.session.bulk_save_objects(origenes_data)
# --- [3. TIPOS FUNDAMENTALES] ---
if TiposF.query.count() == 0:
tipos_data = [
TiposF(id=1, nombre='Arco', descripcion='Huella en forma de arco', foto=None),
TiposF(id=2, nombre='Presilla', descripcion='Huella en forma de presilla', foto=None),
TiposF(id=3, nombre='Verticilo', descripcion='Huella en forma de espiral', foto=None)
]
db.session.bulk_save_objects(tipos_data)
db.session.commit()
# --- [4. SUBCLASIFICACIONES] ---
if Subclasificacion.query.count() == 0:
sub_data = [
Subclasificacion(id=1, nombre='Arco simple', tipo_f_id=1),
Subclasificacion(id=2, nombre='Presilla derecha', tipo_f_id=2),
Subclasificacion(id=3, nombre='Verticilo central', tipo_f_id=3)
]
db.session.bulk_save_objects(sub_data)
# --- [5. PUNTOS CARACTERÍSTICOS] ---
if PuntoCaracteristico.query.count() == 0:
puntos_data = [
PuntoCaracteristico(id=1, nombre='Bifurcación', foto=None),
PuntoCaracteristico(id=2, nombre='Final de cresta', foto=None),
PuntoCaracteristico(id=3, nombre='Punto de isla', foto=None)
]
db.session.bulk_save_objects(puntos_data)
db.session.commit()
# --- [6. HUELLAS] ---
if Huella.query.count() == 0:
huellas_data = [
Huella(id=1, descripcion='Huella digital clara', origen_hd='Escáner', foto=None, persona_cod_id=1, origen_huella_id=1),
Huella(id=2, descripcion='Huella con ligera distorsión', origen_hd='Tinta', foto=None, persona_cod_id=2, origen_huella_id=2),
Huella(id=3, descripcion='Huella poco definida', origen_hd='Escáner', foto=None, persona_cod_id=3, origen_huella_id=3)
]
db.session.bulk_save_objects(huellas_data)
db.session.commit()
# --- [7. COORDENADAS PIVOTE] ---
if HuellaPuntoCaracteristico.query.count() == 0:
coordenadas_data = [
HuellaPuntoCaracteristico(huella_id=1, punto_id=1, x=12.345678, y=45.678912, angulo=30.00),
HuellaPuntoCaracteristico(huella_id=1, punto_id=2, x=15.111111, y=50.222222, angulo=45.00),
HuellaPuntoCaracteristico(huella_id=2, punto_id=3, x=20.333333, y=60.444444, angulo=60.00)
]
db.session.bulk_save_objects(coordenadas_data)
# --- [8. FICHAS DE CLASIFICACIÓN] ---
if FichaClasificacion.query.count() == 0:
fichas_data = [
FichaClasificacion(id=1, huella_id=1, subclasificacion_id=1, tipos_f_id=1, fecha=datetime.strptime('2025-09-30', '%Y-%m-%d').date()),
FichaClasificacion(id=2, huella_id=2, subclasificacion_id=2, tipos_f_id=2, fecha=datetime.strptime('2025-09-30', '%Y-%m-%d').date()),
FichaClasificacion(id=3, huella_id=3, subclasificacion_id=3, tipos_f_id=3, fecha=datetime.strptime('2025-09-30', '%Y-%m-%d').date())
]
db.session.bulk_save_objects(fichas_data)
db.session.commit()
click.echo("¡Base de datos del S.I.P sembrada e integrada al 100%!")
# =========================================================
# 2. FABRICA DE LA APLICACIÓN (Application Factory)
# =========================================================
def create_app():
app = Flask(__name__)
# 3. Inicialización de Flask-CORS agresiva a nivel de aplicación interna
CORS(app, resources={r"/*": {"origins": "*"}}, supports_credentials=True)
# 4. CAPA DE CONTROL ADICIONAL: Interceptor nativo de Preflight Checks
@app.after_request
def add_cors_headers(response):
# Permitimos cualquier origen de desarrollo (como tu puerto 8080)
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Headers"] = "Content-Type,Authorization"
response.headers["Access-Control-Allow-Methods"] = "GET,POST,PUT,DELETE,OPTIONS"
return response
@app.before_request
def handle_options_directly():
from flask import make_response, request
# Si el navegador pregunta mediante OPTIONS, respondemos de inmediato con 200 OK
# saltándonos cualquier validación posterior de rutas o Blueprints
if request.method.upper() == "OPTIONS":
response = make_response()
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Headers"] = "Content-Type,Authorization"
response.headers["Access-Control-Allow-Methods"] = "GET,POST,PUT,DELETE,OPTIONS"
return response, 200
# Mapeo exacto de tu archivo .env de la raíz
POSTGRES_USER = os.getenv('POSTGRES_USER', 'admin')
POSTGRES_PASSWORD = os.getenv('POSTGRES_PASSWORD', 'secretpassword_2026_x8')
POSTGRES_DB = os.getenv('POSTGRES_DB', 'huellas_investigacion')
POSTGRES_HOST = os.getenv('POSTGRES_HOST', 'db') # Cambiado a 'db' según tu .env
POSTGRES_PORT = os.getenv('POSTGRES_PORT', '5432')
# Reconstruimos la URI de forma segura en Python para evitar fallos de interpolación string
app.config['SQLALCHEMY_DATABASE_URI'] = f'postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# Secreto para los Tokens JWT de tu módulo auth
app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY', 'laravel_style_jwt_secret_token_scientific_2026')
# Inicializar base de datos y migraciones
db.init_app(app)
Migrate(app, db)
# =========================================================
# REGISTRO DE BLUEPRINTS (Normalización de Prefijos REST)
# =========================================================
try:
# 1. Rutas de Autenticación
from app.routes.auth import auth_bp
# Mapea directamente: POST /api/auth/login y GET /api/auth/me
app.register_blueprint(auth_bp, url_prefix='/api/auth')
print("-> Blueprint 'auth' registrado en /api/auth")
# 2. Rutas de Usuarios
from app.routes.users import users_bp
# Al poner el prefijo '/api' a secas, mapea exacto: GET /api/users
app.register_blueprint(users_bp, url_prefix='/api')
print("-> Blueprint 'users' registrado en /api")
# 3. Rutas de Roles
from app.routes.roles import roles_bp
# Al poner el prefijo '/api' a secas, mapea exacto: GET /api/roles y POST /api/roles
app.register_blueprint(roles_bp, url_prefix='/api')
print("-> Blueprint 'roles' registrado en /api")
except ImportError as e:
print(f"⚠️ Error al importar los módulos de rutas: {e}")
# Registrar comando seed
app.cli.add_command(seed_db_command)
return app
app = create_app()
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)