This commit is contained in:
QuangMinh_123
2026-05-27 13:50:27 +07:00
parent 7aebcf9567
commit 2683cdb882
30 changed files with 2091 additions and 17 deletions

View File

@@ -0,0 +1,109 @@
# pyrefly: ignore [missing-import]
from flask import request
from common.response.api_response import success_response
from common.constants.status_code import HTTP_CREATED
from modules.device.service import (
get_devices_service,
get_device_by_id_service,
create_device_service,
update_device_service,
delete_device_service
)
from modules.device.schemas import CreateDeviceSchema, UpdateDeviceSchema
def get_devices():
"""GET /api/devices — Lấy danh sách tất cả các thiết bị mạng"""
devices = get_devices_service()
return success_response(
data=devices,
message="Devices retrieved successfully"
)
def get_device_by_id(device_id):
"""GET /api/devices/<device_id> — Lấy chi tiết thiết bị theo ID"""
device = get_device_by_id_service(device_id)
return success_response(
data=device,
message="Device retrieved successfully"
)
def create_device():
"""
POST /api/devices — Thêm mới một thiết bị mạng
"""
body = request.get_json()
# Validate bằng schema
schema = CreateDeviceSchema()
data = schema.load(body)
# Gọi service xử lý logic nghiệp vụ
new_device = create_device_service(data)
return success_response(
data=new_device,
message="Device created successfully",
status_code=HTTP_CREATED
)
def update_device(device_id):
"""
PUT /api/devices/<device_id> — Cập nhật thông tin thiết bị mạng
"""
body = request.get_json()
# Validate bằng schema
schema = UpdateDeviceSchema()
data = schema.load(body)
# Gọi service để kiểm tra và cập nhật DB
updated_device = update_device_service(device_id, data)
return success_response(
data=updated_device,
message="Device updated successfully"
)
def delete_device(device_id):
"""
DELETE /api/devices/<device_id> — Xóa thiết bị mạng khỏi hệ thống
"""
delete_device_service(device_id)
return success_response(
message="Device deleted successfully"
)
# def upload_device_avatar_controller(device_id):
# """
# POST /api/devices/<device_id>/avatar — Upload ảnh đại diện thiết bị lên S3/MinIO
# và cập nhật URL vào database.
# """
# # 1. Kiểm tra tồn tại của thiết bị
# device = get_device_by_id_service(device_id)
# # 2. Lấy file ảnh từ request.files
# file = request.files.get("file")
# if not file:
# raise BadRequestException("Missing image file in request")
# try:
# # 3. Thực hiện upload lên S3/MinIO
# avatar_url = upload_device_avatar(file, device["name"])
# # 4. Cập nhật trường avatar_url của thiết bị trong DB
# updated_device = update_device_service(device_id, {"avatar_url": avatar_url})
# return success_response(
# data=updated_device,
# message="Device avatar uploaded successfully",
# status_code=HTTP_CREATED
# )
# except ValueError as e:
# # ValidationError trong quá trình validate loại file
# raise BadRequestException(str(e))

View File

@@ -0,0 +1,25 @@
from common.exceptions.app_exception import (
NotFoundException,
ConflictException
)
class DeviceNotFoundException(NotFoundException):
def __init__(self, device_id):
super().__init__(
message=f"Device not found with id={device_id}",
payload={"device_id": device_id}
)
class DeviceAlreadyExistsException(ConflictException):
def __init__(self, name):
super().__init__(
message=f"Device already exists with name={name}",
payload={"name": name}
)
class DeviceIPAlreadyExistsException(ConflictException):
def __init__(self, ip_address):
super().__init__(
message=f"Device already exists with IP address={ip_address}",
payload={"ip_address": ip_address}
)

View File

@@ -0,0 +1,293 @@
from config.database import get_connection, release_connection
def _row_to_dict(row):
"""
Chuyển đổi một dòng kết quả từ tuple (từ câu lệnh JOIN) thành dictionary.
"""
if not row:
return None
device_dict = {
"id": str(row[0]),
"device_type_id": str(row[1]),
"name": row[2],
"description": row[3],
"ip_address": row[4],
"port": row[5],
"latitude": row[6],
"longitude": row[7],
"color": row[8],
"avatar_url": row[9],
"is_active": row[10],
"created": row[11].isoformat() if row[11] else None,
"modified": row[12].isoformat() if row[12] else None
}
# Nếu câu truy vấn có JOIN với device_type
if len(row) > 13:
device_dict["device_type_name"] = row[13]
device_dict["device_type_icon"] = row[14]
return device_dict
def find_all_devices():
"""
Lấy danh sách tất cả các thiết bị trong database, JOIN với bảng device_type
để hiển thị tên loại thiết bị và icon đại diện.
"""
conn = get_connection()
cur = None
try:
cur = conn.cursor()
cur.execute("""
SELECT
d.id, d.device_type_id, d.name, d.description, d.ip_address, d.port,
d.latitude, d.longitude, d.color, d.avatar_url, d.is_active, d.created, d.modified,
dt.name as device_type_name, dt.icon_url as device_type_icon
FROM device d
JOIN device_type dt ON d.device_type_id = dt.id
ORDER BY d.created DESC
""")
rows = cur.fetchall()
devices = []
for row in rows:
devices.append(_row_to_dict(row))
return devices
finally:
if cur:
cur.close()
release_connection(conn)
def find_device_by_id(device_id):
"""
Tìm thiết bị theo ID, JOIN với device_type để lấy thông tin chi tiết.
"""
conn = get_connection()
cur = None
try:
cur = conn.cursor()
cur.execute("""
SELECT
d.id, d.device_type_id, d.name, d.description, d.ip_address, d.port,
d.latitude, d.longitude, d.color, d.avatar_url, d.is_active, d.created, d.modified,
dt.name as device_type_name, dt.icon_url as device_type_icon
FROM device d
JOIN device_type dt ON d.device_type_id = dt.id
WHERE d.id = %s
""", (device_id,))
row = cur.fetchone()
return _row_to_dict(row) if row else None
finally:
if cur:
cur.close()
release_connection(conn)
def find_device_by_name(name):
"""
Tìm thiết bị theo tên (không phân biệt hoa thường) để phục vụ validate trùng tên.
"""
conn = get_connection()
cur = None
try:
cur = conn.cursor()
cur.execute("""
SELECT id, device_type_id, name, description, ip_address, port, latitude, longitude, color, avatar_url, is_active, created, modified
FROM device
WHERE LOWER(name) = LOWER(%s)
""", (name,))
row = cur.fetchone()
return _row_to_dict(row) if row else None
finally:
if cur:
cur.close()
release_connection(conn)
def find_device_by_ip(ip_address):
"""
Tìm thiết bị theo địa chỉ IP để phục vụ validate tránh trùng lặp IP thiết bị.
"""
conn = get_connection()
cur = None
try:
cur = conn.cursor()
cur.execute("""
SELECT id, device_type_id, name, description, ip_address, port, latitude, longitude, color, avatar_url, is_active, created, modified
FROM device
WHERE ip_address = %s
""", (ip_address,))
row = cur.fetchone()
return _row_to_dict(row) if row else None
finally:
if cur:
cur.close()
release_connection(conn)
def insert_device(data):
"""
Tạo mới một thiết bị và cấu hình mặc định (MonitorConfig & AlertConfig)
trong cùng một database transaction.
"""
conn = get_connection()
cur = None
try:
cur = conn.cursor()
# 1. Thêm thiết bị vào bảng device
cur.execute("""
INSERT INTO device (device_type_id, name, description, ip_address, port, latitude, longitude, color, avatar_url, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, device_type_id, name, description, ip_address, port, latitude, longitude, color, avatar_url, is_active, created, modified
""", (
data["device_type_id"],
data["name"],
data.get("description"),
data["ip_address"],
data.get("port"),
data["latitude"],
data["longitude"],
data["color"],
data.get("avatar_url"),
data.get("is_active", True)
))
device_row = cur.fetchone()
device_id = device_row[0]
# 2. Thêm cấu hình giám sát mặc định (MonitorConfig) cho thiết bị vừa tạo
# enable_ping mặc định bật True để thực hiện Ping giám sát
cur.execute("""
INSERT INTO monitor_config (device_id, enable_ping, ping_count, ping_timeout, ping_interval, enable_snmp)
VALUES (%s, %s, %s, %s, %s, %s)
""", (
device_id,
True, # enable_ping
3, # ping_count
5, # ping_timeout (giây)
60, # ping_interval (giây)
False # enable_snmp
))
# 3. Thêm cấu hình cảnh báo mặc định (AlertConfig) cho thiết bị vừa tạo
cur.execute("""
INSERT INTO alert_config (device_id, is_enabled, fail_threshold, cooldown_minutes, notify_web, notify_email)
VALUES (%s, %s, %s, %s, %s, %s)
""", (
device_id,
True, # is_enabled
3, # fail_threshold (số lần fail liên tiếp trước khi alert)
30, # cooldown_minutes (thời gian tránh spam alert)
True, # notify_web (hiển thị thông báo trên web)
False # notify_email (mặc định tắt gửi email)
))
# 4. Lấy lại thiết bị kèm thông tin DeviceType đã JOIN để trả về đầy đủ dữ liệu
cur.execute("""
SELECT
d.id, d.device_type_id, d.name, d.description, d.ip_address, d.port,
d.latitude, d.longitude, d.color, d.avatar_url, d.is_active, d.created, d.modified,
dt.name as device_type_name, dt.icon_url as device_type_icon
FROM device d
JOIN device_type dt ON d.device_type_id = dt.id
WHERE d.id = %s
""", (device_id,))
full_row = cur.fetchone()
# Commit toàn bộ transaction
conn.commit()
return _row_to_dict(full_row)
except Exception:
conn.rollback()
raise
finally:
if cur:
cur.close()
release_connection(conn)
def update_device_db(device_id, data):
"""
Cập nhật thông tin chi tiết của một thiết bị.
"""
conn = get_connection()
cur = None
try:
cur = conn.cursor()
update_fields = []
params = []
fields_list = [
"device_type_id", "name", "description",
"ip_address", "port", "latitude",
"longitude", "color", "avatar_url", "is_active"
]
for key in fields_list:
if key in data:
update_fields.append(f"{key} = %s")
params.append(data[key])
if not update_fields:
return find_device_by_id(device_id)
update_fields.append("modified = CURRENT_TIMESTAMP")
sql = f"""
UPDATE device
SET {', '.join(update_fields)}
WHERE id = %s
RETURNING id, device_type_id, name, description, ip_address, port, latitude, longitude, color, avatar_url, is_active, created, modified
"""
params.append(device_id)
cur.execute(sql, tuple(params))
row = cur.fetchone()
conn.commit()
if row:
# Lấy chi tiết có kèm JOIN device_type để trả về đầy đủ
return find_device_by_id(device_id)
return None
except Exception:
conn.rollback()
raise
finally:
if cur:
cur.close()
release_connection(conn)
def delete_device_db(device_id):
"""
Xóa thiết bị khỏi database. Do có khóa ngoại với Cascade Delete,
tất cả các dòng liên quan trong monitor_config, alert_config, device_status, alert_log sẽ tự động bị xóa.
"""
conn = get_connection()
cur = None
try:
cur = conn.cursor()
cur.execute("""
DELETE FROM device
WHERE id = %s
""", (device_id,))
conn.commit()
except Exception:
conn.rollback()
raise
finally:
if cur:
cur.close()
release_connection(conn)

View File

@@ -0,0 +1,20 @@
# pyrefly: ignore [missing-import]
from flask import Blueprint
from modules.device.controller import (
get_devices,
get_device_by_id,
create_device,
update_device,
delete_device
)
# Khởi tạo Blueprint cho Module Device
device_bp = Blueprint("device", __name__)
# Đăng ký các endpoints với controller tương ứng
device_bp.route("", methods=["GET"])(get_devices)
device_bp.route("/<device_id>", methods=["GET"])(get_device_by_id)
device_bp.route("", methods=["POST"])(create_device)
device_bp.route("/<device_id>", methods=["PUT"])(update_device)
device_bp.route("/<device_id>", methods=["DELETE"])(delete_device)

View File

@@ -0,0 +1,120 @@
import ipaddress
# pyrefly: ignore [missing-import]
from marshmallow import Schema, fields, validate, ValidationError
HEX_COLOR_REGEX = r"^#[0-9A-Fa-f]{6}$"
def validate_ip_address(val):
try:
ipaddress.ip_address(val)
except ValueError:
raise ValidationError("Invalid IP address format. Must be a valid IPv4 or IPv6 address.")
class CreateDeviceSchema(Schema):
device_type_id = fields.UUID(
required=True,
error_messages={"required": "Device type ID is required."}
)
name = fields.String(
required=True,
validate=validate.Length(min=1, max=200),
error_messages={"required": "Device name is required."}
)
description = fields.String(
required=False,
allow_none=True
)
ip_address = fields.String(
required=True,
validate=validate_ip_address,
error_messages={"required": "IP address is required."}
)
port = fields.Integer(
required=False,
allow_none=True,
validate=validate.Range(min=1, max=65535)
)
latitude = fields.Float(
required=True,
validate=validate.Range(min=-90.0, max=90.0),
error_messages={"required": "Latitude is required."}
)
longitude = fields.Float(
required=True,
validate=validate.Range(min=-180.0, max=180.0),
error_messages={"required": "Longitude is required."}
)
color = fields.String(
required=True,
validate=validate.Regexp(HEX_COLOR_REGEX),
error_messages={"required": "Color code (HEX) is required."}
)
avatar_url = fields.String(
required=False,
allow_none=True,
validate=validate.Length(max=512)
)
is_active = fields.Boolean(
required=False,
load_default=True
)
class UpdateDeviceSchema(Schema):
device_type_id = fields.UUID(
required=False
)
name = fields.String(
required=False,
validate=validate.Length(min=1, max=200)
)
description = fields.String(
required=False,
allow_none=True
)
ip_address = fields.String(
required=False,
validate=validate_ip_address
)
port = fields.Integer(
required=False,
allow_none=True,
validate=validate.Range(min=1, max=65535)
)
latitude = fields.Float(
required=False,
validate=validate.Range(min=-90.0, max=90.0)
)
longitude = fields.Float(
required=False,
validate=validate.Range(min=-180.0, max=180.0)
)
color = fields.String(
required=False,
validate=validate.Regexp(HEX_COLOR_REGEX)
)
avatar_url = fields.String(
required=False,
allow_none=True,
validate=validate.Length(max=512)
)
is_active = fields.Boolean(
required=False
)

View File

@@ -0,0 +1,138 @@
from modules.device.repository import (
find_all_devices,
find_device_by_id,
find_device_by_name,
find_device_by_ip,
insert_device,
update_device_db,
delete_device_db
)
from modules.device.exceptions import (
DeviceNotFoundException,
DeviceAlreadyExistsException,
DeviceIPAlreadyExistsException
)
from modules.device_type.repository import find_device_type_by_id
from modules.device_type.exceptions import DeviceTypeNotFoundException
from scheduler.scheduler import (
add_device_monitoring_job,
remove_device_monitoring_job,
reschedule_device_monitoring_job
)
def get_devices_service():
"""Lấy danh sách tất cả thiết bị"""
return find_all_devices()
def get_device_by_id_service(device_id):
"""
Lấy thông tin chi tiết một thiết bị theo ID.
Ném lỗi DeviceNotFoundException nếu không tồn tại.
"""
device = find_device_by_id(device_id)
if not device:
raise DeviceNotFoundException(device_id)
return device
def create_device_service(data):
"""
Tạo mới một thiết bị mạng.
Các bước xử lý:
1. Kiểm tra loại thiết bị (device_type_id) có tồn tại trong hệ thống hay không.
2. Kiểm tra trùng lặp tên thiết bị (case-insensitive).
3. Kiểm tra trùng lặp địa chỉ IP.
4. Thêm thiết bị và cấu hình mặc định (monitor & alert) vào database.
5. Đăng ký công việc giám sát vào Scheduler nền.
"""
# 1. Kiểm tra DeviceType
device_type = find_device_type_by_id(data["device_type_id"])
if not device_type:
raise DeviceTypeNotFoundException(data["device_type_id"])
# 2. Kiểm tra trùng tên
existing_name = find_device_by_name(data["name"])
if existing_name:
raise DeviceAlreadyExistsException(data["name"])
# 3. Kiểm tra trùng IP
existing_ip = find_device_by_ip(data["ip_address"])
if existing_ip:
raise DeviceIPAlreadyExistsException(data["ip_address"])
# 4. Insert DB
new_device = insert_device(data)
# 5. Kích hoạt Job giám sát trên Background Scheduler
# Truyền kèm thông tin cấu hình mặc định (enable_ping=True, v.v...)
add_device_monitoring_job(new_device["id"], None)
return new_device
def update_device_service(device_id, data):
"""
Cập nhật thông tin thiết bị mạng.
Các bước xử lý:
1. Kiểm tra sự tồn tại của thiết bị.
2. Nếu cập nhật device_type_id -> kiểm tra xem có tồn tại không.
3. Nếu cập nhật name -> kiểm tra xem có bị trùng với thiết bị khác không.
4. Nếu cập nhật ip_address -> kiểm tra xem có bị trùng với thiết bị khác không.
5. Cập nhật dữ liệu vào DB.
6. Cập nhật lại cấu hình giám sát trong Scheduler.
"""
# 1. Kiểm tra thiết bị có tồn tại hay không
existing = find_device_by_id(device_id)
if not existing:
raise DeviceNotFoundException(device_id)
# 2. Kiểm tra loại thiết bị nếu có truyền vào
new_device_type_id = data.get("device_type_id")
if new_device_type_id and new_device_type_id != existing["device_type_id"]:
device_type = find_device_type_by_id(new_device_type_id)
if not device_type:
raise DeviceTypeNotFoundException(new_device_type_id)
# 3. Kiểm tra trùng tên khi tên bị thay đổi
new_name = data.get("name")
if new_name and new_name.lower() != existing["name"].lower():
conflict_name = find_device_by_name(new_name)
if conflict_name and conflict_name["id"] != device_id:
raise DeviceAlreadyExistsException(new_name)
# 4. Kiểm tra trùng IP khi IP bị thay đổi
new_ip = data.get("ip_address")
if new_ip and new_ip != existing["ip_address"]:
conflict_ip = find_device_by_ip(new_ip)
if conflict_ip and conflict_ip["id"] != device_id:
raise DeviceIPAlreadyExistsException(new_ip)
# 5. Cập nhật DB
updated_device = update_device_db(device_id, data)
# 6. Cập nhật lại thông tin giám sát trong Scheduler
reschedule_device_monitoring_job(device_id, None)
return updated_device
def delete_device_service(device_id):
"""
Xóa thiết bị mạng.
Các bước xử lý:
1. Kiểm tra sự tồn tại của thiết bị.
2. Thực hiện xóa khỏi DB (tự động xóa cấu hình và lịch sử liên quan).
3. Hủy bỏ job giám sát khỏi Scheduler.
"""
# 1. Kiểm tra tồn tại
existing = find_device_by_id(device_id)
if not existing:
raise DeviceNotFoundException(device_id)
# 2. Xóa khỏi DB
delete_device_db(device_id)
# 3. Hủy công việc giám sát trong Scheduler
remove_device_monitoring_job(device_id)