feat: Implementación de gestión manual y panel de administración
Se introduce una refactorización masiva y se añaden nuevas funcionalidades críticas para la gestión del inventario, incluyendo un panel de administración para la limpieza de datos y un sistema completo para la gestión manual de equipos.
### Nuevas Funcionalidades
* **Panel de Administración:** Se crea una nueva vista de "Administración" para la gestión de datos maestros. Permite unificar valores inconsistentes (ej: "W10" -> "Windows 10 Pro") y eliminar registros maestros no utilizados (ej: Módulos de RAM) para mantener la base de datos limpia.
* **Gestión de Sectores (CRUD):** Se añade una vista dedicada para crear, editar y eliminar sectores de la organización.
* **Diferenciación Manual vs. Automático:** Se introduce una columna `origen` en la base de datos para distinguir entre los datos recopilados automáticamente por el script y los introducidos manualmente por el usuario. La UI ahora refleja visualmente este origen.
* **CRUD de Equipos Manuales:** Se implementa la capacidad de crear, editar y eliminar equipos de origen "manual" a través de la interfaz de usuario. Se protege la eliminación de equipos automáticos.
* **Gestión de Componentes Manuales:** Se permite añadir y eliminar componentes (Discos, RAM, Usuarios) a los equipos de origen "manual".
### Mejoras de UI/UX
* **Refactorización de Estilos:** Se migran todos los estilos en línea del componente `SimpleTable` a un archivo CSS Module (`SimpleTable.module.css`), mejorando la mantenibilidad y el rendimiento.
* **Notificaciones de Usuario:** Se integra `react-hot-toast` para proporcionar feedback visual inmediato (carga, éxito, error) en todas las operaciones asíncronas, reemplazando los `alert`.
* **Componentización:** Se extraen todos los modales (`ModalDetallesEquipo`, `ModalAnadirEquipo`, etc.) a sus propios componentes, limpiando y simplificando drásticamente el componente `SimpleTable`.
* **Paginación en Tabla Principal:** Se implementa paginación completa en la tabla de equipos, con controles para navegar, ir a una página específica y cambiar el número de items por página. Se añade un indicador de carga inicial.
* **Navegación Mejorada:** Se reemplaza la navegación por botones con un componente `Navbar` estilizado y dedicado, mejorando la estructura visual y de código.
* **Autocompletado de Datos:** Se introduce un componente `AutocompleteInput` reutilizable para guiar al usuario a usar datos consistentes al rellenar campos como OS, CPU y Motherboard. Se implementa búsqueda dinámica para la asociación de usuarios.
* **Validación de MAC Address:** Se añade validación de formato en tiempo real y auto-formateo para el campo de MAC Address, reduciendo errores humanos.
* **Consistencia de Iconos:** Se unifica el icono de eliminación a (🗑️) en toda la aplicación para una experiencia de usuario más coherente.
### Mejoras en el Backend / API
* **Seguridad de Credenciales:** Las credenciales SSH para la función Wake On Lan se mueven del código fuente a `appsettings.json`.
* **Nuevo `AdminController`:** Se crea un controlador dedicado para las tareas administrativas, con endpoints para obtener valores únicos de componentes y para ejecutar la lógica de unificación y eliminación.
* **Endpoints de Gestión Manual:** Se añaden rutas específicas (`/manual/...` y `/asociacion/...`) para la manipulación de datos de origen manual, separando la lógica de la gestión automática.
* **Protección de Datos Automáticos:** Los endpoints `DELETE` y `PUT` ahora validan el campo `origen` para prevenir la modificación o eliminación no deseada de datos generados automáticamente.
* **Correcciones y Refinamiento:** Se soluciona el mapeo incorrecto de fechas (`created_at`, `updated_at`), se corrigen errores de compilación y se refinan las consultas SQL para incluir los nuevos campos.
This commit is contained in:
192
backend/Controllers/AdminController.cs
Normal file
192
backend/Controllers/AdminController.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
// backend/Controllers/AdminController.cs
|
||||
using Dapper;
|
||||
using Inventario.API.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Inventario.API.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AdminController : ControllerBase
|
||||
{
|
||||
private readonly DapperContext _context;
|
||||
|
||||
public AdminController(DapperContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
// DTO para devolver los valores y su conteo
|
||||
public class ComponenteValorDto
|
||||
{
|
||||
public string Valor { get; set; } = "";
|
||||
public int Conteo { get; set; }
|
||||
}
|
||||
|
||||
[HttpGet("componentes/{tipo}")]
|
||||
public async Task<IActionResult> GetComponenteValores(string tipo)
|
||||
{
|
||||
var allowedTypes = new Dictionary<string, string>
|
||||
{
|
||||
{ "os", "Os" },
|
||||
{ "cpu", "Cpu" },
|
||||
{ "motherboard", "Motherboard" },
|
||||
{ "architecture", "Architecture" }
|
||||
};
|
||||
|
||||
if (!allowedTypes.TryGetValue(tipo.ToLower(), out var columnName))
|
||||
{
|
||||
return BadRequest("Tipo de componente no válido.");
|
||||
}
|
||||
|
||||
var query = $@"
|
||||
SELECT {columnName} AS Valor, COUNT(*) AS Conteo
|
||||
FROM dbo.equipos
|
||||
WHERE {columnName} IS NOT NULL AND {columnName} != ''
|
||||
GROUP BY {columnName}
|
||||
ORDER BY Conteo DESC, Valor ASC;";
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var valores = await connection.QueryAsync<ComponenteValorDto>(query);
|
||||
return Ok(valores);
|
||||
}
|
||||
}
|
||||
|
||||
// DTO para la petición de unificación
|
||||
public class UnificarComponenteDto
|
||||
{
|
||||
public required string ValorNuevo { get; set; }
|
||||
public required string ValorAntiguo { get; set; }
|
||||
}
|
||||
|
||||
[HttpPut("componentes/{tipo}/unificar")]
|
||||
public async Task<IActionResult> UnificarComponenteValores(string tipo, [FromBody] UnificarComponenteDto dto)
|
||||
{
|
||||
var allowedTypes = new Dictionary<string, string>
|
||||
{
|
||||
{ "os", "Os" },
|
||||
{ "cpu", "Cpu" },
|
||||
{ "motherboard", "Motherboard" },
|
||||
{ "architecture", "Architecture" }
|
||||
};
|
||||
|
||||
if (!allowedTypes.TryGetValue(tipo.ToLower(), out var columnName))
|
||||
{
|
||||
return BadRequest("Tipo de componente no válido.");
|
||||
}
|
||||
|
||||
if (dto.ValorAntiguo == dto.ValorNuevo)
|
||||
{
|
||||
return BadRequest("El valor antiguo y el nuevo no pueden ser iguales.");
|
||||
}
|
||||
|
||||
var query = $@"
|
||||
UPDATE dbo.equipos
|
||||
SET {columnName} = @ValorNuevo
|
||||
WHERE {columnName} = @ValorAntiguo;";
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var filasAfectadas = await connection.ExecuteAsync(query, new { dto.ValorNuevo, dto.ValorAntiguo });
|
||||
return Ok(new { message = $"Se unificaron {filasAfectadas} registros.", filasAfectadas });
|
||||
}
|
||||
}
|
||||
|
||||
// DTO para devolver los valores de RAM y su conteo
|
||||
public class RamMaestraDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Part_number { get; set; }
|
||||
public string? Fabricante { get; set; }
|
||||
public int Tamano { get; set; }
|
||||
public int? Velocidad { get; set; }
|
||||
public int Conteo { get; set; }
|
||||
}
|
||||
|
||||
[HttpGet("componentes/ram")]
|
||||
public async Task<IActionResult> GetComponentesRam()
|
||||
{
|
||||
var query = @"
|
||||
SELECT
|
||||
mr.Id, mr.part_number as PartNumber, mr.Fabricante, mr.Tamano, mr.Velocidad,
|
||||
COUNT(emr.memoria_ram_id) as Conteo
|
||||
FROM
|
||||
dbo.memorias_ram mr
|
||||
LEFT JOIN
|
||||
dbo.equipos_memorias_ram emr ON mr.id = emr.memoria_ram_id
|
||||
GROUP BY
|
||||
mr.Id, mr.part_number, mr.Fabricante, mr.Tamano, mr.Velocidad
|
||||
ORDER BY
|
||||
Conteo DESC, mr.Fabricante, mr.Tamano;";
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var valores = await connection.QueryAsync<RamMaestraDto>(query);
|
||||
return Ok(valores);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("componentes/ram/{id}")]
|
||||
public async Task<IActionResult> BorrarComponenteRam(int id)
|
||||
{
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
// 1. Verificación de seguridad: Asegurarse de que el módulo no esté en uso.
|
||||
var usageQuery = "SELECT COUNT(*) FROM dbo.equipos_memorias_ram WHERE memoria_ram_id = @Id;";
|
||||
var usageCount = await connection.ExecuteScalarAsync<int>(usageQuery, new { Id = id });
|
||||
|
||||
if (usageCount > 0)
|
||||
{
|
||||
return Conflict($"Este módulo de RAM está en uso por {usageCount} equipo(s) y no puede ser eliminado.");
|
||||
}
|
||||
|
||||
// 2. Si no está en uso, proceder con la eliminación.
|
||||
var deleteQuery = "DELETE FROM dbo.memorias_ram WHERE Id = @Id;";
|
||||
var filasAfectadas = await connection.ExecuteAsync(deleteQuery, new { Id = id });
|
||||
|
||||
if (filasAfectadas == 0)
|
||||
{
|
||||
return NotFound("Módulo de RAM no encontrado.");
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("componentes/{tipo}/{valor}")]
|
||||
public async Task<IActionResult> BorrarComponenteTexto(string tipo, string valor)
|
||||
{
|
||||
var allowedTypes = new Dictionary<string, string>
|
||||
{
|
||||
{ "os", "Os" },
|
||||
{ "cpu", "Cpu" },
|
||||
{ "motherboard", "Motherboard" },
|
||||
{ "architecture", "Architecture" }
|
||||
};
|
||||
|
||||
if (!allowedTypes.TryGetValue(tipo.ToLower(), out var columnName))
|
||||
{
|
||||
return BadRequest("Tipo de componente no válido.");
|
||||
}
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
// 1. Verificación de seguridad: Asegurarse de que el valor no esté en uso.
|
||||
var usageQuery = $"SELECT COUNT(*) FROM dbo.equipos WHERE {columnName} = @Valor;";
|
||||
var usageCount = await connection.ExecuteScalarAsync<int>(usageQuery, new { Valor = valor });
|
||||
|
||||
if (usageCount > 0)
|
||||
{
|
||||
return Conflict($"Este valor está en uso por {usageCount} equipo(s) y no puede ser eliminado. Intente unificarlo en su lugar.");
|
||||
}
|
||||
|
||||
// Esta parte es más conceptual. Un componente de texto no existe en una tabla maestra,
|
||||
// por lo que no hay nada que "eliminar". El hecho de que el conteo sea 0 significa
|
||||
// que ya no existe en la práctica. Devolvemos éxito para confirmar esto.
|
||||
// Si tuviéramos tablas maestras (ej: dbo.sistemas_operativos), aquí iría la consulta DELETE.
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using System.Data;
|
||||
using System.Net.NetworkInformation;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Renci.SshNet;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Inventario.API.Controllers
|
||||
{
|
||||
@@ -15,24 +17,27 @@ namespace Inventario.API.Controllers
|
||||
public class EquiposController : ControllerBase
|
||||
{
|
||||
private readonly DapperContext _context;
|
||||
private readonly IConfiguration _configuration; // 1. Añadimos el campo para la configuración
|
||||
|
||||
public EquiposController(DapperContext context)
|
||||
// 2. Modificamos el constructor para inyectar IConfiguration
|
||||
public EquiposController(DapperContext context, IConfiguration configuration)
|
||||
{
|
||||
_context = context;
|
||||
_configuration = configuration; // Asignamos la configuración inyectada
|
||||
}
|
||||
|
||||
// --- MÉTODOS CRUD BÁSICOS (Ya implementados) ---
|
||||
// --- MÉTODOS CRUD BÁSICOS ---
|
||||
// GET /api/equipos
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Consultar()
|
||||
{
|
||||
var query = @"
|
||||
SELECT
|
||||
e.Id, e.Hostname, e.Ip, e.Mac, e.Motherboard, e.Cpu, e.Ram_installed, e.Ram_slots, e.Os, e.Architecture,
|
||||
e.Id, e.Hostname, e.Ip, e.Mac, e.Motherboard, e.Cpu, e.Ram_installed, e.Ram_slots, e.Os, e.Architecture, e.created_at, e.updated_at, e.Origen,
|
||||
s.Id as Id, s.Nombre,
|
||||
u.Id as Id, u.Username, u.Password,
|
||||
d.Id as Id, d.Mediatype, d.Size,
|
||||
mr.Id as Id, mr.part_number as PartNumber, mr.Fabricante, mr.Tamano, mr.Velocidad, emr.Slot
|
||||
u.Id as Id, u.Username, u.Password, ue.Origen as Origen,
|
||||
d.Id as Id, d.Mediatype, d.Size, ed.Origen as Origen, ed.Id as EquipoDiscoId,
|
||||
mr.Id as Id, mr.part_number as PartNumber, mr.Fabricante, mr.Tamano, mr.Velocidad, emr.Slot, emr.Origen as Origen, emr.Id as EquipoMemoriaRamId
|
||||
FROM dbo.equipos e
|
||||
LEFT JOIN dbo.sectores s ON e.sector_id = s.id
|
||||
LEFT JOIN dbo.usuarios_equipos ue ON e.id = ue.equipo_id
|
||||
@@ -46,7 +51,8 @@ namespace Inventario.API.Controllers
|
||||
{
|
||||
var equipoDict = new Dictionary<int, Equipo>();
|
||||
|
||||
await connection.QueryAsync<Equipo, Sector, Usuario, Disco, MemoriaRamDetalle, Equipo>(
|
||||
// CAMBIO: Se actualizan los tipos en la función de mapeo de Dapper
|
||||
await connection.QueryAsync<Equipo, Sector, UsuarioEquipoDetalle, DiscoDetalle, MemoriaRamEquipoDetalle, Equipo>(
|
||||
query, (equipo, sector, usuario, disco, memoria) =>
|
||||
{
|
||||
if (!equipoDict.TryGetValue(equipo.Id, out var equipoActual))
|
||||
@@ -55,6 +61,7 @@ namespace Inventario.API.Controllers
|
||||
equipoActual.Sector = sector;
|
||||
equipoDict.Add(equipoActual.Id, equipoActual);
|
||||
}
|
||||
// CAMBIO: Se ajusta la lógica para evitar duplicados en los nuevos tipos detallados
|
||||
if (usuario != null && !equipoActual.Usuarios.Any(u => u.Id == usuario.Id))
|
||||
equipoActual.Usuarios.Add(usuario);
|
||||
if (disco != null && !equipoActual.Discos.Any(d => d.Id == disco.Id))
|
||||
@@ -64,7 +71,7 @@ namespace Inventario.API.Controllers
|
||||
|
||||
return equipoActual;
|
||||
},
|
||||
splitOn: "Id,Id,Id,Id" // Dapper divide en cada 'Id'
|
||||
splitOn: "Id,Id,Id,Id"
|
||||
);
|
||||
return Ok(equipoDict.Values.OrderBy(e => e.Sector?.Nombre).ThenBy(e => e.Hostname));
|
||||
}
|
||||
@@ -77,7 +84,7 @@ namespace Inventario.API.Controllers
|
||||
var query = @"SELECT
|
||||
e.*,
|
||||
s.Id as SectorId, s.Nombre as SectorNombre,
|
||||
u.Id as UsuarioId, u.Username, u.Password
|
||||
u.Id as UsuarioId, u.Username, u.Password, ue.Origen as Origen
|
||||
FROM dbo.equipos e
|
||||
LEFT JOIN dbo.sectores s ON e.sector_id = s.id
|
||||
LEFT JOIN dbo.usuarios_equipos ue ON e.id = ue.equipo_id
|
||||
@@ -87,7 +94,8 @@ namespace Inventario.API.Controllers
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var equipoDict = new Dictionary<int, Equipo>();
|
||||
var equipo = (await connection.QueryAsync<Equipo, Sector, Usuario, Equipo>(
|
||||
|
||||
var equipo = (await connection.QueryAsync<Equipo, Sector, UsuarioEquipoDetalle, Equipo>(
|
||||
query, (e, sector, usuario) =>
|
||||
{
|
||||
if (!equipoDict.TryGetValue(e.Id, out var equipoActual))
|
||||
@@ -107,6 +115,12 @@ namespace Inventario.API.Controllers
|
||||
|
||||
if (equipo == null) return NotFound("Equipo no encontrado.");
|
||||
|
||||
var discosQuery = "SELECT d.*, ed.Origen, ed.Id as EquipoDiscoId FROM dbo.discos d JOIN dbo.equipos_discos ed ON d.Id = ed.disco_id WHERE ed.equipo_id = @Id";
|
||||
equipo.Discos = (await connection.QueryAsync<DiscoDetalle>(discosQuery, new { equipo.Id })).ToList();
|
||||
|
||||
var ramQuery = "SELECT mr.*, emr.Slot, emr.Origen, emr.Id as EquipoMemoriaRamId FROM dbo.memorias_ram mr JOIN dbo.equipos_memorias_ram emr ON mr.Id = emr.memoria_ram_id WHERE emr.equipo_id = @Id";
|
||||
equipo.MemoriasRam = (await connection.QueryAsync<MemoriaRamEquipoDetalle>(ramQuery, new { equipo.Id })).ToList();
|
||||
|
||||
return Ok(equipo);
|
||||
}
|
||||
}
|
||||
@@ -124,8 +138,8 @@ namespace Inventario.API.Controllers
|
||||
if (equipoExistente == null)
|
||||
{
|
||||
// Crear
|
||||
var insertQuery = @"INSERT INTO dbo.equipos (Hostname, Ip, Mac, Motherboard, Cpu, Ram_installed, Ram_slots, Os, Architecture)
|
||||
VALUES (@Hostname, @Ip, @Mac, @Motherboard, @Cpu, @Ram_installed, @Ram_slots, @Os, @Architecture);
|
||||
var insertQuery = @"INSERT INTO dbo.equipos (Hostname, Ip, Mac, Motherboard, Cpu, Ram_installed, Ram_slots, Os, Architecture, Origen)
|
||||
VALUES (@Hostname, @Ip, @Mac, @Motherboard, @Cpu, @Ram_installed, @Ram_slots, @Os, @Architecture, 'automatica');
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
var nuevoId = await connection.ExecuteScalarAsync<int>(insertQuery, equipoData);
|
||||
equipoData.Id = nuevoId;
|
||||
@@ -177,12 +191,16 @@ namespace Inventario.API.Controllers
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Borrar(int id)
|
||||
{
|
||||
// La base de datos está configurada con ON DELETE CASCADE, por lo que las relaciones se borrarán automáticamente.
|
||||
var query = "DELETE FROM dbo.equipos WHERE Id = @Id;";
|
||||
var query = "DELETE FROM dbo.equipos WHERE Id = @Id AND Origen = 'manual';";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var filasAfectadas = await connection.ExecuteAsync(query, new { Id = id });
|
||||
if (filasAfectadas == 0) return NotFound("Equipo no encontrado.");
|
||||
if (filasAfectadas == 0)
|
||||
{
|
||||
// Puede que no se haya borrado porque no existe o porque es automático.
|
||||
// Damos un mensaje de error genérico pero informativo.
|
||||
return NotFound("Equipo no encontrado o no se puede eliminar porque fue generado automáticamente.");
|
||||
}
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -222,8 +240,8 @@ namespace Inventario.API.Controllers
|
||||
public async Task<IActionResult> AsociarUsuario(string hostname, [FromBody] AsociacionUsuarioDto dto)
|
||||
{
|
||||
var query = @"
|
||||
INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id)
|
||||
SELECT e.id, u.id
|
||||
INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id, origen)
|
||||
SELECT e.id, u.id, 'automatica'
|
||||
FROM dbo.equipos e, dbo.usuarios u
|
||||
WHERE e.Hostname = @Hostname AND u.Username = @Username;";
|
||||
|
||||
@@ -283,14 +301,7 @@ namespace Inventario.API.Controllers
|
||||
JOIN dbo.discos d ON ed.disco_id = d.id
|
||||
WHERE ed.equipo_id = @EquipoId;";
|
||||
|
||||
// Creamos una clase anónima temporal para mapear el resultado del JOIN
|
||||
var discosEnDb = (await connection.QueryAsync(discosActualesQuery, new { EquipoId = equipo.Id }, transaction)).Select(d => new
|
||||
{
|
||||
Id = (int)d.Id,
|
||||
Mediatype = (string)d.Mediatype,
|
||||
Size = (int)d.Size,
|
||||
EquipoDiscoId = (int)d.EquipoDiscoId
|
||||
}).ToList();
|
||||
var discosEnDb = (await connection.QueryAsync<DiscoAsociado>(discosActualesQuery, new { EquipoId = equipo.Id }, transaction)).ToList();
|
||||
|
||||
// 3. AGRUPAR Y CONTAR DISCOS (del cliente y de la BD)
|
||||
// Crea un diccionario estilo: {"SSD_256": 2, "HDD_1024": 1}
|
||||
@@ -344,9 +355,9 @@ namespace Inventario.API.Controllers
|
||||
{
|
||||
// Este es un disco nuevo que hay que asociar.
|
||||
var disco = await connection.QuerySingleOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size;", discoCliente, transaction);
|
||||
if (disco == null) continue; // Si el disco no existe en la tabla maestra, lo ignoramos
|
||||
if (disco == null) continue;
|
||||
|
||||
await connection.ExecuteAsync("INSERT INTO dbo.equipos_discos (equipo_id, disco_id) VALUES (@EquipoId, @DiscoId);", new { EquipoId = equipo.Id, DiscoId = disco.Id }, transaction);
|
||||
await connection.ExecuteAsync("INSERT INTO dbo.equipos_discos (equipo_id, disco_id, origen) VALUES (@EquipoId, @DiscoId, 'automatica');", new { EquipoId = equipo.Id, DiscoId = disco.Id }, transaction);
|
||||
|
||||
// Registrar para el historial
|
||||
var nombreDisco = $"Disco {disco.Mediatype} {disco.Size}GB";
|
||||
@@ -381,81 +392,85 @@ namespace Inventario.API.Controllers
|
||||
}
|
||||
|
||||
[HttpPost("{hostname}/ram")]
|
||||
public async Task<IActionResult> AsociarRam(string hostname, [FromBody] List<MemoriaRamDetalle> memoriasDesdeCliente)
|
||||
public async Task<IActionResult> AsociarRam(string hostname, [FromBody] List<MemoriaRamEquipoDetalle> memoriasDesdeCliente)
|
||||
{
|
||||
var equipoQuery = "SELECT * FROM dbo.equipos WHERE Hostname = @Hostname;";
|
||||
using var connection = _context.CreateConnection();
|
||||
connection.Open();
|
||||
var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>(equipoQuery, new { Hostname = hostname });
|
||||
|
||||
if (equipo == null)
|
||||
{
|
||||
return NotFound("Equipo no encontrado.");
|
||||
}
|
||||
|
||||
if (equipo == null) return NotFound("Equipo no encontrado.");
|
||||
using var transaction = connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
// 1. OBTENER ASOCIACIONES DE RAM ACTUALES
|
||||
var ramActualQuery = @"
|
||||
SELECT emr.Id as EquipoMemoriaRamId, emr.Slot, mr.Id, mr.part_number as PartNumber, mr.Fabricante, mr.Tamano, mr.Velocidad
|
||||
FROM dbo.equipos_memorias_ram emr
|
||||
JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id
|
||||
WHERE emr.equipo_id = @EquipoId;";
|
||||
|
||||
var ramEnDb = (await connection.QueryAsync<dynamic>(ramActualQuery, new { EquipoId = equipo.Id }, transaction)).ToList();
|
||||
|
||||
// 2. CREAR "HUELLAS DIGITALES" ÚNICAS PARA COMPARAR
|
||||
// Una huella única para cada módulo en un slot. Ej: "DIMM0_Kingston_8_3200"
|
||||
Func<dynamic, string> crearHuella = ram =>
|
||||
$"{ram.Slot}_{ram.PartNumber ?? ""}_{ram.Tamano}_{ram.Velocidad ?? 0}";
|
||||
|
||||
Func<dynamic, string> crearHuella = ram => $"{ram.Slot}_{ram.PartNumber ?? ""}_{ram.Tamano}_{ram.Velocidad ?? 0}";
|
||||
var huellasCliente = new HashSet<string>(memoriasDesdeCliente.Select(crearHuella));
|
||||
var huellasDb = new HashSet<string>(ramEnDb.Select(crearHuella));
|
||||
|
||||
// 3. CALCULAR Y EJECUTAR ELIMINACIONES
|
||||
var asociacionesAEliminar = ramEnDb
|
||||
.Where(ramDb => !huellasCliente.Contains(crearHuella(ramDb)))
|
||||
.Select(ramDb => (int)ramDb.EquipoMemoriaRamId)
|
||||
.ToList();
|
||||
var cambios = new Dictionary<string, (string anterior, string nuevo)>();
|
||||
Func<dynamic, string> formatRamDetails = ram =>
|
||||
{
|
||||
var parts = new List<string?> { ram.Fabricante, $"{ram.Tamano}GB", ram.PartNumber, ram.Velocidad?.ToString() + "MHz" };
|
||||
return string.Join(" ", parts.Where(p => !string.IsNullOrEmpty(p)));
|
||||
};
|
||||
|
||||
var modulosEliminados = ramEnDb.Where(ramDb => !huellasCliente.Contains(crearHuella(ramDb))).ToList();
|
||||
foreach (var modulo in modulosEliminados)
|
||||
{
|
||||
var campo = $"RAM Slot {modulo.Slot}";
|
||||
cambios[campo] = (formatRamDetails(modulo), "Vacio");
|
||||
}
|
||||
|
||||
var modulosInsertados = memoriasDesdeCliente.Where(ramCliente => !huellasDb.Contains(crearHuella(ramCliente))).ToList();
|
||||
foreach (var modulo in modulosInsertados)
|
||||
{
|
||||
var campo = $"RAM Slot {modulo.Slot}";
|
||||
var valorNuevo = formatRamDetails(modulo);
|
||||
if (cambios.ContainsKey(campo))
|
||||
{
|
||||
cambios[campo] = (cambios[campo].anterior, valorNuevo);
|
||||
}
|
||||
else
|
||||
{
|
||||
cambios[campo] = ("Vacio", valorNuevo);
|
||||
}
|
||||
}
|
||||
|
||||
var asociacionesAEliminar = modulosEliminados.Select(ramDb => (int)ramDb.EquipoMemoriaRamId).ToList();
|
||||
if (asociacionesAEliminar.Any())
|
||||
{
|
||||
await connection.ExecuteAsync("DELETE FROM dbo.equipos_memorias_ram WHERE Id IN @Ids;", new { Ids = asociacionesAEliminar }, transaction);
|
||||
}
|
||||
|
||||
// 4. CALCULAR Y EJECUTAR INSERCIONES
|
||||
var memoriasAInsertar = memoriasDesdeCliente.Where(ramCliente => !huellasDb.Contains(crearHuella(ramCliente))).ToList();
|
||||
|
||||
foreach (var memInfo in memoriasAInsertar)
|
||||
foreach (var memInfo in modulosInsertados)
|
||||
{
|
||||
// Buscar o crear el módulo de RAM en la tabla maestra 'memorias_ram'
|
||||
var findRamQuery = @"SELECT * FROM dbo.memorias_ram WHERE
|
||||
(part_number = @PartNumber OR (part_number IS NULL AND @PartNumber IS NULL)) AND
|
||||
tamano = @Tamano AND (velocidad = @Velocidad OR (velocidad IS NULL AND @Velocidad IS NULL));";
|
||||
|
||||
var findRamQuery = @"SELECT * FROM dbo.memorias_ram WHERE (part_number = @PartNumber OR (part_number IS NULL AND @PartNumber IS NULL)) AND tamano = @Tamano AND (velocidad = @Velocidad OR (velocidad IS NULL AND @Velocidad IS NULL));";
|
||||
var memoriaMaestra = await connection.QuerySingleOrDefaultAsync<MemoriaRam>(findRamQuery, memInfo, transaction);
|
||||
|
||||
int memoriaMaestraId;
|
||||
if (memoriaMaestra == null)
|
||||
{
|
||||
var insertRamQuery = @"INSERT INTO dbo.memorias_ram (part_number, fabricante, tamano, velocidad)
|
||||
VALUES (@PartNumber, @Fabricante, @Tamano, @Velocidad);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
var insertRamQuery = @"INSERT INTO dbo.memorias_ram (part_number, fabricante, tamano, velocidad) VALUES (@PartNumber, @Fabricante, @Tamano, @Velocidad); SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
memoriaMaestraId = await connection.ExecuteScalarAsync<int>(insertRamQuery, memInfo, transaction);
|
||||
}
|
||||
else
|
||||
{
|
||||
memoriaMaestraId = memoriaMaestra.Id;
|
||||
}
|
||||
|
||||
// Crear la asociación en la tabla intermedia
|
||||
var insertAsociacionQuery = "INSERT INTO dbo.equipos_memorias_ram (equipo_id, memoria_ram_id, slot) VALUES (@EquipoId, @MemoriaRamId, @Slot);";
|
||||
var insertAsociacionQuery = "INSERT INTO dbo.equipos_memorias_ram (equipo_id, memoria_ram_id, slot, origen) VALUES (@EquipoId, @MemoriaRamId, @Slot, 'automatica');";
|
||||
await connection.ExecuteAsync(insertAsociacionQuery, new { EquipoId = equipo.Id, MemoriaRamId = memoriaMaestraId, memInfo.Slot }, transaction);
|
||||
}
|
||||
|
||||
// (Opcional, pero recomendado) Registrar cambios en el historial.
|
||||
// La lógica exacta para el historial de RAM puede ser compleja y la omitimos por ahora para centrarnos en la sincronización.
|
||||
if (cambios.Count > 0)
|
||||
{
|
||||
await HistorialHelper.RegistrarCambios(_context, equipo.Id, cambios);
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
return Ok(new { message = "Módulos de RAM sincronizados correctamente." });
|
||||
@@ -478,32 +493,398 @@ namespace Inventario.API.Controllers
|
||||
{
|
||||
using (var ping = new Ping())
|
||||
{
|
||||
var reply = await ping.SendPingAsync(request.Ip, 2000); // Timeout de 2 segundos
|
||||
return Ok(new { isAlive = reply.Status == IPStatus.Success, latency = reply.RoundtripTime });
|
||||
var reply = await ping.SendPingAsync(request.Ip, 2000);
|
||||
bool isAlive = reply.Status == IPStatus.Success;
|
||||
if (!isAlive)
|
||||
{
|
||||
reply = await ping.SendPingAsync(request.Ip, 2000);
|
||||
isAlive = reply.Status == IPStatus.Success;
|
||||
}
|
||||
return Ok(new { isAlive, latency = isAlive ? reply.RoundtripTime : (long?)null });
|
||||
}
|
||||
}
|
||||
catch (PingException ex)
|
||||
{
|
||||
// Maneja errores comunes como "Host desconocido"
|
||||
return Ok(new { isAlive = false, error = ex.Message });
|
||||
Console.WriteLine($"Error de Ping para {request.Ip}: {ex.Message}");
|
||||
return Ok(new { isAlive = false, error = "Host no alcanzable o desconocido." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, $"Error interno al hacer ping: {ex.Message}");
|
||||
Console.WriteLine($"Error interno al hacer ping a {request.Ip}: {ex.Message}");
|
||||
return StatusCode(500, "Error interno del servidor al realizar el ping.");
|
||||
}
|
||||
}
|
||||
|
||||
// WOL (Wake-On-LAN) es más complejo porque requiere ejecutar comandos de sistema operativo.
|
||||
// Lo dejamos pendiente para no añadir complejidad de configuración de SSH por ahora.
|
||||
[HttpPost("wake-on-lan")]
|
||||
public IActionResult EnviarWol([FromBody] WolRequestDto request)
|
||||
{
|
||||
Console.WriteLine($"Recibida solicitud WOL para MAC: {request.Mac}");
|
||||
return Ok(new { message = "Solicitud WOL recibida. La ejecución del comando está pendiente de implementación." });
|
||||
var mac = request.Mac;
|
||||
var ip = request.Ip;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mac) || !Regex.IsMatch(mac, "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"))
|
||||
{
|
||||
return BadRequest("Formato de dirección MAC inválido.");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(ip) || !Regex.IsMatch(ip, @"^(\d{1,3}\.){3}\d{1,3}$"))
|
||||
{
|
||||
return BadRequest("Formato de dirección IP inválido.");
|
||||
}
|
||||
|
||||
var octetos = ip.Split('.');
|
||||
if (octetos.Length != 4)
|
||||
{
|
||||
return BadRequest("Formato de dirección IP incorrecto.");
|
||||
}
|
||||
|
||||
var vlanNumber = octetos[2];
|
||||
var interfaceName = $"vlan{vlanNumber}";
|
||||
|
||||
// 3. Leemos los valores desde la configuración en lugar de hardcodearlos
|
||||
var sshHost = _configuration.GetValue<string>("SshSettings:Host");
|
||||
var sshPort = _configuration.GetValue<int>("SshSettings:Port");
|
||||
var sshUser = _configuration.GetValue<string>("SshSettings:User");
|
||||
var sshPass = _configuration.GetValue<string>("SshSettings:Password");
|
||||
|
||||
if (string.IsNullOrEmpty(sshHost) || string.IsNullOrEmpty(sshUser) || string.IsNullOrEmpty(sshPass))
|
||||
{
|
||||
Console.WriteLine("Error: La configuración SSH no está completa en appsettings.json.");
|
||||
return StatusCode(500, "La configuración del servidor SSH está incompleta.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using (var client = new SshClient(sshHost, sshPort, sshUser, sshPass))
|
||||
{
|
||||
client.Connect();
|
||||
if (client.IsConnected)
|
||||
{
|
||||
var command = $"/usr/sbin/etherwake -b -i {interfaceName} {mac}";
|
||||
var sshCommand = client.CreateCommand(command);
|
||||
sshCommand.Execute();
|
||||
|
||||
Console.WriteLine($"Comando WOL ejecutado: {sshCommand.CommandText}");
|
||||
|
||||
client.Disconnect();
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Error: No se pudo conectar al servidor SSH.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error al ejecutar comando WOL: {ex.Message}");
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("manual")]
|
||||
public async Task<IActionResult> CrearEquipoManual([FromBody] CrearEquipoManualDto equipoDto)
|
||||
{
|
||||
var findQuery = "SELECT Id FROM dbo.equipos WHERE Hostname = @Hostname;";
|
||||
var insertQuery = @"
|
||||
INSERT INTO dbo.equipos (Hostname, Ip, Motherboard, Cpu, Os, Sector_id, Origen, Ram_installed, Architecture)
|
||||
VALUES (@Hostname, @Ip, @Motherboard, @Cpu, @Os, @Sector_id, 'manual', 0, '');
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var existente = await connection.QuerySingleOrDefaultAsync<int?>(findQuery, new { equipoDto.Hostname });
|
||||
if (existente.HasValue)
|
||||
{
|
||||
return Conflict($"El hostname '{equipoDto.Hostname}' ya existe.");
|
||||
}
|
||||
|
||||
var nuevoId = await connection.ExecuteScalarAsync<int>(insertQuery, equipoDto);
|
||||
|
||||
// Devolvemos el objeto completo para que el frontend pueda actualizar su estado
|
||||
var nuevoEquipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = nuevoId });
|
||||
|
||||
if (nuevoEquipo == null)
|
||||
{
|
||||
return StatusCode(500, "No se pudo recuperar el equipo después de crearlo.");
|
||||
}
|
||||
|
||||
return CreatedAtAction(nameof(ConsultarDetalle), new { hostname = nuevoEquipo.Hostname }, nuevoEquipo);
|
||||
}
|
||||
}
|
||||
|
||||
// --- ENDPOINTS PARA BORRADO MANUAL DE ASOCIACIONES ---
|
||||
|
||||
[HttpDelete("asociacion/disco/{equipoDiscoId}")]
|
||||
public async Task<IActionResult> BorrarAsociacionDisco(int equipoDiscoId)
|
||||
{
|
||||
var query = "DELETE FROM dbo.equipos_discos WHERE Id = @EquipoDiscoId AND Origen = 'manual';";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var filasAfectadas = await connection.ExecuteAsync(query, new { EquipoDiscoId = equipoDiscoId });
|
||||
if (filasAfectadas == 0)
|
||||
{
|
||||
return NotFound("Asociación de disco no encontrada o no se puede eliminar porque es automática.");
|
||||
}
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("asociacion/ram/{equipoMemoriaRamId}")]
|
||||
public async Task<IActionResult> BorrarAsociacionRam(int equipoMemoriaRamId)
|
||||
{
|
||||
var query = "DELETE FROM dbo.equipos_memorias_ram WHERE Id = @EquipoMemoriaRamId AND Origen = 'manual';";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var filasAfectadas = await connection.ExecuteAsync(query, new { EquipoMemoriaRamId = equipoMemoriaRamId });
|
||||
if (filasAfectadas == 0)
|
||||
{
|
||||
return NotFound("Asociación de RAM no encontrada o no se puede eliminar porque es automática.");
|
||||
}
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("asociacion/usuario/{equipoId}/{usuarioId}")]
|
||||
public async Task<IActionResult> BorrarAsociacionUsuario(int equipoId, int usuarioId)
|
||||
{
|
||||
var query = "DELETE FROM dbo.usuarios_equipos WHERE equipo_id = @EquipoId AND usuario_id = @UsuarioId AND Origen = 'manual';";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var filasAfectadas = await connection.ExecuteAsync(query, new { EquipoId = equipoId, UsuarioId = usuarioId });
|
||||
if (filasAfectadas == 0)
|
||||
{
|
||||
return NotFound("Asociación de usuario no encontrada o no se puede eliminar porque es automática.");
|
||||
}
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("manual/{id}")]
|
||||
public async Task<IActionResult> ActualizarEquipoManual(int id, [FromBody] EditarEquipoManualDto equipoDto)
|
||||
{
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
// 1. Verificar que el equipo existe y es manual
|
||||
var equipoActual = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = id });
|
||||
if (equipoActual == null)
|
||||
{
|
||||
return NotFound("El equipo no existe.");
|
||||
}
|
||||
if (equipoActual.Origen != "manual")
|
||||
{
|
||||
return Forbid("No se puede modificar un equipo generado automáticamente.");
|
||||
}
|
||||
|
||||
// 2. (Opcional pero recomendado) Verificar que el nuevo hostname no exista ya en otro equipo
|
||||
if (equipoActual.Hostname != equipoDto.Hostname)
|
||||
{
|
||||
var hostExistente = await connection.QuerySingleOrDefaultAsync<int?>("SELECT Id FROM dbo.equipos WHERE Hostname = @Hostname AND Id != @Id", new { equipoDto.Hostname, Id = id });
|
||||
if (hostExistente.HasValue)
|
||||
{
|
||||
return Conflict($"El hostname '{equipoDto.Hostname}' ya está en uso por otro equipo.");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Construir y ejecutar la consulta de actualización
|
||||
var updateQuery = @"UPDATE dbo.equipos SET
|
||||
Hostname = @Hostname,
|
||||
Ip = @Ip,
|
||||
Mac = @Mac,
|
||||
Motherboard = @Motherboard,
|
||||
Cpu = @Cpu,
|
||||
Os = @Os,
|
||||
Sector_id = @Sector_id,
|
||||
updated_at = GETDATE()
|
||||
WHERE Id = @Id AND Origen = 'manual';";
|
||||
|
||||
var filasAfectadas = await connection.ExecuteAsync(updateQuery, new
|
||||
{
|
||||
equipoDto.Hostname,
|
||||
equipoDto.Ip,
|
||||
mac = equipoDto.Mac,
|
||||
equipoDto.Motherboard,
|
||||
equipoDto.Cpu,
|
||||
equipoDto.Os,
|
||||
equipoDto.Sector_id,
|
||||
Id = id
|
||||
});
|
||||
|
||||
if (filasAfectadas == 0)
|
||||
{
|
||||
// Esto no debería pasar si las primeras verificaciones pasaron, pero es una salvaguarda
|
||||
return StatusCode(500, "No se pudo actualizar el equipo.");
|
||||
}
|
||||
|
||||
return NoContent(); // Éxito en la actualización
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("manual/{equipoId}/disco")]
|
||||
public async Task<IActionResult> AsociarDiscoManual(int equipoId, [FromBody] AsociarDiscoManualDto dto)
|
||||
{
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId });
|
||||
if (equipo == null || equipo.Origen != "manual") return Forbid("Solo se pueden añadir componentes a equipos manuales.");
|
||||
|
||||
// Buscar o crear el disco maestro
|
||||
var discoMaestro = await connection.QuerySingleOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size", dto);
|
||||
int discoId;
|
||||
if (discoMaestro == null)
|
||||
{
|
||||
discoId = await connection.ExecuteScalarAsync<int>("INSERT INTO dbo.discos (Mediatype, Size) VALUES (@Mediatype, @Size); SELECT CAST(SCOPE_IDENTITY() as int);", dto);
|
||||
}
|
||||
else
|
||||
{
|
||||
discoId = discoMaestro.Id;
|
||||
}
|
||||
|
||||
// Crear la asociación manual
|
||||
var asociacionQuery = "INSERT INTO dbo.equipos_discos (equipo_id, disco_id, origen) VALUES (@EquipoId, @DiscoId, 'manual'); SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
var nuevaAsociacionId = await connection.ExecuteScalarAsync<int>(asociacionQuery, new { EquipoId = equipoId, DiscoId = discoId });
|
||||
|
||||
return Ok(new { message = "Disco asociado manualmente.", equipoDiscoId = nuevaAsociacionId });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("manual/{equipoId}/ram")]
|
||||
public async Task<IActionResult> AsociarRamManual(int equipoId, [FromBody] AsociarRamManualDto dto)
|
||||
{
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId });
|
||||
if (equipo == null || equipo.Origen != "manual") return Forbid("Solo se pueden añadir componentes a equipos manuales.");
|
||||
|
||||
// Lógica similar a la de discos para buscar/crear el módulo maestro
|
||||
int ramId;
|
||||
var ramMaestra = await connection.QuerySingleOrDefaultAsync<MemoriaRam>("SELECT * FROM dbo.memorias_ram WHERE Tamano = @Tamano AND Fabricante = @Fabricante AND Velocidad = @Velocidad", dto);
|
||||
if (ramMaestra == null)
|
||||
{
|
||||
ramId = await connection.ExecuteScalarAsync<int>("INSERT INTO dbo.memorias_ram (Tamano, Fabricante, Velocidad) VALUES (@Tamano, @Fabricante, @Velocidad); SELECT CAST(SCOPE_IDENTITY() as int);", dto);
|
||||
}
|
||||
else
|
||||
{
|
||||
ramId = ramMaestra.Id;
|
||||
}
|
||||
|
||||
// Crear la asociación manual
|
||||
var asociacionQuery = "INSERT INTO dbo.equipos_memorias_ram (equipo_id, memoria_ram_id, slot, origen) VALUES (@EquipoId, @RamId, @Slot, 'manual'); SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
var nuevaAsociacionId = await connection.ExecuteScalarAsync<int>(asociacionQuery, new { EquipoId = equipoId, RamId = ramId, dto.Slot });
|
||||
|
||||
return Ok(new { message = "RAM asociada manualmente.", equipoMemoriaRamId = nuevaAsociacionId });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("manual/{equipoId}/usuario")]
|
||||
public async Task<IActionResult> AsociarUsuarioManual(int equipoId, [FromBody] AsociarUsuarioManualDto dto)
|
||||
{
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId });
|
||||
if (equipo == null || equipo.Origen != "manual") return Forbid("Solo se pueden añadir componentes a equipos manuales.");
|
||||
|
||||
// Buscar o crear el usuario maestro
|
||||
int usuarioId;
|
||||
var usuario = await connection.QuerySingleOrDefaultAsync<Usuario>("SELECT * FROM dbo.usuarios WHERE Username = @Username", dto);
|
||||
if (usuario == null)
|
||||
{
|
||||
usuarioId = await connection.ExecuteScalarAsync<int>("INSERT INTO dbo.usuarios (Username) VALUES (@Username); SELECT CAST(SCOPE_IDENTITY() as int);", dto);
|
||||
}
|
||||
else
|
||||
{
|
||||
usuarioId = usuario.Id;
|
||||
}
|
||||
|
||||
// Crear la asociación manual
|
||||
try
|
||||
{
|
||||
var asociacionQuery = "INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id, origen) VALUES (@EquipoId, @UsuarioId, 'manual');";
|
||||
await connection.ExecuteAsync(asociacionQuery, new { EquipoId = equipoId, UsuarioId = usuarioId });
|
||||
}
|
||||
catch (SqlException ex) when (ex.Number == 2627)
|
||||
{
|
||||
return Conflict("El usuario ya está asociado a este equipo.");
|
||||
}
|
||||
|
||||
return Ok(new { message = "Usuario asociado manualmente." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("distinct/{fieldName}")]
|
||||
public async Task<IActionResult> GetDistinctFieldValues(string fieldName)
|
||||
{
|
||||
// 1. Lista blanca de campos permitidos para evitar inyección SQL y exposición de datos.
|
||||
var allowedFields = new List<string> { "os", "cpu", "motherboard", "architecture" };
|
||||
|
||||
if (!allowedFields.Contains(fieldName.ToLower()))
|
||||
{
|
||||
return BadRequest("El campo especificado no es válido o no está permitido.");
|
||||
}
|
||||
|
||||
// 2. Construir la consulta de forma segura
|
||||
var query = $"SELECT DISTINCT {fieldName} FROM dbo.equipos WHERE {fieldName} IS NOT NULL AND {fieldName} != '' ORDER BY {fieldName};";
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var values = await connection.QueryAsync<string>(query);
|
||||
return Ok(values);
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs locales para las peticiones
|
||||
public class PingRequestDto { public string? Ip { get; set; } }
|
||||
|
||||
public class WolRequestDto
|
||||
{
|
||||
public string? Mac { get; set; }
|
||||
public string? Ip { get; set; }
|
||||
}
|
||||
|
||||
class DiscoAsociado
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Mediatype { get; set; } = "";
|
||||
public int Size { get; set; }
|
||||
public int EquipoDiscoId { get; set; }
|
||||
}
|
||||
|
||||
public class CrearEquipoManualDto
|
||||
{
|
||||
public required string Hostname { get; set; }
|
||||
public required string Ip { get; set; }
|
||||
public string? Motherboard { get; set; }
|
||||
public string? Cpu { get; set; }
|
||||
public string? Os { get; set; }
|
||||
public int? Sector_id { get; set; }
|
||||
}
|
||||
|
||||
public class EditarEquipoManualDto
|
||||
{
|
||||
public required string Hostname { get; set; }
|
||||
public required string Ip { get; set; }
|
||||
public string? Mac { get; set; }
|
||||
public string? Motherboard { get; set; }
|
||||
public string? Cpu { get; set; }
|
||||
public string? Os { get; set; }
|
||||
public int? Sector_id { get; set; }
|
||||
}
|
||||
|
||||
public class AsociarDiscoManualDto
|
||||
{
|
||||
public required string Mediatype { get; set; }
|
||||
public int Size { get; set; }
|
||||
}
|
||||
|
||||
public class AsociarRamManualDto
|
||||
{
|
||||
public required string Slot { get; set; }
|
||||
public int Tamano { get; set; }
|
||||
public string? Fabricante { get; set; }
|
||||
public int? Velocidad { get; set; }
|
||||
}
|
||||
|
||||
public class AsociarUsuarioManualDto
|
||||
{
|
||||
public required string Username { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs locales para las peticiones
|
||||
public class PingRequestDto { public string? Ip { get; set; } }
|
||||
public class WolRequestDto { public string? Mac { get; set; } }
|
||||
}
|
||||
@@ -119,5 +119,18 @@ namespace Inventario.API.Controllers
|
||||
return NoContent(); // Respuesta HTTP 204 No Content
|
||||
}
|
||||
}
|
||||
|
||||
// --- GET /api/usuarios/buscar/{termino} ---
|
||||
[HttpGet("buscar/{termino}")]
|
||||
public async Task<IActionResult> BuscarUsuarios(string termino)
|
||||
{
|
||||
// Usamos LIKE para una búsqueda flexible. El '%' son comodines.
|
||||
var query = "SELECT Username FROM dbo.usuarios WHERE Username LIKE @SearchTerm ORDER BY Username;";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var usuarios = await connection.QueryAsync<string>(query, new { SearchTerm = $"%{termino}%" });
|
||||
return Ok(usuarios);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user