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,
|
||||
)
|
||||
|
||||
# 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
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="devices") # type: ignore[name-defined]
|
||||
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 (
|
||||
BulkAddRequest,
|
||||
BulkAddResult,
|
||||
BulkAddWithProfileRequest,
|
||||
BulkAddWithProfileResult,
|
||||
DeviceCreate,
|
||||
DeviceListResponse,
|
||||
DeviceResponse,
|
||||
@@ -394,6 +396,54 @@ async def bulk_add_devices(
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -29,6 +29,10 @@ from app.models.device import (
|
||||
DeviceTagAssignment,
|
||||
)
|
||||
from app.schemas.device import (
|
||||
BulkAddDefaults,
|
||||
BulkAddDeviceResult,
|
||||
BulkAddWithProfileRequest,
|
||||
BulkAddWithProfileResult,
|
||||
DeviceCreate,
|
||||
DeviceGroupCreate,
|
||||
DeviceGroupResponse,
|
||||
@@ -425,6 +429,158 @@ async def delete_device(
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user