feat(13-03): add link service, schemas, router, and wire subscribers into lifespan
- LinkResponse/UnknownClientResponse Pydantic schemas with from_attributes - Link service with get_links, get_device_links, get_site_links, get_unknown_clients - Unknown clients query uses DISTINCT ON for latest registration per MAC - 4 REST endpoints: tenant links, device links, site links, unknown clients - Interface and link discovery subscribers wired into FastAPI lifespan start/stop - Links router registered at /api prefix Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
182
backend/app/services/link_service.py
Normal file
182
backend/app/services/link_service.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""Link service -- query layer for wireless links and unknown clients.
|
||||
|
||||
All functions use raw SQL via the app_user engine (RLS enforced).
|
||||
Tenant isolation is handled automatically by PostgreSQL RLS policies
|
||||
once the tenant context is set by the middleware.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.schemas.link import (
|
||||
LinkListResponse,
|
||||
LinkResponse,
|
||||
UnknownClientListResponse,
|
||||
UnknownClientResponse,
|
||||
)
|
||||
|
||||
|
||||
async def get_links(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
state: Optional[str] = None,
|
||||
device_id: Optional[uuid.UUID] = None,
|
||||
) -> LinkListResponse:
|
||||
"""List wireless links for a tenant with optional state and device filters.
|
||||
|
||||
The device_id filter matches links where the device is either the AP or CPE side.
|
||||
"""
|
||||
conditions = ["wl.tenant_id = :tenant_id"]
|
||||
params: dict = {"tenant_id": str(tenant_id)}
|
||||
|
||||
if state:
|
||||
conditions.append("wl.state = :state")
|
||||
params["state"] = state
|
||||
|
||||
if device_id:
|
||||
conditions.append("(wl.ap_device_id = :device_id OR wl.cpe_device_id = :device_id)")
|
||||
params["device_id"] = str(device_id)
|
||||
|
||||
where_clause = " AND ".join(conditions)
|
||||
|
||||
result = await db.execute(
|
||||
text(f"""
|
||||
SELECT wl.id, wl.ap_device_id, wl.cpe_device_id,
|
||||
ap.hostname AS ap_hostname, cpe.hostname AS cpe_hostname,
|
||||
wl.interface, wl.client_mac, wl.signal_strength,
|
||||
wl.tx_ccq, wl.tx_rate, wl.rx_rate, wl.state,
|
||||
wl.missed_polls, wl.discovered_at, wl.last_seen
|
||||
FROM wireless_links wl
|
||||
LEFT JOIN devices ap ON ap.id = wl.ap_device_id
|
||||
LEFT JOIN devices cpe ON cpe.id = wl.cpe_device_id
|
||||
WHERE {where_clause}
|
||||
ORDER BY wl.last_seen DESC
|
||||
"""),
|
||||
params,
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
items = [
|
||||
LinkResponse(
|
||||
id=row.id,
|
||||
ap_device_id=row.ap_device_id,
|
||||
cpe_device_id=row.cpe_device_id,
|
||||
ap_hostname=row.ap_hostname,
|
||||
cpe_hostname=row.cpe_hostname,
|
||||
interface=row.interface,
|
||||
client_mac=row.client_mac,
|
||||
signal_strength=row.signal_strength,
|
||||
tx_ccq=row.tx_ccq,
|
||||
tx_rate=row.tx_rate,
|
||||
rx_rate=row.rx_rate,
|
||||
state=row.state,
|
||||
missed_polls=row.missed_polls,
|
||||
discovered_at=row.discovered_at,
|
||||
last_seen=row.last_seen,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return LinkListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
async def get_device_links(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
device_id: uuid.UUID,
|
||||
) -> LinkListResponse:
|
||||
"""List wireless links where the given device is either the AP or CPE."""
|
||||
return await get_links(db=db, tenant_id=tenant_id, device_id=device_id)
|
||||
|
||||
|
||||
async def get_site_links(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
site_id: uuid.UUID,
|
||||
) -> LinkListResponse:
|
||||
"""List wireless links where either the AP or CPE device belongs to the given site."""
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT wl.id, wl.ap_device_id, wl.cpe_device_id,
|
||||
ap.hostname AS ap_hostname, cpe.hostname AS cpe_hostname,
|
||||
wl.interface, wl.client_mac, wl.signal_strength,
|
||||
wl.tx_ccq, wl.tx_rate, wl.rx_rate, wl.state,
|
||||
wl.missed_polls, wl.discovered_at, wl.last_seen
|
||||
FROM wireless_links wl
|
||||
LEFT JOIN devices ap ON ap.id = wl.ap_device_id
|
||||
LEFT JOIN devices cpe ON cpe.id = wl.cpe_device_id
|
||||
WHERE wl.tenant_id = :tenant_id
|
||||
AND (ap.site_id = :site_id OR cpe.site_id = :site_id)
|
||||
ORDER BY wl.last_seen DESC
|
||||
"""),
|
||||
{"tenant_id": str(tenant_id), "site_id": str(site_id)},
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
items = [
|
||||
LinkResponse(
|
||||
id=row.id,
|
||||
ap_device_id=row.ap_device_id,
|
||||
cpe_device_id=row.cpe_device_id,
|
||||
ap_hostname=row.ap_hostname,
|
||||
cpe_hostname=row.cpe_hostname,
|
||||
interface=row.interface,
|
||||
client_mac=row.client_mac,
|
||||
signal_strength=row.signal_strength,
|
||||
tx_ccq=row.tx_ccq,
|
||||
tx_rate=row.tx_rate,
|
||||
rx_rate=row.rx_rate,
|
||||
state=row.state,
|
||||
missed_polls=row.missed_polls,
|
||||
discovered_at=row.discovered_at,
|
||||
last_seen=row.last_seen,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return LinkListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
async def get_unknown_clients(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
device_id: uuid.UUID,
|
||||
) -> UnknownClientListResponse:
|
||||
"""List wireless clients connected to a device whose MAC does not match any known device interface.
|
||||
|
||||
Uses DISTINCT ON to return the most recent registration per unique MAC address.
|
||||
"""
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT DISTINCT ON (wr.mac_address)
|
||||
wr.mac_address, wr.interface, wr.signal_strength,
|
||||
wr.tx_rate, wr.rx_rate, wr.time AS last_seen
|
||||
FROM wireless_registrations wr
|
||||
WHERE wr.device_id = :device_id
|
||||
AND wr.tenant_id = :tenant_id
|
||||
AND wr.mac_address NOT IN (
|
||||
SELECT di.mac_address FROM device_interfaces di
|
||||
WHERE di.tenant_id = :tenant_id
|
||||
)
|
||||
ORDER BY wr.mac_address, wr.time DESC
|
||||
"""),
|
||||
{"device_id": str(device_id), "tenant_id": str(tenant_id)},
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
items = [
|
||||
UnknownClientResponse(
|
||||
mac_address=row.mac_address,
|
||||
interface=row.interface,
|
||||
signal_strength=row.signal_strength,
|
||||
tx_rate=row.tx_rate,
|
||||
rx_rate=row.rx_rate,
|
||||
last_seen=row.last_seen,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return UnknownClientListResponse(items=items, total=len(items))
|
||||
Reference in New Issue
Block a user