feat(17-03): bulk add endpoint and service with credential profile support
- POST /tenants/{tenant_id}/devices/bulk endpoint with rate limiting
- bulk_add_with_profile service validates profile ownership and type compatibility
- Duplicate IP check prevents adding same IP twice in one tenant
- TCP reachability check for RouterOS devices, skipped for SNMP (UDP)
- Per-device result reporting with partial success support
- Device model updated with device_type, snmp_port, snmp_version, snmp_profile_id columns
- Audit logging for bulk add operations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -93,6 +93,19 @@ class Device(Base):
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# SNMP / device-type columns (migration 039)
|
||||||
|
device_type: Mapped[str] = mapped_column(
|
||||||
|
Text, default="routeros", server_default="routeros", nullable=False
|
||||||
|
)
|
||||||
|
snmp_port: Mapped[int | None] = mapped_column(Integer, default=161, nullable=True)
|
||||||
|
snmp_version: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
snmp_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("snmp_profiles.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="devices") # type: ignore[name-defined]
|
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="devices") # type: ignore[name-defined]
|
||||||
group_memberships: Mapped[list["DeviceGroupMembership"]] = relationship(
|
group_memberships: Mapped[list["DeviceGroupMembership"]] = relationship(
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ from app.middleware.tenant_context import CurrentUser, get_current_user
|
|||||||
from app.schemas.device import (
|
from app.schemas.device import (
|
||||||
BulkAddRequest,
|
BulkAddRequest,
|
||||||
BulkAddResult,
|
BulkAddResult,
|
||||||
|
BulkAddWithProfileRequest,
|
||||||
|
BulkAddWithProfileResult,
|
||||||
DeviceCreate,
|
DeviceCreate,
|
||||||
DeviceListResponse,
|
DeviceListResponse,
|
||||||
DeviceResponse,
|
DeviceResponse,
|
||||||
@@ -394,6 +396,54 @@ async def bulk_add_devices(
|
|||||||
return BulkAddResult(added=added, failed=failed)
|
return BulkAddResult(added=added, failed=failed)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/tenants/{tenant_id}/devices/bulk",
|
||||||
|
response_model=BulkAddWithProfileResult,
|
||||||
|
summary="Bulk add devices using a credential profile",
|
||||||
|
dependencies=[Depends(require_operator_or_above), require_scope("devices:write")],
|
||||||
|
)
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
async def bulk_add_with_profile(
|
||||||
|
request: Request,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
data: BulkAddWithProfileRequest,
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> BulkAddWithProfileResult:
|
||||||
|
"""Add multiple devices using a credential profile.
|
||||||
|
|
||||||
|
Supports partial success -- individual devices can fail without blocking others.
|
||||||
|
Returns per-device results with success/failure reasons.
|
||||||
|
"""
|
||||||
|
await _check_tenant_access(current_user, tenant_id, db)
|
||||||
|
result = await device_service.bulk_add_with_profile(
|
||||||
|
db=db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
data=data,
|
||||||
|
user_id=current_user.user_id,
|
||||||
|
)
|
||||||
|
# Audit log the bulk add (fire-and-forget)
|
||||||
|
try:
|
||||||
|
await log_action(
|
||||||
|
db,
|
||||||
|
tenant_id,
|
||||||
|
current_user.user_id,
|
||||||
|
"device_bulk_add",
|
||||||
|
resource_type="device",
|
||||||
|
details={
|
||||||
|
"total": result.total,
|
||||||
|
"succeeded": result.succeeded,
|
||||||
|
"failed": result.failed,
|
||||||
|
"device_type": data.device_type,
|
||||||
|
"credential_profile_id": str(data.credential_profile_id),
|
||||||
|
},
|
||||||
|
ip_address=request.client.host if request.client else None,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Group assignment
|
# Group assignment
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ from app.models.device import (
|
|||||||
DeviceTagAssignment,
|
DeviceTagAssignment,
|
||||||
)
|
)
|
||||||
from app.schemas.device import (
|
from app.schemas.device import (
|
||||||
|
BulkAddDefaults,
|
||||||
|
BulkAddDeviceResult,
|
||||||
|
BulkAddWithProfileRequest,
|
||||||
|
BulkAddWithProfileResult,
|
||||||
DeviceCreate,
|
DeviceCreate,
|
||||||
DeviceGroupCreate,
|
DeviceGroupCreate,
|
||||||
DeviceGroupResponse,
|
DeviceGroupResponse,
|
||||||
@@ -425,6 +429,158 @@ async def delete_device(
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bulk add with credential profile
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def bulk_add_with_profile(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
data: BulkAddWithProfileRequest,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> BulkAddWithProfileResult:
|
||||||
|
"""Add multiple devices using a credential profile. Partial success allowed."""
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.models.credential_profile import CredentialProfile
|
||||||
|
|
||||||
|
# 1. Validate credential profile exists and belongs to tenant
|
||||||
|
profile_row = await db.execute(
|
||||||
|
select(CredentialProfile).where(
|
||||||
|
CredentialProfile.id == data.credential_profile_id,
|
||||||
|
CredentialProfile.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
profile = profile_row.scalar_one_or_none()
|
||||||
|
if not profile:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Credential profile not found or does not belong to this tenant",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Validate credential_type matches device_type
|
||||||
|
valid_types_for_device = {
|
||||||
|
"routeros": ["routeros"],
|
||||||
|
"snmp": ["snmp_v1", "snmp_v2c", "snmp_v3"],
|
||||||
|
}
|
||||||
|
if profile.credential_type not in valid_types_for_device.get(data.device_type, []):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
f"Credential profile type '{profile.credential_type}' "
|
||||||
|
f"is not compatible with device_type '{data.device_type}'"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. If SNMP and snmp_profile_id provided in defaults, validate it exists
|
||||||
|
snmp_profile_id = None
|
||||||
|
if data.device_type == "snmp" and data.defaults and data.defaults.snmp_profile_id:
|
||||||
|
snmp_check = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT id FROM snmp_profiles"
|
||||||
|
" WHERE id = :id AND (tenant_id = :tenant_id OR tenant_id IS NULL)"
|
||||||
|
),
|
||||||
|
{"id": str(data.defaults.snmp_profile_id), "tenant_id": str(tenant_id)},
|
||||||
|
)
|
||||||
|
if not snmp_check.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=404, detail="SNMP profile not found")
|
||||||
|
snmp_profile_id = data.defaults.snmp_profile_id
|
||||||
|
|
||||||
|
# 4. Process each device
|
||||||
|
results: list[BulkAddDeviceResult] = []
|
||||||
|
defaults = data.defaults or BulkAddDefaults()
|
||||||
|
|
||||||
|
for entry in data.devices:
|
||||||
|
try:
|
||||||
|
hostname = entry.hostname or entry.ip_address
|
||||||
|
|
||||||
|
# Check for duplicate IP in tenant
|
||||||
|
dup_check = await db.execute(
|
||||||
|
text("SELECT id FROM devices WHERE ip_address = :ip AND tenant_id = :tid"),
|
||||||
|
{"ip": entry.ip_address, "tid": str(tenant_id)},
|
||||||
|
)
|
||||||
|
if dup_check.scalar_one_or_none():
|
||||||
|
results.append(
|
||||||
|
BulkAddDeviceResult(
|
||||||
|
ip_address=entry.ip_address,
|
||||||
|
hostname=hostname,
|
||||||
|
success=False,
|
||||||
|
error="Device with this IP already exists",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# TCP reachability check for RouterOS devices
|
||||||
|
if data.device_type == "routeros":
|
||||||
|
reachable = await _tcp_reachable(entry.ip_address, defaults.api_ssl_port)
|
||||||
|
if not reachable:
|
||||||
|
reachable = await _tcp_reachable(entry.ip_address, defaults.api_port)
|
||||||
|
if not reachable:
|
||||||
|
results.append(
|
||||||
|
BulkAddDeviceResult(
|
||||||
|
ip_address=entry.ip_address,
|
||||||
|
hostname=hostname,
|
||||||
|
success=False,
|
||||||
|
error=(
|
||||||
|
f"Device unreachable on ports "
|
||||||
|
f"{defaults.api_port}/{defaults.api_ssl_port}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create device with credential profile reference
|
||||||
|
device = Device(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
hostname=hostname,
|
||||||
|
ip_address=entry.ip_address,
|
||||||
|
device_type=data.device_type,
|
||||||
|
credential_profile_id=data.credential_profile_id,
|
||||||
|
# RouterOS fields
|
||||||
|
api_port=defaults.api_port if data.device_type == "routeros" else 8728,
|
||||||
|
api_ssl_port=defaults.api_ssl_port if data.device_type == "routeros" else 8729,
|
||||||
|
tls_mode=defaults.tls_mode if data.device_type == "routeros" else "auto",
|
||||||
|
# SNMP fields
|
||||||
|
snmp_port=defaults.snmp_port if data.device_type == "snmp" else 161,
|
||||||
|
snmp_version=defaults.snmp_version if data.device_type == "snmp" else None,
|
||||||
|
snmp_profile_id=snmp_profile_id,
|
||||||
|
status="unknown",
|
||||||
|
)
|
||||||
|
db.add(device)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
BulkAddDeviceResult(
|
||||||
|
ip_address=entry.ip_address,
|
||||||
|
hostname=hostname,
|
||||||
|
success=True,
|
||||||
|
device_id=device.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
results.append(
|
||||||
|
BulkAddDeviceResult(
|
||||||
|
ip_address=entry.ip_address,
|
||||||
|
hostname=entry.hostname or entry.ip_address,
|
||||||
|
success=False,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
succeeded = sum(1 for r in results if r.success)
|
||||||
|
return BulkAddWithProfileResult(
|
||||||
|
total=len(results),
|
||||||
|
succeeded=succeeded,
|
||||||
|
failed=len(results) - succeeded,
|
||||||
|
results=results,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Group / Tag assignment
|
# Group / Tag assignment
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user