Skip to content

Commit f4fac72

Browse files
CopilotJackass4life
andcommitted
feat: add tenant group filter to map UI
Add a "Tenant Group" filter dropdown to the sidebar filter section. Locations are resolved to their tenant group via the tenant's tenant_group field in the Nautobot API. Changes: - app.py: add _build_tenant_group_map() helper and tenant_group field - templates/index.html: add filter-tenant-group select element - static/js/map.js: populate, apply, and clear tenant group filter - demo/mock_nautobot.py: add TENANT_GROUPS seed data and endpoint - tests: add unit and integration tests for tenant_group Co-authored-by: Jackass4life <94110786+Jackass4life@users.noreply.github.com> Agent-Logs-Url: https://github.com/Jackass4life/Nautobot-maps/sessions/8a5b4fe1-ccd3-406f-be2a-4124fd563b8e
1 parent c17476f commit f4fac72

6 files changed

Lines changed: 108 additions & 5 deletions

File tree

app.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,37 @@ def _build_device_type_maps() -> tuple:
122122
return {}, {}
123123

124124

125+
def _build_tenant_group_map() -> dict:
126+
"""Return ``{tenant_id: tenant_group_name}``.
127+
128+
Fetches all tenants and resolves each tenant's ``tenant_group`` field so
129+
that locations can expose the tenant group without extra per-location API
130+
calls. A fallback name-map for tenant groups is built from the
131+
``tenancy/tenant-groups/`` endpoint for Nautobot builds where the nested
132+
object is brief (id + url only).
133+
"""
134+
try:
135+
tg_name_map = _build_id_name_map("tenancy/tenant-groups/")
136+
tenants = fetch_all_pages("tenancy/tenants/")
137+
result: dict = {}
138+
for tenant in tenants:
139+
tid = tenant.get("id")
140+
if not tid:
141+
continue
142+
tg_obj = tenant.get("tenant_group") or {}
143+
tg_id = tg_obj.get("id", "") if isinstance(tg_obj, dict) else ""
144+
tg_name = (
145+
_nested_str(tg_obj, "name", "display")
146+
or tg_name_map.get(tg_id, "")
147+
)
148+
if tg_name:
149+
result[tid] = tg_name
150+
return result
151+
except Exception as exc:
152+
logger.debug("Could not build tenant group map: %s", exc)
153+
return {}
154+
155+
125156
def _cache_get(key: str):
126157
entry = _cache.get(key)
127158
if entry and time.time() - entry["ts"] < CACHE_TTL:
@@ -187,6 +218,7 @@ def get_locations() -> list:
187218
tenant_map = _build_id_name_map("tenancy/tenants/")
188219
status_map = _build_id_name_map("extras/statuses/")
189220
lt_map = _build_id_name_map("dcim/location-types/")
221+
tenant_group_map = _build_tenant_group_map()
190222

191223
# Build a location id → name map from the raw data for parent resolution.
192224
# Parents are locations themselves, and their nested objects may also be
@@ -246,6 +278,8 @@ def get_locations() -> list:
246278
or loc_name_map.get(parent_id, "")
247279
)
248280

281+
tenant_group_name = tenant_group_map.get(tenant_id, "")
282+
249283
locations.append(
250284
{
251285
"id": loc.get("id", ""),
@@ -260,6 +294,7 @@ def get_locations() -> list:
260294
"physical_address": loc.get("physical_address", ""),
261295
"tenant": tenant_name,
262296
"tenant_id": tenant_id,
297+
"tenant_group": tenant_group_name,
263298
"asn": loc.get("asn"),
264299
"time_zone": loc.get("time_zone", ""),
265300
"url": loc.get("url", ""),

demo/mock_nautobot.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,16 @@
2121
# Seed data
2222
# ---------------------------------------------------------------------------
2323

24+
TENANT_GROUPS = {
25+
"tg-corp": {"id": "tg-corp", "name": "Corporate", "slug": "corporate"},
26+
"tg-infra": {"id": "tg-infra", "name": "Infrastructure", "slug": "infrastructure"},
27+
}
28+
2429
TENANTS = {
25-
"ten-acme": {"id": "ten-acme", "name": "Acme Corp", "slug": "acme-corp"},
26-
"ten-nordnet": {"id": "ten-nordnet", "name": "Nordic Net", "slug": "nordic-net"},
27-
"ten-euroix": {"id": "ten-euroix", "name": "EuroIX", "slug": "euroix"},
28-
"ten-dcgmbh": {"id": "ten-dcgmbh", "name": "DataCenter GmbH", "slug": "datacenter-gmbh"},
30+
"ten-acme": {"id": "ten-acme", "name": "Acme Corp", "slug": "acme-corp", "tenant_group": TENANT_GROUPS["tg-corp"]},
31+
"ten-nordnet": {"id": "ten-nordnet", "name": "Nordic Net", "slug": "nordic-net", "tenant_group": TENANT_GROUPS["tg-infra"]},
32+
"ten-euroix": {"id": "ten-euroix", "name": "EuroIX", "slug": "euroix", "tenant_group": TENANT_GROUPS["tg-infra"]},
33+
"ten-dcgmbh": {"id": "ten-dcgmbh", "name": "DataCenter GmbH", "slug": "datacenter-gmbh", "tenant_group": None},
2934
}
3035

3136
LOCATION_TYPES = {
@@ -482,6 +487,12 @@ def tenants():
482487
return jsonify(_paginate(list(TENANTS.values())))
483488

484489

490+
@app.route("/api/tenancy/tenant-groups/")
491+
def tenant_groups():
492+
_check_auth()
493+
return jsonify(_paginate(list(TENANT_GROUPS.values())))
494+
495+
485496
@app.route("/api/extras/statuses/")
486497
def statuses():
487498
_check_auth()

static/js/map.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,12 +515,14 @@ const filterStatus = document.getElementById("filter-status");
515515
const filterType = document.getElementById("filter-type");
516516
const filterParent = document.getElementById("filter-parent");
517517
const filterTenant = document.getElementById("filter-tenant");
518+
const filterTenantGroup = document.getElementById("filter-tenant-group");
518519

519520
function populateFilters(locations) {
520521
const statuses = [...new Set(locations.map((l) => l.status).filter(Boolean))].sort();
521522
const types = [...new Set(locations.map((l) => l.location_type).filter(Boolean))].sort();
522523
const parents = [...new Set(locations.map((l) => l.parent).filter(Boolean))].sort();
523524
const tenants = [...new Set(locations.map((l) => l.tenant).filter(Boolean))].sort();
525+
const tenantGroups = [...new Set(locations.map((l) => l.tenant_group).filter(Boolean))].sort();
524526

525527
filterStatus.innerHTML = '<option value="">All statuses</option>';
526528
for (const status of statuses) {
@@ -553,19 +555,29 @@ function populateFilters(locations) {
553555
opt.textContent = tenant;
554556
filterTenant.appendChild(opt);
555557
}
558+
559+
filterTenantGroup.innerHTML = '<option value="">All tenant groups</option>';
560+
for (const group of tenantGroups) {
561+
const opt = document.createElement("option");
562+
opt.value = group;
563+
opt.textContent = group;
564+
filterTenantGroup.appendChild(opt);
565+
}
556566
}
557567

558568
function applyFilters() {
559569
const statusVal = filterStatus.value;
560570
const typeVal = filterType.value;
561571
const parentVal = filterParent.value;
562572
const tenantVal = filterTenant.value;
573+
const tenantGroupVal = filterTenantGroup.value;
563574

564575
const filtered = allLocations.filter((loc) => {
565576
if (statusVal && loc.status !== statusVal) return false;
566577
if (typeVal && loc.location_type !== typeVal) return false;
567578
if (parentVal && loc.parent !== parentVal) return false;
568579
if (tenantVal && loc.tenant !== tenantVal) return false;
580+
if (tenantGroupVal && loc.tenant_group !== tenantGroupVal) return false;
569581
return true;
570582
});
571583

@@ -577,13 +589,15 @@ filterStatus.addEventListener("change", applyFilters);
577589
filterType.addEventListener("change", applyFilters);
578590
filterParent.addEventListener("change", applyFilters);
579591
filterTenant.addEventListener("change", applyFilters);
592+
filterTenantGroup.addEventListener("change", applyFilters);
580593

581594
// Clear filters button
582595
document.getElementById("clear-filters").addEventListener("click", () => {
583596
filterStatus.value = "";
584597
filterType.value = "";
585598
filterParent.value = "";
586599
filterTenant.value = "";
600+
filterTenantGroup.value = "";
587601
applyFilters();
588602
});
589603

templates/index.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ <h1>
6464
<select id="filter-tenant" title="Filter by tenant">
6565
<option value="">All tenants</option>
6666
</select>
67+
<select id="filter-tenant-group" title="Filter by tenant group">
68+
<option value="">All tenant groups</option>
69+
</select>
6770
<button id="clear-filters" title="Clear all filters" style="margin-top:8px;padding:6px 12px;background:#6c757d;color:white;border:none;border-radius:4px;cursor:pointer;font-size:0.85rem">
6871
Clear Filters
6972
</button>

tests/test_app.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def client():
3333
"longitude": "12.5683",
3434
"description": "Main DC",
3535
"physical_address": "Somestreet 1, Copenhagen",
36-
"tenant": {"id": "ten-1", "name": "Acme Corp"},
36+
"tenant": {"id": "ten-1", "name": "Acme Corp", "tenant_group": {"id": "tg-1", "name": "Corporate"}},
3737
"asn": 65001,
3838
"time_zone": "Europe/Copenhagen",
3939
"url": "https://nautobot.example.com/api/dcim/locations/loc-1/",
@@ -118,6 +118,18 @@ def mock_nautobot_get(endpoint, params=None):
118118
return SAMPLE_DEVICES_PAGE
119119
if "ipam/asns" in endpoint:
120120
return SAMPLE_ASNS_PAGE
121+
if "tenancy/tenant-groups" in endpoint:
122+
return {
123+
"count": 1, "next": None,
124+
"results": [{"id": "tg-1", "name": "Corporate"}],
125+
}
126+
if "tenancy/tenants" in endpoint:
127+
return {
128+
"count": 1, "next": None,
129+
"results": [
130+
{"id": "ten-1", "name": "Acme Corp", "tenant_group": {"id": "tg-1", "name": "Corporate"}},
131+
],
132+
}
121133
return {"count": 0, "next": None, "results": []}
122134

123135

@@ -157,6 +169,19 @@ def test_parent_field_populated(self, client):
157169
loc = resp.get_json()["locations"][0]
158170
assert loc["parent"] == "Denmark"
159171

172+
def test_tenant_group_field_populated(self, client):
173+
with patch.object(flask_app, "nautobot_get", side_effect=mock_nautobot_get):
174+
resp = client.get("/api/locations")
175+
loc = resp.get_json()["locations"][0]
176+
assert loc["tenant_group"] == "Corporate"
177+
178+
def test_tenant_group_empty_when_no_tenant(self, client):
179+
with patch.object(flask_app, "nautobot_get", side_effect=mock_nautobot_get):
180+
resp = client.get("/api/locations")
181+
# loc-2 (Aarhus PoP) has no tenant
182+
loc = resp.get_json()["locations"][1]
183+
assert loc["tenant_group"] == ""
184+
160185
def test_location_type_fallback_with_brief_nested_object(self, client):
161186
"""When location_type is brief (id+url only), the fallback map resolves the name."""
162187
brief_locations = {
@@ -455,6 +480,10 @@ def test_index_contains_filter_section(self, client):
455480
resp = client.get("/")
456481
assert b'id="filter-section"' in resp.data
457482

483+
def test_index_contains_filter_tenant_group(self, client):
484+
resp = client.get("/")
485+
assert b'id="filter-tenant-group"' in resp.data
486+
458487

459488

460489
# ---------------------------------------------------------------------------

tests/test_integration.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,17 @@ def test_tenant_field_populated(self, integration_client):
133133
cph = next(l for l in data["locations"] if l["name"] == "Copenhagen DC")
134134
assert cph["tenant"] == "Acme Corp"
135135

136+
def test_tenant_group_field_populated(self, integration_client):
137+
data = integration_client.get("/api/locations").get_json()
138+
cph = next(l for l in data["locations"] if l["name"] == "Copenhagen DC")
139+
assert cph["tenant_group"] == "Corporate"
140+
141+
def test_tenant_group_empty_when_no_group(self, integration_client):
142+
"""Frankfurt DC has tenant DataCenter GmbH which has no tenant group."""
143+
data = integration_client.get("/api/locations").get_json()
144+
fra = next(l for l in data["locations"] if l["name"] == "Frankfurt DC")
145+
assert fra["tenant_group"] == ""
146+
136147
def test_asn_field_populated(self, integration_client):
137148
data = integration_client.get("/api/locations").get_json()
138149
cph = next(l for l in data["locations"] if l["name"] == "Copenhagen DC")

0 commit comments

Comments
 (0)