diff --git a/Makefile b/Makefile index f1f1c727..b46d0ad4 100644 --- a/Makefile +++ b/Makefile @@ -9,10 +9,17 @@ SERVICE_HOME ?= /opt/danbyte # Where the app writes danbyte.log + gunicorn logs (settings.LOGGING reads # DANBYTE_LOG_DIR from .env; systemd still mirrors process output to journald). LOG_DIR ?= /var/log/danbyte -SERVICES := danbyte-mockups danbyte-infra danbyte-backend danbyte-workers danbyte-docs +# Dev-only units. `danbyte-infra` runs Postgres + Redis in docker compose for a +# workstation; a production install provisions them natively (scripts/install.sh +# creates the role/database with psql), so these must NEVER be linked there — +# doing so left an idle, empty Postgres container on hosts that already had one. +DEV_SERVICES := danbyte-mockups danbyte-infra danbyte-backend +# Units both dev and production run. +SHARED_SERVICES := danbyte-workers danbyte-docs +SERVICES := $(DEV_SERVICES) $(SHARED_SERVICES) # Timer-driven oneshots (monitoring beat). Each has a .service + a .timer; the # timer is what gets enabled. Not part of `up`/`down` (they're not long-running). -TIMERS := danbyte-dispatch danbyte-materialise danbyte-prune danbyte-utilization danbyte-alert-maintenance danbyte-discover danbyte-cleanup danbyte-drift-dispatch danbyte-auto-upgrade danbyte-drive-outposts danbyte-digest +TIMERS := danbyte-dispatch danbyte-materialise danbyte-prune danbyte-utilization danbyte-alert-maintenance danbyte-discover danbyte-cleanup danbyte-drift-dispatch danbyte-auto-upgrade danbyte-drive-outposts danbyte-digest danbyte-hardware PY := $(PROJECT_DIR)/.venv/bin/python .PHONY: help install-services uninstall-services reload \ @@ -298,7 +305,9 @@ collectstatic: # make install-prod-services # systemctl --user stop danbyte-backend danbyte-frontend # the dev units # systemctl --user enable --now danbyte-web danbyte-ws danbyte-frontend-prod -PROD_SERVICES := danbyte-web danbyte-ws danbyte-frontend-prod +# Everything a production host runs — web/ws/frontend plus the shared units. +# scripts/install.sh links ONLY these; it must not pull in DEV_SERVICES. +PROD_SERVICES := danbyte-web danbyte-ws danbyte-frontend-prod $(SHARED_SERVICES) install-prod-services: @mkdir -p $(SYSTEMD_DIR) @@ -306,7 +315,14 @@ install-prod-services: ln -sfn $(PROJECT_DIR)/services/$$s.service $(SYSTEMD_DIR)/$$s.service ; \ echo " linked $$s.service" ; \ done + @for s in $(TIMERS); do \ + ln -sfn $(PROJECT_DIR)/services/$$s.service $(SYSTEMD_DIR)/$$s.service ; \ + ln -sfn $(PROJECT_DIR)/services/$$s.timer $(SYSTEMD_DIR)/$$s.timer ; \ + done @systemctl --user daemon-reload + @for s in $(TIMERS); do \ + systemctl --user enable --now $$s.timer >/dev/null 2>&1 || true ; \ + done @echo "Linked. Build the frontend + collect static, then enable:" @echo " make frontend-build collectstatic" @echo " systemctl --user enable --now $(PROD_SERVICES)" diff --git a/api/api_urls.py b/api/api_urls.py index 8fe1a677..8b040c2c 100644 --- a/api/api_urls.py +++ b/api/api_urls.py @@ -95,6 +95,8 @@ MACAddressViewSet, RackViewSet, RackRoleViewSet, + RackTypeViewSet, + RackTypeAccessoryViewSet, DeviceRoleViewSet, PlatformGroupViewSet, PlatformViewSet, @@ -143,7 +145,9 @@ FloorPlanTileViewSet, SiteMarkerViewSet, CableRouteViewSet, + FloorPlanRaisedFloorAreaViewSet, FloorPlanTrayViewSet, + FloorPlanWallViewSet, FloorPlanViewSet, FloorTileTypeViewSet, ModuleViewSet, @@ -240,6 +244,9 @@ router.register(r"vm-interfaces", VMInterfaceViewSet, basename="vm-interface") router.register(r"racks", RackViewSet, basename="rack") router.register(r"rack-roles", RackRoleViewSet, basename="rack-role") +router.register(r"rack-types", RackTypeViewSet, basename="rack-type") +router.register(r"rack-type-accessories", RackTypeAccessoryViewSet, + basename="rack-type-accessory") router.register(r"device-roles", DeviceRoleViewSet, basename="device-role") router.register(r"platform-groups", PlatformGroupViewSet, basename="platform-group") router.register(r"platforms", PlatformViewSet, basename="platform") @@ -276,6 +283,14 @@ router.register(r"floor-plans", FloorPlanViewSet, basename="floor-plan") router.register(r"floor-plan-tiles", FloorPlanTileViewSet, basename="floor-plan-tile") router.register(r"floor-plan-trays", FloorPlanTrayViewSet, basename="floor-plan-tray") +router.register( + r"floor-plan-raised-floors", + FloorPlanRaisedFloorAreaViewSet, + basename="floor-plan-raised-floor", +) +router.register( + r"floor-plan-walls", FloorPlanWallViewSet, basename="floor-plan-wall" +) router.register(r"cable-routes", CableRouteViewSet, basename="cable-route") router.register(r"module-interface-templates", ModuleInterfaceTemplateViewSet, basename="module-interface-template") router.register(r"modules", ModuleViewSet, basename="module") diff --git a/api/apps.py b/api/apps.py index 4a5d4ae4..210bee8e 100644 --- a/api/apps.py +++ b/api/apps.py @@ -11,7 +11,7 @@ def ready(self): # by shipping an ``io.py`` that calls ``api.io.register_io``. from django.utils.module_loading import autodiscover_modules - from . import io + from . import io, signals # noqa: F401 (signals register on import) io.register_builtins() autodiscover_modules("io") diff --git a/api/device_library.py b/api/device_library.py new file mode 100644 index 00000000..57690c7d --- /dev/null +++ b/api/device_library.py @@ -0,0 +1,353 @@ +"""Portable device-type bundles — the shareable half of the device library. + +Teaching Danbyte a piece of hardware is real work: stamp the component +templates, draw the faceplate, place the photo-port markers on the rear image, +find the vendor OID that reports drive health. All of it is knowledge about the +*model*, identical for everyone who owns that box. A bundle is that work in one +file, so the next person imports it instead of redoing it. + +Design rules, in order of importance: + +1. **No credentials, ever.** A bundle carries OIDs and value maps; sensors poll + with the *importing* deployment's own SNMP profile. There is nothing secret + to strip because nothing secret is referenced. +2. **Names, not ids.** UUIDs are per-deployment. Manufacturers, device types and + inter-component references (an outlet's inlet, a front port's rear port) all + travel as names and are re-resolved on the far side. +3. **The type's name is its identity.** Re-importing updates in place; nothing + duplicates. (Sensors inside a bundle key off their own slug.) +4. **An imported sensor observes, it does not write.** ``apply_mode`` is forced + to ``drift`` on import — see :func:`import_bundle`. +""" +from __future__ import annotations + +from typing import Any + +BUNDLE_VERSION = 1 +BUNDLE_KEY = "danbyte_device_type" + +# The physical spec of the type itself. Deliberately excludes ids, tenant, +# owning_site, timestamps and device_count — all local facts. +# `name` is the identity (DeviceType has no slug); `model` is a separate +# free-text field the catalog also carries. +TYPE_FIELDS = ( + "name", "model", "part_number", "u_height", "rack_width", "is_full_depth", + "airflow", "weight", "weight_unit", "subdevice_role", + "exclude_from_utilization", "description", +) + +# Component templates: bundle key → (device-type relation, exported fields). +# Order matters on import — rear ports before front ports, power ports before +# outlets — because the second of each pair references the first BY NAME. +COMPONENT_SPECS: tuple[tuple[str, str, tuple[str, ...]], ...] = ( + ("interfaces", "interface_templates", + ("name", "description", "type", "enabled", "poe_mode", "poe_type", + "mgmt_only")), + ("console_ports", "console_port_templates", ("name", "description", "type")), + ("console_server_ports", "console_server_port_templates", + ("name", "description", "type")), + ("aux_ports", "aux_port_templates", ("name", "description", "type")), + ("power_ports", "power_port_templates", + ("name", "description", "type", "maximum_draw", "allocated_draw")), + ("power_outlets", "power_outlet_templates", + ("name", "description", "type", "feed_leg")), + ("rear_ports", "rear_port_templates", + ("name", "description", "type", "positions", "is_splitter")), + ("front_ports", "front_port_templates", + ("name", "description", "type", "rear_port_position", "positions")), + ("module_bays", "module_bay_templates", + ("name", "description", "position")), + ("device_bays", "device_bay_templates", ("name", "description")), + ("inventory_items", "inventory_item_templates", + ("name", "description", "part_id", "kind", "media", "capacity_bytes", + "speed")), +) + +# Sensor definition fields — the same set the sensor pack exports, so the two +# formats stay interchangeable. +SENSOR_FIELDS = ( + "name", "slug", "description", "oid", "walk", "item_kind", "name_template", + "value_map", "absent_status", "enabled", +) + + +def export_bundle(device_type) -> dict[str, Any]: + """Assemble a portable bundle for one configured device type.""" + from monitoring.models import SnmpSensor + + out: dict[str, Any] = { + BUNDLE_KEY: BUNDLE_VERSION, + "manufacturer": ( + device_type.manufacturer.name if device_type.manufacturer_id else None + ), + } + for f in TYPE_FIELDS: + out[f] = getattr(device_type, f, None) + + components: dict[str, list[dict]] = {} + for key, relation, fields in COMPONENT_SPECS: + rows = [] + for c in getattr(device_type, relation).all(): + row = {f: getattr(c, f) for f in fields} + # Cross-references by name: the far side has different ids. + if key == "power_outlets": + row["power_port"] = ( + c.power_port_template.name if c.power_port_template_id else None + ) + elif key == "front_ports": + row["rear_port"] = c.rear_port_template.name + elif key == "inventory_items": + row["manufacturer"] = ( + c.manufacturer.name if c.manufacturer_id else None + ) + rows.append(row) + if rows: + components[key] = rows + out["components"] = components + + # The Danbyte-specific layers — the whole point of the format. + out["faceplate"] = device_type.faceplate + out["image_ports"] = device_type.image_ports + out["sensors"] = [ + {f: getattr(s, f) for f in SENSOR_FIELDS} + for s in SnmpSensor.objects.filter(device_type=device_type).order_by("name") + ] + # Images are referenced, not embedded: a bundle stays a text file you can + # read and diff. The importer says which are missing so the user can upload + # them — the marker coordinates are useless without the photo they were + # placed on. + out["images"] = { + "front": bool(device_type.front_image), + "rear": bool(device_type.rear_image), + } + return out + + +class BundleError(ValueError): + """The payload isn't a bundle this build can read.""" + + +def _check_envelope(payload: Any) -> None: + if not isinstance(payload, dict): + raise BundleError("Expected a bundle object.") + version = payload.get(BUNDLE_KEY) + if version is None: + raise BundleError( + f"Not a device bundle — the '{BUNDLE_KEY}' key is missing." + ) + if version != BUNDLE_VERSION: + raise BundleError( + f"Bundle version {version} isn't supported (this build reads " + f"{BUNDLE_VERSION})." + ) + if not str(payload.get("name") or "").strip(): + raise BundleError("A bundle needs a device-type name.") + + +def import_bundle( + payload: Any, tenant, *, replace: bool = False, dry_run: bool = False, + owning_site=None, +) -> dict[str, Any]: + """Create or update a device type and everything the bundle carries. + + ``dry_run`` reports exactly what would happen and writes nothing — the + default for the UI's first pass, because "import this stranger's file" should + never be a blind action. + + ``replace`` is required to touch a device type that already exists here; + without it an existing name is reported and skipped, so an import can't + quietly rewrite a type someone tuned. + + Returns a report: what was created, what was skipped, and what couldn't be + resolved. Nothing is silently dropped. + """ + from django.db import transaction + + from monitoring.models import SnmpSensor + + from .models import DeviceType, Manufacturer + + _check_envelope(payload) + name = str(payload["name"]).strip() + report: dict[str, Any] = { + "dry_run": dry_run, + "device_type": name, + "action": "create", + "components": {}, + "sensors": {"created": 0, "updated": 0, "skipped": 0}, + "faceplate": bool(payload.get("faceplate")), + "image_ports": bool(payload.get("image_ports")), + "missing_images": [], + "warnings": [], + } + + existing = DeviceType.objects.filter(tenant=tenant, name=name).first() + if existing and not replace: + report["action"] = "skipped" + report["warnings"].append( + f"A device type named {name!r} already exists here. Re-run with " + "replace to update it." + ) + return report + report["action"] = "update" if existing else "create" + + # The bundle says whether it was built against a front/rear photo. Marker + # coordinates are meaningless without one, so say so rather than importing + # markers that can't be seen. + imgs = payload.get("images") or {} + for side in ("front", "rear"): + if imgs.get(side) and not ( + existing and getattr(existing, f"{side}_image", None) + ): + report["missing_images"].append(side) + if report["missing_images"] and payload.get("image_ports"): + report["warnings"].append( + "Photo-port markers reference a " + + "/".join(report["missing_images"]) + + " image this deployment doesn't have — upload it on the device " + "type and the markers will line up." + ) + + comps = payload.get("components") or {} + if not isinstance(comps, dict): + raise BundleError("`components` must be an object.") + for key, _relation, _fields in COMPONENT_SPECS: + rows = comps.get(key) or [] + if not isinstance(rows, list): + raise BundleError(f"`components.{key}` must be a list.") + if rows: + report["components"][key] = len(rows) + sensors = payload.get("sensors") or [] + if not isinstance(sensors, list): + raise BundleError("`sensors` must be a list.") + + if dry_run: + report["sensors"]["created"] = len(sensors) + return report + + with transaction.atomic(): + manufacturer = None + mname = (payload.get("manufacturer") or "").strip() + if mname: + manufacturer, _ = Manufacturer.objects.get_or_create( + tenant=tenant, name=mname, + defaults={"owning_site": owning_site} if owning_site else {}, + ) + fields = { + f: payload.get(f) + for f in TYPE_FIELDS + if f != "name" and payload.get(f) is not None + } + fields["manufacturer"] = manufacturer + if payload.get("faceplate"): + fields["faceplate"] = payload["faceplate"] + if payload.get("image_ports"): + fields["image_ports"] = payload["image_ports"] + if existing: + for k, v in fields.items(): + setattr(existing, k, v) + existing.save() + dt = existing + else: + if owning_site is not None: + fields["owning_site"] = owning_site + dt = DeviceType.objects.create(tenant=tenant, name=name, **fields) + + _import_components(dt, comps, report) + _import_sensors(dt, tenant, sensors, report, replace=replace) + return report + + +def _import_components(dt, comps: dict, report: dict) -> None: + """Create the template rows, in COMPONENT_SPECS order so a row that + references another (front→rear, outlet→inlet) finds it already made.""" + from .models import Manufacturer + + made: dict[str, dict[str, Any]] = {} + for key, relation, fields in COMPONENT_SPECS: + rows = comps.get(key) or [] + model = getattr(dt, relation).model + have = set(getattr(dt, relation).values_list("name", flat=True)) + created = 0 + for row in rows: + if not isinstance(row, dict) or not str(row.get("name") or "").strip(): + report["warnings"].append(f"{key}: a row without a name was skipped.") + continue + if row["name"] in have: + continue + kwargs = { + f: row[f] for f in fields if f in row and row[f] is not None + } + if key == "power_outlets" and row.get("power_port"): + inlet = made.get("power_ports", {}).get(row["power_port"]) or ( + dt.power_port_templates.filter(name=row["power_port"]).first() + ) + if inlet is None: + report["warnings"].append( + f"power_outlets: {row['name']} names inlet " + f"{row['power_port']!r}, which isn't in the bundle." + ) + kwargs["power_port_template"] = inlet + elif key == "front_ports": + rear = made.get("rear_ports", {}).get(row.get("rear_port")) or ( + dt.rear_port_templates.filter(name=row.get("rear_port")).first() + ) + if rear is None: + # A front port cannot exist without its rear port (non-null + # FK), so this row is dropped loudly rather than crashing. + report["warnings"].append( + f"front_ports: {row['name']} names rear port " + f"{row.get('rear_port')!r}, which isn't in the bundle — " + "skipped." + ) + continue + kwargs["rear_port_template"] = rear + elif key == "inventory_items" and row.get("manufacturer"): + kwargs["manufacturer"], _ = Manufacturer.objects.get_or_create( + tenant=dt.tenant, name=row["manufacturer"] + ) + obj = model.objects.create(device_type=dt, **kwargs) + made.setdefault(key, {})[obj.name] = obj + created += 1 + if created: + report["components"][key] = created + elif key in report["components"]: + report["components"][key] = 0 + return None + + +def _import_sensors(dt, tenant, sensors: list, report: dict, *, replace: bool) -> None: + """Bind the bundle's sensors to this type. + + Forced to ``apply_mode=drift``: a bundle from someone else must never arrive + with permission to overwrite a status a human here set. Danbyte is a source + of truth with drift visualisation; the importer opts into ``auto`` locally if + they want it. + """ + from monitoring.models import SnmpSensor + + for row in sensors: + if not isinstance(row, dict): + report["warnings"].append("sensors: a non-object row was skipped.") + continue + slug = str(row.get("slug") or "").strip() + if not slug: + report["warnings"].append( + f"sensors: {row.get('name')!r} has no slug — skipped." + ) + continue + fields = {f: row[f] for f in SENSOR_FIELDS if f in row and f != "slug"} + fields["apply_mode"] = SnmpSensor.APPLY_DRIFT + fields["device_type"] = dt + existing = SnmpSensor.objects.filter(tenant=tenant, slug=slug).first() + if existing and not replace: + report["sensors"]["skipped"] += 1 + continue + if existing: + for k, v in fields.items(): + setattr(existing, k, v) + existing.save() + report["sensors"]["updated"] += 1 + else: + SnmpSensor.objects.create(tenant=tenant, slug=slug, **fields) + report["sensors"]["created"] += 1 diff --git a/api/devicetype_import.py b/api/devicetype_import.py index fb03f8c0..33bc6c60 100644 --- a/api/devicetype_import.py +++ b/api/devicetype_import.py @@ -71,12 +71,89 @@ "master/elevation-images" ) +# Default repository for *re*-importing images (Danbyte's fork of the library — +# same layout, images under elevation-images/). The YAML importer above keeps +# pulling from netbox-community at the pinned ref, unchanged. +DEFAULT_REIMPORT_REPO = "https://github.com/danbyte-net/device-library" + +# GitHub shorthand: a bare "owner/name". +_OWNER_NAME_RE = re.compile(r"^[\w.-]+/[\w.-]+$") +# A repository page URL with no /tree|/blob path: https://github.com/o/r[.git] +_GITHUB_REPO_RE = re.compile(r"^https://github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$") + # github.com blob URLs → raw file URLs, so users can paste straight from the # browser address bar. _GITHUB_BLOB_RE = re.compile( r"^https://github\.com/([^/]+)/([^/]+)/blob/(.+)$" ) +# github.com *directory* URLs — a whole folder of YAML to expand. +# https://github.com///(tree|blob)// +# GitHub uses /tree/ for folders and /blob/ for files, but people paste either +# from the address bar, so accept both and decide by the path: a trailing +# segment with a file extension is a file, anything else is a folder. +_GITHUB_DIR_RE = re.compile( + r"^https://github\.com/([^/]+)/([^/]+)/(?:tree|blob)/([^/]+)(?:/(.*))?$" +) + + +def is_github_dir(url: str) -> bool: + m = _GITHUB_DIR_RE.match(url.strip()) + if not m: + return False + last = (m.group(4) or "").rstrip("/").rsplit("/", 1)[-1] + return "." not in last # no extension → a folder, not a file + + +def expand_github_dir(url: str, get, *, exts=(".yaml", ".yml")) -> list[str]: + """Expand a github.com ``/tree/`` directory URL into raw URLs for every + YAML file under it (recursively). + + Uses the Git *trees* API (one request lists the whole repo tree), then + keeps blobs whose path sits under the requested sub-path. ``get`` is an + SSRF-guarded fetcher (``core.ssrf.safe_get``) so the API host is validated + like any other outbound call. Raises ``ValueError`` with a readable message + on an unusable response.""" + from urllib.parse import quote, unquote + + m = _GITHUB_DIR_RE.match(url.strip()) + if not m: + raise ValueError("Not a GitHub directory URL.") + owner, repo, ref, path = m.group(1), m.group(2), m.group(3), (m.group(4) or "") + # Address-bar URLs arrive percent-encoded ("Palo%20Alto%20Networks"), but + # the trees API returns REAL names with spaces — compare like with like, + # or a pasted manufacturer folder matches zero files and imports nothing. + prefix = unquote(path).rstrip("/") + api = ( + f"https://api.github.com/repos/{owner}/{repo}/git/trees/" + f"{ref}?recursive=1" + ) + resp = get(api, timeout=30) + resp.raise_for_status() + data = resp.json() + if data.get("truncated"): + # The tree API caps at ~100k entries; the library is well under, but be + # explicit rather than silently import a partial set. + raise ValueError( + "GitHub returned a truncated tree — narrow to a sub-folder." + ) + raw_base = f"https://raw.githubusercontent.com/{owner}/{repo}/{ref}/" + out = [] + for node in data.get("tree", []): + p = node.get("path", "") + if node.get("type") != "blob" or not p.lower().endswith(exts): + continue + if prefix and not (p == prefix or p.startswith(prefix + "/")): + continue + # Real names → valid URL (spaces and friends percent-encoded). + out.append(raw_base + quote(p)) + if not out: + raise ValueError( + f"No YAML files found under {prefix or 'the repository root'!r} — " + "check the folder URL (the folder may be empty or renamed)." + ) + return out + # Leading slot digit of a slash-numbered component name. Only 0 or 1 qualify — # they're what standalone hardware ships as (Cisco counts from 1, Juniper 0). _SLOT_RE = re.compile(r"^([A-Za-z\-]*)([01])(/)") @@ -92,6 +169,56 @@ def to_raw_url(url: str) -> str: return url.strip() +def elevation_image_base(repo: str) -> str: + """Normalise a repository reference to the https base URL under which + elevation images live (``//..``). + + Accepts what people actually paste: + + - plain ``owner/name`` GitHub shorthand, + - a ``https://github.com/owner/name`` page URL, optionally with + ``/tree/`` or ``/tree//`` (``/blob/`` too — address-bar + paste), or + - a full ``https://`` base (a raw.githubusercontent.com URL, or an + internal mirror that serves the same layout). + + GitHub forms without an explicit ref use ``HEAD`` — the repository's + default branch, whatever it's called — rather than guessing ``master`` vs + ``main``. ``elevation-images`` is appended unless the given path already + ends with it (the library keeps images there and forks keep the layout). + Raises :class:`ValueError` for anything that isn't https; the fetch itself + still goes through ``core.ssrf.safe_request`` like every outbound call. + """ + from urllib.parse import urlparse + + ref = (repo or "").strip().rstrip("/") + if not ref: + raise ValueError("Provide a repository — owner/name or an https:// URL.") + if _OWNER_NAME_RE.match(ref): + return f"https://raw.githubusercontent.com/{ref}/HEAD/elevation-images" + m = _GITHUB_REPO_RE.match(ref) + if m: + return ( + f"https://raw.githubusercontent.com/{m.group(1)}/{m.group(2)}/" + "HEAD/elevation-images" + ) + m = _GITHUB_DIR_RE.match(ref) + if m: + owner, name, gref = m.group(1), m.group(2), m.group(3) + path = (m.group(4) or "").strip("/") + base = f"https://raw.githubusercontent.com/{owner}/{name}/{gref}" + if path and path != "elevation-images" and not path.endswith("/elevation-images"): + path = f"{path}/elevation-images" + return f"{base}/{path or 'elevation-images'}" + parsed = urlparse(ref) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError( + "Repository must be owner/name, a github.com URL, or an https:// " + "base URL." + ) + return ref if ref.endswith("/elevation-images") else f"{ref}/elevation-images" + + def positionize(name: str) -> str: """``GigabitEthernet1/0/1`` → ``GigabitEthernet{position}/0/1``; ``xe-0/0/0`` → ``xe-{position:0}/0/0``. Names without a leading slot @@ -118,7 +245,8 @@ def _get_or_create_manufacturer(tenant, name: str, owning_site=None): def import_devicetype_yaml( - tenant, text: str, *, stack_positions: bool = False, owning_site=None + tenant, text: str, *, stack_positions: bool = False, owning_site=None, + image_inventory: set[str] | None = None, ) -> dict: """Create a DeviceType (+ templates) from one devicetype-library YAML doc. @@ -192,7 +320,9 @@ def import_devicetype_yaml( for face in ("front", "rear"): if not data.get(f"{face}_image") or not slug: continue - if _fetch_elevation_image(dt, manufacturer_name, slug, face): + if _fetch_elevation_image( + dt, manufacturer_name, slug, face, inventory=image_inventory + ): skipped.append(f"{face}_image: downloaded from devicetype-library") else: skipped.append(f"{face}_image: not found in devicetype-library") @@ -345,7 +475,8 @@ def name_of(row: dict) -> str: def import_yaml_auto( - tenant, text: str, *, stack_positions: bool = False, owning_site=None + tenant, text: str, *, stack_positions: bool = False, owning_site=None, + image_inventory: set[str] | None = None, ) -> dict: """Import one library YAML doc, auto-detecting its kind: device-type files carry ``u_height``/``slug``; module-type files don't. The result @@ -361,7 +492,8 @@ def import_yaml_auto( } return { **import_devicetype_yaml( - tenant, text, stack_positions=stack_positions, owning_site=owning_site + tenant, text, stack_positions=stack_positions, + owning_site=owning_site, image_inventory=image_inventory, ), "kind": "device-type", } @@ -429,27 +561,348 @@ def import_moduletype_yaml(tenant, text: str, *, owning_site=None) -> dict: } -def _fetch_elevation_image(dt, manufacturer: str, slug: str, face: str) -> bool: +def _fetch_elevation_image( + dt, manufacturer: str, slug: str, face: str, image_base: str = _IMAGE_BASE, + inventory: set[str] | None = None, +) -> bool: """Try to download ..png|jpg from the devicetype-library and attach it to the DeviceType. Returns True on success.""" + return ( + _pull_elevation_image(dt, manufacturer, slug, face, image_base, inventory) + == "saved" + ) + + +def _pull_elevation_image( + dt, manufacturer: str, slug: str, face: str, image_base: str, + inventory: set[str] | None = None, +) -> str: + """Download one face's image and attach it. ``"saved"`` on success, + ``"not_found"`` when the repo simply doesn't have it, ``"fetch_failed"`` + when the network/SSRF layer refused — callers report the difference. + + The attach goes through ``FieldFile.save(..., save=True)`` → a plain model + ``.save()``, so the audit change-log signal fires for the image change.""" from urllib.parse import quote from django.core.files.base import ContentFile from core.ssrf import safe_get - for ext in ("png", "jpg"): + exts: tuple[str, ...] = ("png", "jpg") + if inventory is not None: + # One-shot repo listing: fetch the KNOWN extension directly, and skip + # absent images without a single request — extension guessing was up + # to 4 sequential round-trips per type on the bulk import path. + exts = tuple( + ext for ext in exts + if f"{manufacturer}/{slug}.{face}.{ext}" in inventory + ) + if not exts: + return "not_found" + for ext in exts: # Manufacturer dirs can contain spaces ("Palo Alto") — quote segments. - url = f"{_IMAGE_BASE}/{quote(manufacturer)}/{quote(slug)}.{face}.{ext}" + url = f"{image_base}/{quote(manufacturer)}/{quote(slug)}.{face}.{ext}" try: resp = safe_get(url, timeout=5) except Exception: # noqa: BLE001 — network is best-effort here - return False + return "fetch_failed" if resp.status_code == 200 and resp.content: field = dt.front_image if face == "front" else dt.rear_image field.save(f"{slug}.{face}.{ext}", ContentFile(resp.content), save=True) - return True - return False + return "saved" + return "not_found" + + +# ─── Re-importing images for EXISTING device types ────────────────────────── +# Recovery tool: the media folder was lost/corrupted (or types were created +# without images) while the DeviceType rows survived. Match each type against +# a devicetype-library-layout repo and re-download only the elevation images — +# no types are created or modified beyond the two image fields. + +REIMPORT_FACES = ("front", "rear") + +#: Types handled in one synchronous request; bigger catalogs go to the RQ run. +#: A type typically costs 2–4 probe requests (worst-case bounded by the +#: candidate cap below), so 50 types lands in the same outbound budget as the +#: YAML importer's 200-file sync cap. +REIMPORT_SYNC_CAP = 50 + +#: How many slug candidates to probe per type before declaring no_match. +_MAX_SLUG_CANDIDATES = 5 + +# The importer saves downloads as ".."; Django dedupes +# collisions to "._.". Either way the basename still +# carries the library slug — the strongest matching signal we have, since +# DeviceType doesn't persist the library slug itself. +_IMAGE_NAME_RE = re.compile( + r"^(?P.+)\.(?:front|rear)(?:_\w+)?\.(?:png|jpe?g)$", re.IGNORECASE +) + + +#: Refusal shown when an airgapped deployment asks for a repo fetch. Kept in +#: one place so the endpoint and the background task word it identically. +AIRGAP_IMAGES_DETAIL = ( + "This deployment is airgapped (update checks are disabled), so images " + "can't be re-downloaded from a repository. Recover offline instead: " + "restore the media folder from a backup, or re-upload images on each " + "device type — offline device-type bundles carry definitions but " + "reference images rather than embed them." +) + + +def airgap_refusal() -> str | None: + """The refusal message when this deployment is airgapped + (``DeploymentSettings.disable_update_check`` — the same switch that stops + release-repo checks), else ``None``. Checked BEFORE any outbound attempt + so an airgapped install gets a clean error, not a hanging timeout.""" + from core.models import DeploymentSettings + + if DeploymentSettings.load().disable_update_check: + return AIRGAP_IMAGES_DETAIL + return None + + +def summarize_reimport(rows: list[dict]) -> dict: + """Totals for a batch of :func:`reimport_images_for_type` rows.""" + totals = { + "types": len(rows), "matched": 0, "no_match": 0, + "skipped_has_images": 0, "fetch_failed": 0, "images_downloaded": 0, + } + for r in rows: + totals[r["status"]] = totals.get(r["status"], 0) + 1 + totals["images_downloaded"] += r.get("downloaded", 0) + return totals + + +def candidate_slugs(dt) -> list[str]: + """Library slugs this type could be filed under, most-confident first. + + Order: slugs recovered from the stored image *filenames* (exactly what the + original import wrote — they survive in the DB even when the files are + gone), then derivations using the same ``django.utils.text.slugify`` the + importer uses, following the library convention of vendor-prefixed slugs + (``cisco-c9300-48p``) with unprefixed fallbacks.""" + out: list[str] = [] + + def add(s: str) -> None: + if s and s not in out: + out.append(s) + + for field in (dt.front_image, dt.rear_image): + name = (getattr(field, "name", "") or "").rsplit("/", 1)[-1] + m = _IMAGE_NAME_RE.match(name) + if m: + add(m.group("slug")) + mfr = dt.manufacturer.name if dt.manufacturer_id else "" + for label in (dt.name, dt.part_number, dt.model): + label = (label or "").strip() + if not label: + continue + if mfr: + add(slugify(f"{mfr} {label}")) + add(slugify(label)) + return out[:_MAX_SLUG_CANDIDATES] + + +def _face_missing(dt, face: str) -> bool: + """True when this face needs an image: the field is empty, OR the field + holds a path whose file no longer exists in storage. The latter is the + corrupt/lost-media case — the DB survived, the media folder didn't — and + counts as a gap for fill-gaps-only reimports.""" + field = dt.front_image if face == "front" else dt.rear_image + if not field or not field.name: + return True + try: + return not field.storage.exists(field.name) + except Exception: # noqa: BLE001 — unreadable storage counts as missing + return True + + +def repo_image_inventory(image_base: str) -> set[str] | None: + """Every image path under the repo's elevation-images dir, fetched in TWO + requests via GitHub's git-trees API — so matching a 1000-type catalog is + in-memory set lookups instead of ~20 sequential HEAD probes per type + (which is the difference between sub-second and the better part of an + hour). + + Returns ``{"/..", ...}`` with REAL (un-URL- + quoted) names, or ``None`` when the base isn't a GitHub raw URL or the + listing fails (rate limit, private repo, network) — callers fall back to + per-image probing, which stays correct for arbitrary https mirrors.""" + import json as _json + + from core.ssrf import safe_get + + m = re.match( + r"^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)$", + image_base.rstrip("/"), + ) + if not m: + return None + owner, repo, ref, subpath = m.groups() + try: + # Top-level tree (non-recursive) → the subtree's sha. Walk one level + # per path segment so bases like danbyte/elevation-images work too. + # Segments are unquoted first: a pasted URL carries %20 where the + # tree API answers with real spaces. + from urllib.parse import unquote as _unquote + + sha = ref + for segment in _unquote(subpath).split("/"): + top = safe_get( + f"https://api.github.com/repos/{owner}/{repo}/git/trees/{sha}", + timeout=15, + ) + if top.status_code != 200: + return None + entry = next( + ( + e + for e in _json.loads(top.content).get("tree", []) + if e.get("path") == segment and e.get("type") == "tree" + ), + None, + ) + if entry is None: + return set() # repo simply has no such dir — honest empty + sha = entry["sha"] + # The subtree, recursive: every image path in one response. The + # elevation-images subtree is far below GitHub's truncation limits + # even for the full community library. + sub = safe_get( + f"https://api.github.com/repos/{owner}/{repo}/git/trees/{sha}" + "?recursive=1", + timeout=30, + ) + if sub.status_code != 200: + return None + body = _json.loads(sub.content) + if body.get("truncated"): + return None # incomplete listing would fabricate no_match rows + return { + e["path"] for e in body.get("tree", []) if e.get("type") == "blob" + } + except Exception: # noqa: BLE001 — any trouble → probe fallback + return None + + +def _face_in_repo( + manufacturer: str, + slug: str, + face: str, + image_base: str, + inventory: set[str] | None = None, +) -> str: + """Does the repo have this face's image? ``"available"`` / ``"not_found"`` + / ``"fetch_failed"``. With an ``inventory`` (one-shot repo listing) this + is a set lookup; without one it degrades to HEAD probes — existence only, + no body.""" + from urllib.parse import quote + + from core.ssrf import safe_request + + if inventory is not None: + for ext in ("png", "jpg"): + if f"{manufacturer}/{slug}.{face}.{ext}" in inventory: + return "available" + return "not_found" + for ext in ("png", "jpg"): + url = f"{image_base}/{quote(manufacturer)}/{quote(slug)}.{face}.{ext}" + try: + resp = safe_request("HEAD", url, timeout=5) + except Exception: # noqa: BLE001 — SSRF refusal / network trouble + return "fetch_failed" + if resp.status_code == 200: + return "available" + return "not_found" + + +def reimport_images_for_type(dt, image_base: str, *, overwrite: bool = False, + apply: bool = False, + inventory: set[str] | None = None) -> dict: + """Match one EXISTING DeviceType against a library-layout repo and, when + ``apply``, re-download its elevation images. + + Returns ``{"id", "name", "manufacturer", "slug", "status", "faces", + "downloaded"}``. ``status`` is ``matched`` (repo has images for it — on + apply, see per-face detail), ``no_match``, ``skipped_has_images`` (both + faces present *and their files exist on disk* — with ``overwrite`` off + there is nothing to do, so the repo isn't even probed), or + ``fetch_failed``. ``faces`` maps front/rear to ``kept`` / ``available`` / + ``downloaded`` / ``not_found`` / ``fetch_failed``. + + Fill-gaps is the default: a face is written only when the field is empty + or its file is missing from storage (``_face_missing``). ``overwrite`` + replaces intact images too. Network trouble on one face degrades to that + face's ``fetch_failed`` — never an exception out of here.""" + faces: dict[str, str] = {} + row = { + "id": str(dt.id), + "name": dt.name, + "manufacturer": dt.manufacturer.name if dt.manufacturer_id else "", + "slug": "", + "status": "", + "faces": faces, + "downloaded": 0, + } + todo = [f for f in REIMPORT_FACES if overwrite or _face_missing(dt, f)] + if not todo: + row["status"] = "skipped_has_images" + faces.update(dict.fromkeys(REIMPORT_FACES, "kept")) + return row + + mfr = row["manufacturer"] + candidates = candidate_slugs(dt) + if not mfr or not candidates: + # Library images live under a / dir — nothing to probe. + row["status"] = "no_match" + return row + + # Resolve THE slug once: the library names both faces with the same slug, + # so the first candidate with any face present wins. Memoised so the + # per-face report below doesn't re-probe. + probed: dict[tuple[str, str], str] = {} + + def probe(slug: str, face: str) -> str: + key = (slug, face) + if key not in probed: + probed[key] = _face_in_repo( + mfr, slug, face, image_base, inventory=inventory + ) + return probed[key] + + slug = None + for cand in candidates: + statuses = [] + for face in REIMPORT_FACES: + statuses.append(probe(cand, face)) + if statuses[-1] != "not_found": + break + if "fetch_failed" in statuses: + # Can't tell match from no-match while the repo is unreachable. + row["status"] = "fetch_failed" + return row + if "available" in statuses: + slug = cand + break + if slug is None: + row["status"] = "no_match" + return row + + row["slug"] = slug + row["status"] = "matched" + for face in REIMPORT_FACES: + if face not in todo: + faces[face] = "kept" + elif not apply: + faces[face] = probe(slug, face) + else: + pulled = _pull_elevation_image(dt, mfr, slug, face, image_base) + faces[face] = "downloaded" if pulled == "saved" else pulled + if pulled == "saved": + row["downloaded"] += 1 + return row def _err(message: str, name: str = "") -> dict: diff --git a/api/devicetype_import_tasks.py b/api/devicetype_import_tasks.py new file mode 100644 index 00000000..f0675cec --- /dev/null +++ b/api/devicetype_import_tasks.py @@ -0,0 +1,233 @@ +"""Background bulk import from the NetBox devicetype-library. + +A folder (a manufacturer, or the whole ``device-types`` dir — thousands of +files) is too much for the synchronous import-yaml endpoint, so it runs here +off the RQ ``low`` queue with pollable progress. Mirrors the NetBox import +run's shape (``integrations/netbox_tasks.py``).""" +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + +#: Keep at most this many per-file failures on the run (avoid unbounded JSON). +_MAX_FAILURES = 100 +#: Write progress to the DB every N files (not every one — one UPDATE per file +#: on a 1000-file run is a lot of needless writes). +_PROGRESS_EVERY = 5 + + +def run_devicetype_import(run_id: str) -> None: + """Expand the run's folder URL, fetch each YAML, import it, and record + progress. Never raises — failures land on the run so the worker survives.""" + from django.utils import timezone + + from core.ssrf import safe_get + + from .devicetype_import import expand_github_dir, import_yaml_auto + from .models import DeviceTypeImportRun + + run = DeviceTypeImportRun.objects.filter(pk=run_id).first() + if run is None: + # A worker that can't see the run is almost always the WRONG WORKER: + # two instances sharing one Redis DB race for each other's jobs, and + # the winner looks the id up in the wrong database. Returning silently + # here once turned that misconfiguration into "imports hang forever" — + # say it out loud so the next person greps it in minutes. + logger.warning( + "devicetype import run %s not found in this database — is another " + "instance's worker sharing this Redis queue (RQ_REDIS_DB)?", run_id + ) + return + run.status = "running" + run.started_at = timezone.now() + run.save(update_fields=["status", "started_at", "updated_at"]) + + created = failed = done = 0 + failures: list[dict] = [] + try: + files = expand_github_dir(run.source_url, safe_get) + total = len(files) + run.progress = {"done": 0, "total": total, "created": 0, "failed": 0} + run.save(update_fields=["progress", "updated_at"]) + + # One image-repo listing for the whole run: the importer then fetches + # only images that exist, at their known extension — instead of up to + # four guess-probes per type, which made big folders look stuck. + from .devicetype_import import _IMAGE_BASE, repo_image_inventory + + image_inventory = repo_image_inventory(_IMAGE_BASE) + for url in files: + try: + resp = safe_get(url, timeout=15) + resp.raise_for_status() + report = import_yaml_auto( + run.tenant, resp.text, + stack_positions=run.stack_positions, + owning_site=run.owning_site, + image_inventory=image_inventory, + ) + if report.get("ok"): + created += 1 + else: + failed += 1 + if len(failures) < _MAX_FAILURES: + failures.append({ + "name": report.get("name") or url, + "error": report.get("error") or "import failed", + }) + except Exception as exc: # noqa: BLE001 — record, keep going + failed += 1 + if len(failures) < _MAX_FAILURES: + failures.append({"name": url, "error": str(exc)}) + done += 1 + if done % _PROGRESS_EVERY == 0 or done == total: + run.progress = { + "done": done, "total": total, + "created": created, "failed": failed, + } + run.save(update_fields=["progress", "updated_at"]) + run.status = "success" + except Exception as exc: # noqa: BLE001 — the expand/list step blew up + logger.exception("devicetype import %s failed", run_id) + run.status = "failed" + run.error = str(exc) + finally: + run.failures = failures + run.finished_at = timezone.now() + run.save() + + +def enqueue_devicetype_import(tenant, url, *, stack, owning_site, user): + """Create a run and enqueue it on the ``low`` queue. Falls back to inline + execution when Redis is unavailable. Returns the run.""" + from .models import DeviceTypeImportRun + + run = DeviceTypeImportRun.objects.create( + tenant=tenant, source_url=url, stack_positions=stack, + owning_site=owning_site, created_by=user, status="queued", + ) + _enqueue(run_devicetype_import, run, "devicetype import") + return run + + +def _enqueue(task, run, label: str) -> None: + try: + import django_rq + + django_rq.get_queue("low").enqueue(task, str(run.id), job_timeout=3600) + except Exception: # noqa: BLE001 — Redis down: run inline so it still runs + logger.warning("RQ unavailable; running %s inline", label) + try: + task(str(run.id)) + except Exception: # noqa: BLE001 + logger.exception("inline %s failed", label) + + +def run_devicetype_image_reimport(run_id: str) -> None: + """Re-download elevation images for the run's in-scope EXISTING device + types (see ``reimport_images_for_type``) and record progress. Never + raises — failures land on the run so the worker survives.""" + from django.utils import timezone + + from .devicetype_import import ( + airgap_refusal, + reimport_images_for_type, + repo_image_inventory, + summarize_reimport, + ) + from .models import DeviceType, DeviceTypeImportRun + + run = DeviceTypeImportRun.objects.filter( + pk=run_id, kind="image_reimport" + ).first() + if run is None: + logger.warning( + "image reimport run %s not found in this database — is another " + "instance's worker sharing this Redis queue (RQ_REDIS_DB)?", run_id + ) + return + run.status = "running" + run.started_at = timezone.now() + run.save(update_fields=["status", "started_at", "updated_at"]) + + failures: list[dict] = [] + try: + # Re-check at run time — enqueue-time state is not trusted: the + # deployment may have been flipped to airgapped since. + refusal = airgap_refusal() + if refusal: + raise ValueError(refusal) + + # Re-derive scope at run time too. Tenant bounds the queryset; the + # creator's row-level `change` constraints are re-applied so a + # site-scoped editor's run can't touch types outside their grant. A + # deleted creator (SET_NULL) fails the run rather than widening it. + qs = DeviceType.objects.filter(tenant=run.tenant).select_related( + "manufacturer" + ).order_by("name") + user = run.created_by + if user is None: + raise ValueError("The user who started this run no longer exists.") + if not user.is_superuser: + from auth_api import rbac + + qs = rbac.restrict_queryset(qs, user, run.tenant, "devicetype", "change") + + opts = run.options or {} + overwrite = bool(opts.get("overwrite")) + apply = not bool(opts.get("dry_run")) + types = list(qs) + total = len(types) + # One repo listing up front (two requests) turns matching into + # in-memory lookups. None → non-GitHub mirror or listing trouble, + # and each type falls back to its own HEAD probes. + inventory = repo_image_inventory(run.source_url) + rows: list[dict] = [] + for i, dt in enumerate(types, start=1): + row = reimport_images_for_type( + dt, run.source_url, overwrite=overwrite, apply=apply, + inventory=inventory, + ) + rows.append(row) + # Only actionable rows go to `failures` — a big catalog's happy + # path (matched/skipped) lives in the totals. + if row["status"] in ("no_match", "fetch_failed") and ( + len(failures) < _MAX_FAILURES + ): + failures.append({ + "name": row["name"], + "error": "no matching image in the repository" + if row["status"] == "no_match" + else "image fetch failed (repository unreachable?)", + }) + if i % _PROGRESS_EVERY == 0 or i == total: + run.progress = { + "done": i, "total": total, **summarize_reimport(rows), + } + run.save(update_fields=["progress", "updated_at"]) + run.progress = {"done": total, "total": total, **summarize_reimport(rows)} + run.status = "success" + except Exception as exc: # noqa: BLE001 — scope/airgap refusal, DB trouble + logger.exception("devicetype image reimport %s failed", run_id) + run.status = "failed" + run.error = str(exc) + finally: + run.failures = failures + run.finished_at = timezone.now() + run.save() + + +def enqueue_devicetype_image_reimport(tenant, image_base, *, overwrite, + dry_run, user): + """Create an ``image_reimport`` run and enqueue it on the ``low`` queue + (inline fallback when Redis is down). Returns the run.""" + from .models import DeviceTypeImportRun + + run = DeviceTypeImportRun.objects.create( + tenant=tenant, kind="image_reimport", source_url=image_base, + options={"overwrite": bool(overwrite), "dry_run": bool(dry_run)}, + created_by=user, status="queued", + ) + _enqueue(run_devicetype_image_reimport, run, "devicetype image reimport") + return run diff --git a/api/management/commands/seed_dc_test.py b/api/management/commands/seed_dc_test.py new file mode 100644 index 00000000..a1ae9dcb --- /dev/null +++ b/api/management/commands/seed_dc_test.py @@ -0,0 +1,578 @@ +"""seed_dc_test — opt-in test hall for the 3D room view. + +Builds "DC-TEST": ten rows of ten cabinets in hot/cold aisle pairs, every +rack built from one rack type, stamped with A/B vertical PDUs, filled with +photo-faceplate gear, cabled inside and along each row, fed from two power +panels, and run under overhead tray. + +Opt-in and re-runnable, never touched by bootstrap. `--wipe` tears the hall +down first so a test run starts from nothing. + + manage.py seed_dc_test --wipe +""" +from __future__ import annotations + +from django.core.management.base import BaseCommand +from django.db import transaction + +from api.models import ( + Cable, + CableTermination, + Device, + DeviceRole, + DeviceType, + FloorPlan, + FloorPlanTile, + FloorPlanTray, + FloorTileType, + Interface, + Location, + Manufacturer, + PowerFeed, + PowerOutlet, + PowerOutletTemplate, + PowerPanel, + PowerPort, + PowerPortTemplate, + Rack, + RackType, + RackTypeAccessory, + Site, + materialize_device_components, +) +from api.pathfinding import route_through_trays +from core.models import Tenant + +TENANT_SLUG = "acme" +SITE_NAME = "DC-TEST" +LOCATION_NAME = "DC-TEST Hall" +PLAN_NAME = "DC-TEST" + +ROWS = "ABCDEFGHIJ" +PER_ROW = 10 +RACK_X0 = 2 # first rack column +MARGIN = 2 # perimeter walkway, 1200 mm + +# A rack is "front faces −Z" at orientation 0, and grid +y is +Z, so a row +# turns 180° to face DOWN the plan and 0° to face up. +FRONT_DOWN = 180 +FRONT_UP = 0 + +# Everything below is in 600 mm grid cells, and every distance is a REAL one. +# +# The first pass put rows one cell apart, which looked fine on the flat plan +# and was nonsense in the room: a 1200 mm-deep cabinet centred on a 600 mm +# tile overhangs 300 mm each side, so two rows a single cell apart TOUCH and +# the hall had no aisles at all. A rack tile is therefore two cells deep, and +# aisles get the widths they need to be walked: +RACK_CELLS = 2 # 1200 mm — the cabinet's actual depth +COLD_CELLS = 3 # 1800 mm — people install gear from the front here +HOT_CELLS = 2 # 1200 mm — access only, so the tighter standard minimum + + +def _layout(): + """Rack rows and aisles down the hall, in facing pairs. + + Fronts look at each other across a COLD aisle; the backs of adjacent + pairs vent into a shared HOT aisle. Returns (rows, colds, hots, height) + where rows is [(row letter, y, orientation)]. + """ + rows, colds, hots = [], [], [] + y = MARGIN + pairs = len(ROWS) // 2 + for p in range(pairs): + # Top of the pair looks DOWN (+y) into the cold aisle beneath it… + rows.append((ROWS[p * 2], y, FRONT_DOWN)) + y += RACK_CELLS + colds.append(y) + y += COLD_CELLS + # …and its partner looks UP (−y) into the same aisle. + rows.append((ROWS[p * 2 + 1], y, FRONT_UP)) + y += RACK_CELLS + if p < pairs - 1: + hots.append(y) + y += HOT_CELLS + return rows, colds, hots, y + MARGIN + + +GRID_W = RACK_X0 + PER_ROW + MARGIN + +PDU_NAME = "DC-TEST Vertical PDU 24×C13" +PDU_OUTLETS = 24 +RACK_TYPE_NAME = "DC-TEST 42U" +# 600 mm wide = EXACTLY one grid cell, so cabinets bay flush the way they do +# in a real row. The 800 mm variant of the first pass overhung its 600 mm +# tile by 100 mm a side, which collided every neighbour by 200 mm — the same +# mistake as the depth one, on the other axis. A 600 mm cabinet still leaves +# ~63 mm of zero-U channel each side, enough for the 50 mm PDU strips. +OUTER_W_MM = 600 +OUTER_D_MM = 1200 + +FW_TYPE = "PA-3420" # 1U, photo faceplate +SRV_TYPE = "System x3650 M5" # 2U, photo faceplate + +# A FULL cabinet, because half-empty racks tell you nothing about the room: +# a redundant pair of 1U firewalls on top, then 2U servers all the way down. +# 42U = 2×1U + 20×2U with nothing left over. +FW_US = (42, 41) +SRV_US = tuple(range(39, 0, -2)) # 39, 37 … 1 — twenty 2U servers + + +class Command(BaseCommand): + help = "Seed the DC-TEST hall: 100 racks, PDUs, cabling, power and trays." + + def add_arguments(self, parser): + parser.add_argument( + "--wipe", + action="store_true", + help="Delete the existing DC-TEST hall first (site, racks, " + "devices, plan) so the run starts clean.", + ) + parser.add_argument( + "--full-cabling", + action="store_true", + help="Cable EVERY device (~4500 cables) instead of a " + "representative set — for stressing the cables layer.", + ) + parser.add_argument("--tenant", default=TENANT_SLUG) + + @transaction.atomic + def handle(self, *args, **opts): + tenant = Tenant.objects.filter(slug=opts["tenant"]).first() + if tenant is None: + self.stderr.write(f"No tenant with slug {opts['tenant']!r}.") + return + self.t = tenant + + if opts["wipe"]: + self._wipe() + + site, loc = self._place() + rack_type = self._rack_type() + plan = self._plan(loc) + racks = self._racks(site, loc, rack_type, plan) + devices = self._devices(site, loc, racks) + self._power(site, racks) + # Trays before cabling: a run can only be pinned to a tray that + # already exists, and the whole point of the overhead tray is that the + # cross-hall runs follow it instead of flying through the cabinets. + self._trays(plan) + self._cable(plan, racks, devices, full=opts['full_cabling']) + + self.stdout.write(self.style.SUCCESS( + f"DC-TEST ready — {len(racks)} racks, " + f"{Device.objects.filter(site=site).count()} devices, " + f"{Cable.objects.filter(tenant=tenant, label__startswith='DCT').count()} cables. " + f"Open /floorplans/{plan.id}?viz=3d" + )) + + # ── teardown ───────────────────────────────────────────────────────── + def _wipe(self): + site = Site.objects.filter(tenant=self.t, name=SITE_NAME).first() + if site is None: + return + Cable.objects.filter( + tenant=self.t, label__startswith="DCT" + ).delete() + FloorPlan.objects.filter(tenant=self.t, name=PLAN_NAME).delete() + Device.objects.filter(site=site).delete() + PowerFeed.objects.filter(power_panel__site=site).delete() + PowerPanel.objects.filter(site=site).delete() + Rack.objects.filter(site=site).delete() + Location.objects.filter(site=site).delete() + site.delete() + self.stdout.write("wiped the previous DC-TEST hall") + + # ── catalog + place ────────────────────────────────────────────────── + def _place(self): + site, _ = Site.objects.get_or_create(tenant=self.t, name=SITE_NAME) + loc, _ = Location.objects.get_or_create( + tenant=self.t, site=site, slug="dc-test-hall", + defaults={"name": LOCATION_NAME}, + ) + return site, loc + + def _pdu_type(self): + """A 0U vertical PDU: one inlet, 24 switched C13 outlets.""" + mfr, _ = Manufacturer.objects.get_or_create( + tenant=self.t, name="Danbyte Test Gear", + defaults={"slug": "danbyte-test-gear"}, + ) + dt, made = DeviceType.objects.get_or_create( + tenant=self.t, name=PDU_NAME, + defaults={ + "manufacturer": mfr, + "u_height": 0, # 0U → side-mountable + "is_full_depth": False, + "exclude_from_utilization": True, + }, + ) + if made or dt.power_outlet_templates.count() != PDU_OUTLETS: + dt.power_outlet_templates.all().delete() + PowerOutletTemplate.objects.bulk_create([ + PowerOutletTemplate(device_type=dt, name=f"C13-{i:02d}") + for i in range(1, PDU_OUTLETS + 1) + ]) + if not dt.power_port_templates.exists(): + PowerPortTemplate.objects.create( + device_type=dt, name="inlet", + maximum_draw=7360, allocated_draw=3000, + ) + return dt + + def _fix_psu_names(self, dt): + """Rename 0-based PSU templates to the 1-based pair operators use. + + The imported library types came in with `Psu 0 / Psu 1 / Psu 2` — a + name range expanded from zero, so a dual-PSU server claimed three + inlets and the first one was called PSU zero. Targeted on purpose: it + only rewrites a type whose names actually show the 0-based pattern, so + a hand-curated type is never clobbered. Runs before the devices are + stamped, so their power ports come out right the first time. + """ + ports = list(dt.power_port_templates.order_by("name")) + if not any(p.name.strip().lower() in ("psu 0", "psu0") for p in ports): + return + keep = ports[0] + draw = keep.maximum_draw + alloc = keep.allocated_draw + dt.power_port_templates.all().delete() + PowerPortTemplate.objects.bulk_create([ + PowerPortTemplate( + device_type=dt, name=f"PSU {i}", + maximum_draw=draw, allocated_draw=alloc, + ) + for i in (1, 2) + ]) + self.stdout.write(f" {dt.name}: PSU templates renumbered to 1-2") + + def _rack_type(self): + pdu = self._pdu_type() + rt, _ = RackType.objects.update_or_create( + tenant=self.t, name=RACK_TYPE_NAME, + defaults={ + "u_height": 42, + "width": 19, + "starting_unit": 1, + "desc_units": False, + "outer_width_mm": OUTER_W_MM, + "outer_depth_mm": OUTER_D_MM, + "max_weight": 1200, + "max_weight_unit": "kg", + "description": "Test cabinet: 42U, 600 mm wide so a row bays " + "flush; the rear channels take the PDUs.", + }, + ) + # A and B strips, rear channel, one on each rail. + for label, mount in (("PDU-A", "side_left"), ("PDU-B", "side_right")): + RackTypeAccessory.objects.update_or_create( + rack_type=rt, label=label, + defaults={ + "device_type": pdu, + "mount": mount, + "face": "rear", + "mount_offset_mm": 120, + "mount_span_u": 40, + }, + ) + return rt + + def _plan(self, loc): + *_, height = _layout() + plan, _ = FloorPlan.objects.update_or_create( + tenant=self.t, location=loc, name=PLAN_NAME, + defaults={ + "grid_width": GRID_W, + "grid_height": height, + "cell_mm": 600, + "ceiling_mm": 3200, + }, + ) + return plan + + # ── racks + tiles + aisles ─────────────────────────────────────────── + def _tile_types(self): + rack_tt, _ = FloorTileType.objects.get_or_create( + tenant=self.t, slug="rack", + defaults={"name": "Rack", "color": "#3b82f6"}, + ) + cold, _ = FloorTileType.objects.get_or_create( + tenant=self.t, slug="dct-cold-aisle", + defaults={ + "name": "Cold aisle", "color": "#3b82f6", + "is_zone": True, "perforated": True, + }, + ) + hot, _ = FloorTileType.objects.get_or_create( + tenant=self.t, slug="dct-hot-aisle", + defaults={"name": "Hot aisle", "color": "#dc2626", "is_zone": True}, + ) + return rack_tt, cold, hot + + def _racks(self, site, loc, rack_type, plan): + rack_tt, cold, hot = self._tile_types() + plan.tiles.all().delete() + racks: dict[str, Rack] = {} + + rows, colds, hots, _ = _layout() + for row, y, facing in rows: + for i in range(PER_ROW): + name = f"DCT-{row}{i + 1:02d}" + rack, _ = Rack.objects.update_or_create( + tenant=self.t, name=name, + defaults={ + "site": site, + "location": loc, + "rack_type": rack_type, + "u_height": 42, + "width": 19, + "starting_unit": 1, + "outer_width_mm": OUTER_W_MM, + "outer_depth_mm": OUTER_D_MM, + "max_weight": 1200, + "max_weight_unit": "kg", + "facility_id": f"{row}{i + 1:02d}", + }, + ) + racks[name] = rack + FloorPlanTile.objects.create( + floor_plan=plan, tile_type=rack_tt, + # Two cells deep: the tile has to match the 1200 mm + # cabinet, or the rack overhangs into the aisle. + x=RACK_X0 + i, y=y, width=1, height=RACK_CELLS, + orientation=facing, rack=rack, link_kind="rack", + label=name, + ) + + # Aisles at their real widths: cold inside each facing pair, hot + # between pairs. These are walkable spans, not one-cell slivers. + for n, y in enumerate(colds): + FloorPlanTile.objects.create( + floor_plan=plan, tile_type=cold, + x=RACK_X0, y=y, width=PER_ROW, height=COLD_CELLS, + label=f"Cold {ROWS[n * 2]}/{ROWS[n * 2 + 1]}", + ) + for n, y in enumerate(hots): + FloorPlanTile.objects.create( + floor_plan=plan, tile_type=hot, + x=RACK_X0, y=y, width=PER_ROW, height=HOT_CELLS, + label=f"Hot {ROWS[n * 2 + 1]}/{ROWS[n * 2 + 2]}", + ) + return racks + + # ── devices ────────────────────────────────────────────────────────── + def _role(self, name, color): + role, _ = DeviceRole.objects.get_or_create( + tenant=self.t, slug=f"dct-{name.lower()}", + defaults={"name": name, "color": color}, + ) + return role + + def _devices(self, site, loc, racks): + fw_type = DeviceType.objects.get(tenant=self.t, name=FW_TYPE) + srv_type = DeviceType.objects.get(tenant=self.t, name=SRV_TYPE) + self._fix_psu_names(srv_type) + self._fix_psu_names(fw_type) + fw_role = self._role("Firewall", "#ef4444") + srv_role = self._role("Server", "#10b981") + + out: dict[str, dict] = {} + for name, rack in racks.items(): + made = {"fw": [], "srv": [], "pdu": []} + for n, u in enumerate(FW_US, start=1): + fw, _ = Device.objects.update_or_create( + tenant=self.t, name=f"{name}-fw{n}", + defaults={ + "site": site, "location": loc, "rack": rack, + "device_type": fw_type, "role": fw_role, + "position": u, "face": "front", + }, + ) + materialize_device_components(fw) + made["fw"].append(fw) + for n, u in enumerate(SRV_US, start=1): + srv, _ = Device.objects.update_or_create( + tenant=self.t, name=f"{name}-srv{n}", + defaults={ + "site": site, "location": loc, "rack": rack, + "device_type": srv_type, "role": srv_role, + "position": u, "face": "front", + }, + ) + materialize_device_components(srv) + made["srv"].append(srv) + # The rack type's A/B strips. + for label, mount in (("PDU-A", "side_left"), ("PDU-B", "side_right")): + pdu, _ = Device.objects.update_or_create( + tenant=self.t, name=f"{name}-{label}", + defaults={ + "site": site, "location": loc, "rack": rack, + "device_type": self._pdu_type(), + "role": self._role("PDU", "#f59e0b"), + "mount": mount, "face": "rear", + "mount_offset_mm": 120, "mount_span_u": 40, + }, + ) + materialize_device_components(pdu) + made["pdu"].append(pdu) + out[name] = made + return out + + # ── power ──────────────────────────────────────────────────────────── + def _power(self, site, racks): + panels = {} + for side in ("A", "B"): + panels[side], _ = PowerPanel.objects.get_or_create( + tenant=self.t, site=site, name=f"DC-TEST Panel {side}", + ) + for name, rack in racks.items(): + for side in ("A", "B"): + PowerFeed.objects.update_or_create( + tenant=self.t, power_panel=panels[side], + name=f"{name}-{side}", + defaults={ + "rack": rack, "type": "primary", "supply": "ac", + "phase": "single", "voltage": 230, "amperage": 32, + "max_utilization": 80, + }, + ) + + # ── cabling ────────────────────────────────────────────────────────── + def _link(self, label, kind, colour, a, b): + """One cable between two components, idempotent on its label.""" + if a is None or b is None: + return None + if Cable.objects.filter(tenant=self.t, label=label).exists(): + return None + cable = Cable.objects.create( + tenant=self.t, label=label, type=kind, color=colour, + ) + for end, point in (("A", a), ("B", b)): + field = { + Interface: "interface", + PowerPort: "power_port", + PowerOutlet: "power_outlet", + PowerFeed: "power_feed", + }[type(point)] + CableTermination.objects.create( + cable=cable, end=end, **{field: point} + ) + return cable + + def _cable(self, plan, racks, devices, full=False): + """Wire the hall. + + A full cabinet holds 22 devices, so cabling every port would mint + ~4500 cables — past the point where the room draws tubes at all and + slow to resolve. The default is a REPRESENTATIVE set: both firewalls + and two servers powered A+B, four data drops, both feeds, plus the + row chains. `--full-cabling` wires every device for stress testing. + """ + for name in sorted(racks): + d = devices[name] + fws, srvs, pdus = d["fw"], d["srv"], d["pdu"] + banks = [list(p.power_outlets.order_by("name")) for p in pdus] + + # Data: firewalls down into the servers below them. + drops = srvs if full else srvs[:4] + fw_ports = [ + list(fw.interfaces.order_by("name")) for fw in fws + ] + for n, srv in enumerate(drops): + fw_i = n % len(fws) + port = srv.interfaces.filter(name="Ethernet 1").first() + ports = fw_ports[fw_i] + slot = n // len(fws) + if slot < len(ports): + self._link( + f"DCT {srv.name} uplink", "cat6", "#0ea5e9", + ports[slot], port, + ) + + # Power: A and B cords, so each device has real redundancy. + powered = [*fws, *(srvs if full else srvs[:2])] + for slot, dev in enumerate(powered): + dev_ports = list(dev.power_ports.order_by("name")) + for j, port in enumerate(dev_ports[:2]): + bank = banks[j] if j < len(banks) else [] + if slot < len(bank): + self._link( + f"DCT {dev.name} psu{j + 1}", "power", "#f59e0b", + port, bank[slot], + ) + + # Power: each strip's inlet back to its own feed. + for side, pdu in zip(("A", "B"), pdus): + inlet = pdu.power_ports.filter(name="inlet").first() + feed = PowerFeed.objects.filter( + tenant=self.t, name=f"{name}-{side}" + ).first() + self._link( + f"DCT {name} {side} feed", "power", "#dc2626", inlet, feed + ) + + # Data: chain each row rack-to-rack, so runs cross the hall and have + # to follow the tray rather than hop straight through the cabinets. + trays = list(plan.trays.all()) + cells = { + t.rack.name: (t.x + t.width / 2, t.y + t.height / 2) + for t in plan.tiles.select_related("rack").filter( + rack__isnull=False + ) + } + routed = 0 + for row in ROWS: + for i in range(PER_ROW - 1): + a = devices[f"DCT-{row}{i + 1:02d}"]["fw"][0] + b = devices[f"DCT-{row}{i + 2:02d}"]["fw"][0] + pa = a.interfaces.filter(name__endswith="/24").first() + pb = b.interfaces.filter(name__endswith="/23").first() + cable = self._link( + f"DCT row {row} {i + 1}→{i + 2}", "smf-os2", "#facc15", + pa, pb, + ) + if cable is not None: + routed += self._route( + cable, + cells.get(f"DCT-{row}{i + 1:02d}"), + cells.get(f"DCT-{row}{i + 2:02d}"), + trays, + ) + self.stdout.write(f" {routed} row runs pinned to the tray") + + def _route(self, cable, a, b, trays): + """Pin a run to the trays it actually follows, through the SAME + Dijkstra the auto-route endpoint uses. Without this every seeded cable + is point-to-point and the 3D room draws it arcing over the cabinets in + free air, with the tray sitting there unused.""" + if a is None or b is None or not trays: + return 0 + result = route_through_trays(a, b, [t.points for t in trays]) + if not result.reachable: + return 0 + cable.trays.add(*[trays[i] for i in result.tray_indexes]) + return 1 + + # ── trays ──────────────────────────────────────────────────────────── + def _trays(self, plan): + plan.trays.all().delete() + rows, _, _, _ = _layout() + x0, x1 = RACK_X0, RACK_X0 + PER_ROW + spine_x = x0 - 1 + # A spine down the west margin, and a branch over the centre of every + # rack row (a rack tile is RACK_CELLS deep, so + half of that). + FloorPlanTray.objects.create( + floor_plan=plan, name="Spine", kind="ladder", color="#eab308", + level="overhead", elevation_mm=2900, + points=[ + [spine_x, rows[0][1]], + [spine_x, rows[-1][1] + RACK_CELLS / 2], + ], + ) + for row, y, _facing in rows: + mid = y + RACK_CELLS / 2 + FloorPlanTray.objects.create( + floor_plan=plan, name=f"Row {row}", kind="ladder", + color="#eab308", level="overhead", elevation_mm=2700, + points=[[spine_x, mid], [x1, mid]], + ) diff --git a/api/migrations/0094_floorplan_ceiling_mm_floorplan_cell_mm_and_more.py b/api/migrations/0094_floorplan_ceiling_mm_floorplan_cell_mm_and_more.py new file mode 100644 index 00000000..8c20496b --- /dev/null +++ b/api/migrations/0094_floorplan_ceiling_mm_floorplan_cell_mm_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.15 on 2026-07-22 13:13 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0093_modulebaytemplate_default_module_type'), + ] + + operations = [ + migrations.AddField( + model_name='floorplan', + name='ceiling_mm', + field=models.PositiveSmallIntegerField(default=3000, help_text='Room ceiling height, in millimetres.', validators=[django.core.validators.MinValueValidator(1000), django.core.validators.MaxValueValidator(20000)]), + ), + migrations.AddField( + model_name='floorplan', + name='cell_mm', + field=models.PositiveSmallIntegerField(default=600, help_text='Physical size of one grid cell, in millimetres.', validators=[django.core.validators.MinValueValidator(50), django.core.validators.MaxValueValidator(5000)]), + ), + migrations.AddField( + model_name='floorplantray', + name='elevation_mm', + field=models.IntegerField(blank=True, help_text='Height above finished floor in millimetres (negative = below the raised floor). Blank derives from the level: overhead → ceiling − 300, underfloor → −300, floor → 0.', null=True, validators=[django.core.validators.MinValueValidator(-2000), django.core.validators.MaxValueValidator(20000)]), + ), + migrations.AddField( + model_name='floorplantray', + name='level', + field=models.CharField(choices=[('overhead', 'Overhead'), ('underfloor', 'Underfloor'), ('floor', 'Floor level')], default='overhead', max_length=16), + ), + migrations.AddField( + model_name='rack', + name='outer_depth_mm', + field=models.PositiveSmallIntegerField(blank=True, help_text='Cabinet outer depth in millimetres (blank = 1000).', null=True, validators=[django.core.validators.MinValueValidator(100), django.core.validators.MaxValueValidator(3000)]), + ), + migrations.AddField( + model_name='rack', + name='outer_width_mm', + field=models.PositiveSmallIntegerField(blank=True, help_text='Cabinet outer width in millimetres (blank = derived).', null=True, validators=[django.core.validators.MinValueValidator(100), django.core.validators.MaxValueValidator(2000)]), + ), + ] diff --git a/api/migrations/0095_devicetypeimportrun.py b/api/migrations/0095_devicetypeimportrun.py new file mode 100644 index 00000000..0159aa89 --- /dev/null +++ b/api/migrations/0095_devicetypeimportrun.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.15 on 2026-07-22 15:16 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0094_floorplan_ceiling_mm_floorplan_cell_mm_and_more'), + ('core', '0031_deploymentsettings_rq_workers'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='DeviceTypeImportRun', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('source_url', models.CharField(max_length=512)), + ('stack_positions', models.BooleanField(default=False)), + ('status', models.CharField(choices=[('queued', 'Queued'), ('running', 'Running'), ('success', 'Success'), ('failed', 'Failed')], default='queued', max_length=16)), + ('progress', models.JSONField(blank=True, default=dict)), + ('failures', models.JSONField(blank=True, default=list)), + ('error', models.TextField(blank=True, default='')), + ('started_at', models.DateTimeField(blank=True, null=True)), + ('finished_at', models.DateTimeField(blank=True, null=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('owning_site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='api.site')), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devicetype_imports', to='core.tenant')), + ], + options={ + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['tenant', '-created_at'], name='api_devicet_tenant__affd17_idx')], + }, + ), + ] diff --git a/api/migrations/0096_devicetype_image_ports.py b/api/migrations/0096_devicetype_image_ports.py new file mode 100644 index 00000000..05c18d74 --- /dev/null +++ b/api/migrations/0096_devicetype_image_ports.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.15 on 2026-07-22 15:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0095_devicetypeimportrun'), + ] + + operations = [ + migrations.AddField( + model_name='devicetype', + name='image_ports', + field=models.JSONField(blank=True, default=None, help_text='Port markers anchored on the front/rear photo: {front:[{kind,name,x,y,w,h}], rear:[...]} with x/y/w/h normalized 0..1 (center-anchored). Rendered over the image in 2D and on the device face in 3D. Null = none placed.', null=True), + ), + ] diff --git a/api/migrations/0097_inventoryitem_capacity_bytes_inventoryitem_kind_and_more.py b/api/migrations/0097_inventoryitem_capacity_bytes_inventoryitem_kind_and_more.py new file mode 100644 index 00000000..dacb6535 --- /dev/null +++ b/api/migrations/0097_inventoryitem_capacity_bytes_inventoryitem_kind_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 5.2.15 on 2026-07-24 13:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0096_devicetype_image_ports'), + ] + + operations = [ + migrations.AddField( + model_name='inventoryitem', + name='capacity_bytes', + field=models.PositiveBigIntegerField(blank=True, help_text='Capacity in BYTES (unit-agnostic: KB floppies to PB arrays; the UI converts).', null=True), + ), + migrations.AddField( + model_name='inventoryitem', + name='kind', + field=models.CharField(choices=[('other', 'Other'), ('disk', 'Disk'), ('cpu', 'CPU'), ('ram', 'RAM'), ('psu', 'PSU'), ('fan', 'Fan'), ('gpu', 'GPU'), ('controller', 'Controller'), ('transceiver', 'Transceiver')], default='other', max_length=16), + ), + migrations.AddField( + model_name='inventoryitem', + name='media', + field=models.CharField(blank=True, choices=[('', '—'), ('nvme', 'NVMe'), ('ssd', 'SSD (SATA/SAS)'), ('hdd', 'HDD'), ('tape', 'Tape')], default='', max_length=16), + ), + migrations.AddField( + model_name='inventoryitem', + name='speed', + field=models.CharField(blank=True, default='', help_text='Free-form: "7.2K RPM", "PCIe 4.0 x4", "3200 MT/s".', max_length=64), + ), + migrations.AddField( + model_name='inventoryitem', + name='status', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inventory_items', to='api.status'), + ), + migrations.AddField( + model_name='inventoryitemtemplate', + name='capacity_bytes', + field=models.PositiveBigIntegerField(blank=True, help_text='Capacity in BYTES (unit-agnostic: KB floppies to PB arrays; the UI converts).', null=True), + ), + migrations.AddField( + model_name='inventoryitemtemplate', + name='kind', + field=models.CharField(choices=[('other', 'Other'), ('disk', 'Disk'), ('cpu', 'CPU'), ('ram', 'RAM'), ('psu', 'PSU'), ('fan', 'Fan'), ('gpu', 'GPU'), ('controller', 'Controller'), ('transceiver', 'Transceiver')], default='other', max_length=16), + ), + migrations.AddField( + model_name='inventoryitemtemplate', + name='media', + field=models.CharField(blank=True, choices=[('', '—'), ('nvme', 'NVMe'), ('ssd', 'SSD (SATA/SAS)'), ('hdd', 'HDD'), ('tape', 'Tape')], default='', max_length=16), + ), + migrations.AddField( + model_name='inventoryitemtemplate', + name='speed', + field=models.CharField(blank=True, default='', help_text='Free-form: "7.2K RPM", "PCIe 4.0 x4", "3200 MT/s".', max_length=64), + ), + ] diff --git a/api/migrations/0098_seed_inventoryitem_statuses.py b/api/migrations/0098_seed_inventoryitem_statuses.py new file mode 100644 index 00000000..8043e660 --- /dev/null +++ b/api/migrations/0098_seed_inventoryitem_statuses.py @@ -0,0 +1,25 @@ +# Extend every tenant's built-in Status catalog with the inventory-item +# lifecycle values (active / planned / failed / spare). Required system data: +# the hardware-health UI colours parts by status, so the kind must resolve. +# Idempotent — seed_builtin_statuses merges into existing rows by slug. +from django.db import migrations + + +def seed(apps, schema_editor): + from api.status_registry import seed_builtin_statuses + + Tenant = apps.get_model("core", "Tenant") + Status = apps.get_model("api", "Status") + for tenant in Tenant.objects.all(): + seed_builtin_statuses(tenant, Status=Status) + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0097_inventoryitem_capacity_bytes_inventoryitem_kind_and_more"), + ] + + operations = [ + migrations.RunPython(seed, migrations.RunPython.noop), + ] diff --git a/api/migrations/0099_natural_sort_collation.py b/api/migrations/0099_natural_sort_collation.py new file mode 100644 index 00000000..26565c4d --- /dev/null +++ b/api/migrations/0099_natural_sort_collation.py @@ -0,0 +1,20 @@ +# A PostgreSQL ICU collation with NUMERIC ordering ("kn"): "disk2" sorts +# before "disk10". Applied via Collate(...) in list orderings so component +# names read in human order everywhere. +from django.contrib.postgres.operations import CreateCollation +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0098_seed_inventoryitem_statuses"), + ] + + operations = [ + CreateCollation( + "natural_sort", + provider="icu", + locale="und-u-kn-true", + ), + ] diff --git a/api/migrations/0100_interface_snmp_name.py b/api/migrations/0100_interface_snmp_name.py new file mode 100644 index 00000000..5e4cf85e --- /dev/null +++ b/api/migrations/0100_interface_snmp_name.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.15 on 2026-07-24 20:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0099_natural_sort_collation'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='snmp_name', + field=models.CharField(blank=True, default='', help_text="What the agent calls this interface over SNMP (ifName / ifDescr) when it differs from the label — e.g. the port silkscreened 'Ethernet 1' reporting as 'eth0'. Set it and discovery stops reporting the pair as both new and missing.", max_length=128), + ), + ] diff --git a/api/migrations/0101_interface_snmp_ignore.py b/api/migrations/0101_interface_snmp_ignore.py new file mode 100644 index 00000000..442aef2f --- /dev/null +++ b/api/migrations/0101_interface_snmp_ignore.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.15 on 2026-07-24 23:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0100_interface_snmp_name'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='snmp_ignore', + field=models.BooleanField(default=False, help_text="Exclude this interface from SNMP drift. For ports the polled agent can never report — silkscreened ports a BMC doesn't see, out-of-band jacks — which would otherwise flag as 'not seen on device' after every poll, forever."), + ), + ] diff --git a/api/migrations/0102_frontport_description_interface_description_and_more.py b/api/migrations/0102_frontport_description_interface_description_and_more.py new file mode 100644 index 00000000..4b01d7c8 --- /dev/null +++ b/api/migrations/0102_frontport_description_interface_description_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.15 on 2026-07-25 00:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0101_interface_snmp_ignore'), + ] + + operations = [ + migrations.AddField( + model_name='frontport', + name='description', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='interface', + name='description', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='rearport', + name='description', + field=models.CharField(blank=True, default='', max_length=255), + ), + ] diff --git a/api/migrations/0103_devicetypeimportrun_kind_options.py b/api/migrations/0103_devicetypeimportrun_kind_options.py new file mode 100644 index 00000000..b2f75dd1 --- /dev/null +++ b/api/migrations/0103_devicetypeimportrun_kind_options.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.15 on 2026-07-25 14:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0102_frontport_description_interface_description_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='devicetypeimportrun', + name='kind', + field=models.CharField(choices=[('library', 'Library import'), ('image_reimport', 'Image reimport')], default='library', max_length=16), + ), + migrations.AddField( + model_name='devicetypeimportrun', + name='options', + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/api/migrations/0104_floorplan_raised_floor_areas.py b/api/migrations/0104_floorplan_raised_floor_areas.py new file mode 100644 index 00000000..0d252d99 --- /dev/null +++ b/api/migrations/0104_floorplan_raised_floor_areas.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.15 on 2026-07-25 19:43 + +import django.core.validators +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0103_devicetypeimportrun_kind_options'), + ] + + operations = [ + migrations.AlterField( + model_name='floorplantray', + name='elevation_mm', + field=models.IntegerField(blank=True, help_text='Height above finished floor in millimetres (negative = below the raised floor). Blank derives from the level: overhead → ceiling − 300, underfloor → the plenum of the raised-floor area beneath the run (default 300), floor → 0.', null=True, validators=[django.core.validators.MinValueValidator(-2000), django.core.validators.MaxValueValidator(20000)]), + ), + migrations.CreateModel( + name='FloorPlanRaisedFloorArea', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('x', models.PositiveSmallIntegerField()), + ('y', models.PositiveSmallIntegerField()), + ('width', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1)])), + ('height', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1)])), + ('plenum_mm', models.PositiveSmallIntegerField(default=300, help_text='Void depth under the finished floor — how far below 0 the structural slab sits here.', validators=[django.core.validators.MinValueValidator(50), django.core.validators.MaxValueValidator(2000)])), + ('label', models.CharField(blank=True, default='', max_length=64)), + ('color', models.CharField(blank=True, default='', max_length=7)), + ('floor_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='raised_floor_areas', to='api.floorplan')), + ], + options={ + 'ordering': ['y', 'x'], + 'indexes': [models.Index(fields=['floor_plan'], name='api_floorpl_floor_p_c1423a_idx')], + }, + ), + ] diff --git a/api/migrations/0105_floorplanwall.py b/api/migrations/0105_floorplanwall.py new file mode 100644 index 00000000..eacf811c --- /dev/null +++ b/api/migrations/0105_floorplanwall.py @@ -0,0 +1,34 @@ +# Hand-written (the makemigrations gate verifies parity with the model). + +import django.core.validators +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0104_floorplan_raised_floor_areas'), + ] + + operations = [ + migrations.CreateModel( + name='FloorPlanWall', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('label', models.CharField(blank=True, default='', max_length=64)), + ('points', models.JSONField(default=list)), + ('height_mm', models.PositiveSmallIntegerField(blank=True, help_text="Blank = full height (the plan's ceiling).", null=True, validators=[django.core.validators.MinValueValidator(200), django.core.validators.MaxValueValidator(20000)])), + ('color', models.CharField(blank=True, default='', max_length=7)), + ('openings', models.JSONField(blank=True, default=list)), + ('floor_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='walls', to='api.floorplan')), + ], + options={ + 'ordering': ['label'], + 'indexes': [models.Index(fields=['floor_plan'], name='api_floorpl_floor_p_9f31cd_idx')], + }, + ), + ] diff --git a/api/migrations/0106_floortiletype_perforated.py b/api/migrations/0106_floortiletype_perforated.py new file mode 100644 index 00000000..f23f245d --- /dev/null +++ b/api/migrations/0106_floortiletype_perforated.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.15 on 2026-07-26 01:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0105_floorplanwall'), + ] + + operations = [ + migrations.AddField( + model_name='floortiletype', + name='perforated', + field=models.BooleanField(default=False, help_text='Zone tiles of this type render as perforated/grate floor in the 3D room — the cold-aisle supply-tile read.'), + ), + ] diff --git a/api/migrations/0107_device_side_mount.py b/api/migrations/0107_device_side_mount.py new file mode 100644 index 00000000..bcd46131 --- /dev/null +++ b/api/migrations/0107_device_side_mount.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.15 on 2026-07-26 08:41 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0106_floortiletype_perforated'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='mount', + field=models.CharField(blank=True, choices=[('side_left', 'Left rail'), ('side_right', 'Right rail')], default='', help_text='Zero-U side mounting: the rack rail this device bolts to instead of occupying units. Requires a rack, a 0U device type, and no U position/face/side.', max_length=12), + ), + migrations.AddField( + model_name='device', + name='mount_offset_mm', + field=models.PositiveSmallIntegerField(blank=True, help_text='Bottom of the side-mounted strip above the base plate.', null=True), + ), + migrations.AddField( + model_name='device', + name='mount_span_u', + field=models.PositiveSmallIntegerField(blank=True, help_text='Vertical extent of the side-mounted strip, in U. Blank draws ~three quarters of the rack.', null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(60)]), + ), + ] diff --git a/api/migrations/0108_rack_types.py b/api/migrations/0108_rack_types.py new file mode 100644 index 00000000..913108bf --- /dev/null +++ b/api/migrations/0108_rack_types.py @@ -0,0 +1,74 @@ +# Generated by Django 5.2.15 on 2026-07-26 09:04 + +import django.core.validators +import django.db.models.deletion +import taggit.managers +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0107_device_side_mount'), + ('core', '0031_deploymentsettings_rq_workers'), + ] + + operations = [ + migrations.CreateModel( + name='RackType', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('numid', models.PositiveIntegerField(blank=True, db_index=True, editable=False, help_text='Per-tenant human-readable number (see NumIdSequence).', null=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=128)), + ('width', models.PositiveSmallIntegerField(choices=[(10, '10"'), (19, '19"'), (21, '21"'), (23, '23"')], default=19, help_text='Rail-to-rail width, inches.')), + ('u_height', models.PositiveSmallIntegerField(default=42, help_text='Height in rack units (U).')), + ('starting_unit', models.PositiveSmallIntegerField(default=1, help_text='Number of the bottom unit.')), + ('desc_units', models.BooleanField(default=False, help_text='Number units top-to-bottom instead of bottom-up.')), + ('outer_width_mm', models.PositiveSmallIntegerField(blank=True, help_text='Cabinet outer width in millimetres (blank = derived).', null=True, validators=[django.core.validators.MinValueValidator(100), django.core.validators.MaxValueValidator(2000)])), + ('outer_depth_mm', models.PositiveSmallIntegerField(blank=True, help_text='Cabinet outer depth in millimetres (blank = 1000).', null=True, validators=[django.core.validators.MinValueValidator(100), django.core.validators.MaxValueValidator(3000)])), + ('max_weight', models.DecimalField(blank=True, decimal_places=2, help_text='Load budget this cabinet model is rated for.', max_digits=8, null=True)), + ('max_weight_unit', models.CharField(blank=True, choices=[('kg', 'kg'), ('g', 'g'), ('lb', 'lb'), ('oz', 'oz')], default='', max_length=8)), + ('description', models.TextField(blank=True)), + ('manufacturer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rack_types', to='api.manufacturer')), + ('tags', taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='core.TaggedItem', to='core.Tag', verbose_name='Tags')), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rack_types', to='core.tenant')), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='rack', + name='rack_type', + field=models.ForeignKey(blank=True, help_text='The cabinet model this rack is an instance of.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='racks', to='api.racktype'), + ), + migrations.CreateModel( + name='RackTypeAccessory', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('label', models.CharField(help_text='Suffix for stamped devices (e.g. PDU-A).', max_length=64)), + ('mount', models.CharField(choices=[('side_left', 'Left rail'), ('side_right', 'Right rail')], max_length=12)), + ('mount_offset_mm', models.PositiveSmallIntegerField(blank=True, null=True)), + ('mount_span_u', models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(60)])), + ('order', models.PositiveSmallIntegerField(default=0)), + ('device_type', models.ForeignKey(help_text='Must be a 0U device type (vertical strips side-mount).', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='api.devicetype')), + ('rack_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accessories', to='api.racktype')), + ], + options={ + 'ordering': ['order', 'label'], + }, + ), + migrations.AddConstraint( + model_name='racktype', + constraint=models.UniqueConstraint(fields=('tenant', 'name'), name='uniq_racktype_tenant_name'), + ), + migrations.AddConstraint( + model_name='racktypeaccessory', + constraint=models.UniqueConstraint(fields=('rack_type', 'label'), name='uniq_racktypeaccessory_type_label'), + ), + ] diff --git a/api/migrations/0109_racktypeaccessory_face.py b/api/migrations/0109_racktypeaccessory_face.py new file mode 100644 index 00000000..2f46561f --- /dev/null +++ b/api/migrations/0109_racktypeaccessory_face.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.15 on 2026-07-26 11:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0108_rack_types'), + ] + + operations = [ + migrations.AddField( + model_name='racktypeaccessory', + name='face', + field=models.CharField(blank=True, choices=[('front', 'Front'), ('rear', 'Rear')], default='', help_text='Which channel the strip bolts into. Blank draws it on both elevations, which is rarely what a real PDU does.', max_length=5), + ), + migrations.AlterField( + model_name='device', + name='mount', + field=models.CharField(blank=True, choices=[('side_left', 'Left rail'), ('side_right', 'Right rail')], default='', help_text='Zero-U side mounting: the rack rail this device bolts to instead of occupying units. Requires a rack and a 0U device type, and no U position or half-width side. ``face`` stays meaningful — it says which channel (front/rear) the strip lives in; blank shows it on both.', max_length=12), + ), + ] diff --git a/api/models.py b/api/models.py index 5a05f929..f7fe3170 100644 --- a/api/models.py +++ b/api/models.py @@ -430,6 +430,13 @@ class DeviceType(NumIdMixin, TimestampedModel, CustomFieldsMixin, TaggableMixin, "referencing component-template names). Null = automatic " "layout computed from the device's interfaces."), ) + image_ports = models.JSONField( + null=True, blank=True, default=None, + help_text=("Port markers anchored on the front/rear photo: " + "{front:[{kind,name,x,y,w,h}], rear:[...]} with x/y/w/h " + "normalized 0..1 (center-anchored). Rendered over the image " + "in 2D and on the device face in 3D. Null = none placed."), + ) is_full_depth = models.BooleanField( default=True, help_text=("Occupies both the front and rear rack faces. Full-depth " @@ -482,7 +489,7 @@ def __str__(self) -> str: # ships with. Creating a Device of that type materialises every template into # a concrete component (Interface, ConsolePort, …) on the device, so a # "C9300-48P" stamps out its 48 interfaces + console + 2 PSU inlets each time. -# Matches NetBox's *template semantics (and the community devicetype-library), +# Matches the community devicetype-library's *template semantics, # so imported device types carry their components over. Templates hold no # per-device state — they're part of the type definition, and per the # zero-pre-filled-data rule none ship by default. @@ -694,9 +701,34 @@ class Meta: ordering = ["name"] +# Hardware kind of an inventory item/template — what the part IS. "other" +# keeps pre-existing rows meaningful; new kinds may be added here (kept as +# hardcoded protocol constants, like connector types). +INVENTORY_ITEM_KINDS = [ + ("other", "Other"), + ("disk", "Disk"), + ("cpu", "CPU"), + ("ram", "RAM"), + ("psu", "PSU"), + ("fan", "Fan"), + ("gpu", "GPU"), + ("controller", "Controller"), + ("transceiver", "Transceiver"), +] + +# Storage media/protocol for kind=disk ("" for non-disks). +INVENTORY_MEDIA_TYPES = [ + ("", "—"), + ("nvme", "NVMe"), + ("ssd", "SSD (SATA/SAS)"), + ("hdd", "HDD"), + ("tape", "Tape"), +] + + class InventoryItemTemplate(_ComponentTemplate): """A physical part the hardware ships with that isn't a connectable - component — PSU, fan tray, CPU, factory-fitted transceiver.""" + component — PSU, fan tray, CPU, disk bay, factory-fitted transceiver.""" device_type = models.ForeignKey( DeviceType, on_delete=models.CASCADE, @@ -707,6 +739,21 @@ class InventoryItemTemplate(_ComponentTemplate): related_name="inventory_item_templates", ) part_id = models.CharField(max_length=128, blank=True, default="") + kind = models.CharField( + max_length=16, choices=INVENTORY_ITEM_KINDS, default="other" + ) + media = models.CharField( + max_length=16, choices=INVENTORY_MEDIA_TYPES, blank=True, default="" + ) + capacity_bytes = models.PositiveBigIntegerField( + null=True, blank=True, + help_text="Capacity in BYTES (unit-agnostic: KB floppies to PB " + "arrays; the UI converts).", + ) + speed = models.CharField( + max_length=64, blank=True, default="", + help_text='Free-form: "7.2K RPM", "PCIe 4.0 x4", "3200 MT/s".', + ) class Meta: unique_together = ("device_type", "name") @@ -874,7 +921,7 @@ def _names(manager) -> set[str]: made = [ Interface(device=device, name=n, type=t.type, enabled=t.enabled, mgmt_only=t.mgmt_only, poe_mode=t.poe_mode, - poe_type=t.poe_type) + poe_type=t.poe_type, description=t.description) for t in dt.interface_templates.all() if (n := render_component_name(t.name, pos)) not in have ] @@ -936,7 +983,7 @@ def _names(manager) -> set[str]: have = _names(device.rear_ports) made = [ RearPort(device=device, name=n, type=t.type, positions=t.positions, - is_splitter=t.is_splitter) + is_splitter=t.is_splitter, description=t.description) for t in dt.rear_port_templates.all() if (n := render_component_name(t.name, pos)) not in have ] @@ -953,6 +1000,7 @@ def _names(manager) -> set[str]: ], rear_port_position=t.rear_port_position, positions=t.positions, + description=t.description, ) for t in dt.front_port_templates.select_related("rear_port_template") if (n := render_component_name(t.name, pos)) not in have @@ -975,6 +1023,8 @@ def _names(manager) -> set[str]: InventoryItem( device=device, name=n, manufacturer=t.manufacturer, part_id=t.part_id, description=t.description, + kind=t.kind, media=t.media, capacity_bytes=t.capacity_bytes, + speed=t.speed, ) for t in dt.inventory_item_templates.select_related("manufacturer") if (n := render_component_name(t.name, pos)) not in have @@ -1101,6 +1151,173 @@ def sync_device_components(device, *, remove_extra: bool = False) -> dict: return {"added": {k: v for k, v in added.items() if v}, "removed": removed} +# Dimension fields a rack inherits from its type — the ones the rack form +# pre-fills, and therefore the ones that can drift from the model. +_RACK_TYPE_DIMS = ( + "width", "u_height", "starting_unit", "desc_units", + "outer_width_mm", "outer_depth_mm", "max_weight", "max_weight_unit", +) + + +def diff_rack_from_type(rack) -> dict: + """How this rack differs from its rack type, right now. + + Two halves, because a rack inherits two different things from its model: + + * ``dims`` — ``{field: {"rack": v, "type": v}}`` for every dimension that + drifted. Picking a type pre-fills these and then lets you edit them, so + drift is legitimate; this only reports it. + * ``accessories`` — ``{"add": [...], "update": [...], "extra": [...]}``. + *add* = accessories on the type with no side-mounted device carrying + that label; *update* = a strip that EXISTS but no longer matches its + accessory (the type's device type was swapped, the rail moved, a + channel was set); *extra* = side-mounted devices whose name looks + stamped (``{rack}-{label}``) but matches no current accessory. + + Matching on the label alone is what makes *update* necessary: presence + is not agreement, and a first pass that only asked "is a strip with this + label here?" answered "nothing to apply" after the model's PDU had been + changed to a different device type entirely. + + Empty dict when the rack has no type. + """ + rt = rack.rack_type + if rt is None: + return {} + dims = {} + for f in _RACK_TYPE_DIMS: + have, want = getattr(rack, f), getattr(rt, f) + if have != want: + dims[f] = {"rack": have, "type": want} + + accessories = list(rt.accessories.select_related("device_type")) + # A stamped strip is named "{rack}-{label}"; match on that suffix so a + # renamed rack (or a hand-added strip) doesn't read as a missing one. + prefix = f"{rack.name}-" + mounted = [d for d in rack.devices.select_related("device_type") if d.mount] + by_label: dict[str, object] = {} + for d in mounted: + if d.name.startswith(prefix): + by_label.setdefault(d.name[len(prefix):], d) + want_labels = {a.label for a in accessories} + + add = sorted(want_labels - set(by_label)) + update = [] + for acc in accessories: + d = by_label.get(acc.label) + if d is None: + continue + changes: dict = {} + if d.device_type_id != acc.device_type_id: + changes["device_type"] = { + "device": d.device_type.name if d.device_type else None, + "type": acc.device_type.name, + } + for f in ("mount", "face", "mount_offset_mm", "mount_span_u"): + have, want = getattr(d, f), getattr(acc, f) + if have != want: + changes[f] = {"device": have, "type": want} + if changes: + update.append( + {"name": d.name, "label": acc.label, "changes": changes} + ) + extra = sorted( + label for label in by_label if label not in want_labels + ) + + out: dict = {} + if dims: + out["dims"] = dims + if add or update or extra: + out["accessories"] = { + "add": add, + "update": update, + "extra": [f"{prefix}{label}" for label in extra], + } + return out + + +@transaction.atomic +def sync_rack_from_type(rack, *, dims: bool = True, accessories: bool = True): + """Bring a rack back in line with its type. + + ``dims`` copies the model's dimensions onto the rack. ``accessories`` + stamps the strips the type defines that this rack is missing AND brings + existing strips back in line with their accessory — the same naming and + component materialisation the create-time stamp uses, so a type that + gains a PDU (or swaps to a different one) can be rolled out to racks + already built from it. + + NEVER deletes: an "extra" strip is somebody's real, cabled PDU, and a + re-pointed device type keeps the components it already had (the new + type's are added alongside — the DEVICE's own sync-from-type is where + stale components get pruned, because only that action knows what the + cabling depends on). The diff reports extras so a human can decide. + Returns ``{"dims": [...], "accessories": [...], "updated": [...]}``. + """ + diff = diff_rack_from_type(rack) + changed_dims: list[str] = [] + if dims and diff.get("dims"): + for f in diff["dims"]: + setattr(rack, f, getattr(rack.rack_type, f)) + changed_dims.append(f) + rack.save(update_fields=changed_dims) + + updated: list[str] = [] + if accessories and diff.get("accessories", {}).get("update"): + by_label = {a.label: a for a in rack.rack_type.accessories.all()} + for entry in diff["accessories"]["update"]: + acc = by_label.get(entry["label"]) + device = rack.devices.filter(name=entry["name"]).first() + if acc is None or device is None: + continue + retyped = "device_type" in entry["changes"] + device.device_type = acc.device_type + device.mount = acc.mount + device.face = acc.face + device.mount_offset_mm = acc.mount_offset_mm + device.mount_span_u = acc.mount_span_u + device.save(update_fields=[ + "device_type", "mount", "face", + "mount_offset_mm", "mount_span_u", + ]) + if retyped: + # The new type's components; the old ones stay put. + materialize_device_components(device) + updated.append(device.name) + + stamped: list[str] = [] + if accessories and diff.get("accessories", {}).get("add"): + wanted = set(diff["accessories"]["add"]) + for acc in rack.rack_type.accessories.select_related("device_type"): + if acc.label not in wanted: + continue + base = f"{rack.name}-{acc.label}" + name, n = base, 2 + while Device.objects.filter(tenant=rack.tenant, name=name).exists(): + name = f"{base}-{n}" + n += 1 + device = Device.objects.create( + tenant=rack.tenant, + name=name, + site=rack.site, + location=rack.location, + rack=rack, + device_type=acc.device_type, + mount=acc.mount, + face=acc.face, + mount_offset_mm=acc.mount_offset_mm, + mount_span_u=acc.mount_span_u, + ) + materialize_device_components(device) + stamped.append(name) + return { + "dims": changed_dims, + "accessories": stamped, + "updated": updated, + } + + def _module_interface_names(module) -> list[str]: """The concrete interface names a module contributes to its host device — ``{module}`` → the bay's position, then ``{position}`` → the device's @@ -1124,7 +1341,8 @@ def install_module(module) -> int: have = set(module.device.interfaces.values_list("name", flat=True)) made = [ Interface(device=module.device, name=n, type=t.type, - enabled=t.enabled, mgmt_only=t.mgmt_only) + enabled=t.enabled, mgmt_only=t.mgmt_only, + description=t.description) for n, t in types.items() if n not in have ] @@ -1218,6 +1436,26 @@ class Device(NumIdMixin, TimestampedModel, CustomFieldsMixin, TaggableMixin): "device type's rack_width must be 'half'). Blank for " "full-width devices."), ) + # ── Zero-U side mounting (vertical PDU strips and the like) ───────── + MOUNT_CHOICES = [("side_left", "Left rail"), ("side_right", "Right rail")] + mount = models.CharField( + max_length=12, choices=MOUNT_CHOICES, blank=True, default="", + help_text=("Zero-U side mounting: the rack rail this device bolts " + "to instead of occupying units. Requires a rack and a 0U " + "device type, and no U position or half-width side. " + "``face`` stays meaningful — it says which channel " + "(front/rear) the strip lives in; blank shows it on both."), + ) + mount_offset_mm = models.PositiveSmallIntegerField( + null=True, blank=True, + help_text="Bottom of the side-mounted strip above the base plate.", + ) + mount_span_u = models.PositiveSmallIntegerField( + null=True, blank=True, + validators=[MinValueValidator(1), MaxValueValidator(60)], + help_text=("Vertical extent of the side-mounted strip, in U. Blank " + "draws ~three quarters of the rack."), + ) status = models.ForeignKey( "Status", on_delete=models.PROTECT, null=True, blank=True, related_name="devices", @@ -1326,13 +1564,20 @@ class Meta: ), ] + @property + def effective_airflow(self) -> str: + """The device's own airflow when set, else its type's default, else + "". Derived like ``effective_platform`` — the stored field stays + untouched, so clearing the override falls back to the hardware's.""" + return self.airflow or (self.device_type.airflow if self.device_type else "") + def __str__(self) -> str: return self.name class ImageAttachment(TimestampedModel): """A user-uploaded image pinned to any object that makes sense to - photograph — devices, racks, sites, locations (NetBox's ``ImageAttachment``). + photograph — devices, racks, sites, locations. Generic-FK so one model + one upload flow covers every object type. Scoped to the parent's tenant, so the same tenant isolation applies; managed @@ -1933,6 +2178,20 @@ class Interface(TimestampedModel, CustomFieldsMixin, TaggableMixin): Device, on_delete=models.CASCADE, related_name="interfaces" ) name = models.CharField(max_length=64) + snmp_name = models.CharField( + max_length=128, blank=True, default="", + help_text="What the agent calls this interface over SNMP (ifName / " + "ifDescr) when it differs from the label — e.g. the port silkscreened " + "'Ethernet 1' reporting as 'eth0'. Set it and discovery stops " + "reporting the pair as both new and missing.", + ) + snmp_ignore = models.BooleanField( + default=False, + help_text="Exclude this interface from SNMP drift. For ports the " + "polled agent can never report — silkscreened ports a BMC doesn't " + "see, out-of-band jacks — which would otherwise flag as 'not seen " + "on device' after every poll, forever.", + ) type = models.CharField( max_length=64, blank=True, default="", choices=INTERFACE_TYPE_CHOICES, help_text="Physical/logical media type, e.g. 10gbase-x-sfpp.", @@ -1965,6 +2224,7 @@ class Interface(TimestampedModel, CustomFieldsMixin, TaggableMixin): max_length=17, blank=True, help_text="Layer-2 hardware address, e.g. 00:1b:44:11:3a:b7.", ) + description = models.CharField(max_length=255, blank=True, default="") # ─── L2: 802.1Q switching ──────────────────────────────────────────── # `vlan` is the **untagged / access (native)** VLAN. `mode` says how the # port behaves; `tagged_vlans` are the trunk VLANs when tagged. @@ -2087,6 +2347,7 @@ class RearPort(TimestampedModel, CustomFieldsMixin, TaggableMixin): "position 1 and carries the same signal (PON). Requires positions=1.", ) type = models.CharField(max_length=64, blank=True) + description = models.CharField(max_length=255, blank=True, default="") class Meta: unique_together = ("device", "name") @@ -2129,6 +2390,7 @@ class FrontPort(TimestampedModel, CustomFieldsMixin, TaggableMixin): "rear-port positions from the start.", ) type = models.CharField(max_length=64, blank=True) + description = models.CharField(max_length=255, blank=True, default="") class Meta: ordering = ["name"] @@ -2229,9 +2491,13 @@ def __str__(self) -> str: class InventoryItem(TimestampedModel, CustomFieldsMixin, TaggableMixin): - """A serial-tracked physical part on a device — PSU, fan, CPU, discrete - SFP. Self-nesting (a card can contain sub-parts). Roles are tags, per the - zero-pre-filled-data rule. Tenant scope inherited via device.""" + """A serial-tracked physical part on a device — disk, CPU, RAM, PSU, fan, + discrete SFP. Self-nesting (a card can contain sub-parts). Roles are tags, + per the zero-pre-filled-data rule. Tenant scope inherited via device. + + ``kind``/``media``/``capacity_bytes``/``speed`` describe the hardware; + ``status`` carries its lifecycle/health (active / planned / failed / + spare — user-extensible via the Status catalog).""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) device = models.ForeignKey( @@ -2250,6 +2516,25 @@ class InventoryItem(TimestampedModel, CustomFieldsMixin, TaggableMixin): serial_number = models.CharField(max_length=255, blank=True, default="") asset_tag = models.CharField(max_length=128, blank=True, default="") description = models.CharField(max_length=255, blank=True, default="") + kind = models.CharField( + max_length=16, choices=INVENTORY_ITEM_KINDS, default="other" + ) + media = models.CharField( + max_length=16, choices=INVENTORY_MEDIA_TYPES, blank=True, default="" + ) + capacity_bytes = models.PositiveBigIntegerField( + null=True, blank=True, + help_text="Capacity in BYTES (unit-agnostic: KB floppies to PB " + "arrays; the UI converts).", + ) + speed = models.CharField( + max_length=64, blank=True, default="", + help_text='Free-form: "7.2K RPM", "PCIe 4.0 x4", "3200 MT/s".', + ) + status = models.ForeignKey( + "Status", on_delete=models.SET_NULL, null=True, blank=True, + related_name="inventory_items", + ) class Meta: unique_together = ("device", "name") @@ -2912,6 +3197,69 @@ def __str__(self) -> str: return self.name +class DeviceTypeImportRun(TimestampedModel): + """A background bulk import from the NetBox devicetype-library — one folder + (e.g. a whole manufacturer, or the entire device-types dir) pulled off the + RQ ``low`` queue so the UI can poll its progress. The synchronous + import-yaml endpoint handles small pastes; this handles the thousands. + + ``kind`` distinguishes the two jobs sharing this machinery: ``library`` + (the original YAML import — creates types) and ``image_reimport`` + (re-downloading elevation images for EXISTING types after media loss — + touches only the two image fields).""" + + STATUS_CHOICES = [ + ("queued", "Queued"), + ("running", "Running"), + ("success", "Success"), + ("failed", "Failed"), + ] + KIND_CHOICES = [ + ("library", "Library import"), + ("image_reimport", "Image reimport"), + ] + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + tenant = models.ForeignKey( + Tenant, on_delete=models.CASCADE, related_name="devicetype_imports" + ) + kind = models.CharField( + max_length=16, choices=KIND_CHOICES, default="library" + ) + #: library: the github.com /tree/ folder URL being imported. + #: image_reimport: the normalised elevation-images base URL. + source_url = models.CharField(max_length=512) + stack_positions = models.BooleanField(default=False) + #: Kind-specific knobs. image_reimport: {"overwrite": bool, "dry_run": bool}. + options = models.JSONField(default=dict, blank=True) + status = models.CharField( + max_length=16, choices=STATUS_CHOICES, default="queued" + ) + #: Live progress: {"done": n, "total": n, "created": n, "failed": n}. + progress = models.JSONField(default=dict, blank=True) + #: Per-file failures once finished: [{"name": …, "error": …}, …] (capped). + failures = models.JSONField(default=list, blank=True) + error = models.TextField(blank=True, default="") + #: The site imported types are scoped to under enhanced site separation. + owning_site = models.ForeignKey( + "Site", on_delete=models.SET_NULL, null=True, blank=True, + related_name="+", + ) + created_by = models.ForeignKey( + "auth.User", on_delete=models.SET_NULL, null=True, + blank=True, related_name="+", + ) + started_at = models.DateTimeField(null=True, blank=True) + finished_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created_at"] + indexes = [models.Index(fields=["tenant", "-created_at"])] + + def __str__(self) -> str: + return f"{self.source_url} ({self.status})" + + class TopologyView(NumIdMixin, TimestampedModel): """A saved topology-map view: the filter set plus hand-tuned node positions, so a curated diagram survives reloads and re-layouts.""" @@ -2964,6 +3312,11 @@ class Rack(NumIdMixin, TimestampedModel, CustomFieldsMixin, TaggableMixin): RackRole, on_delete=models.SET_NULL, null=True, blank=True, related_name="racks", ) + rack_type = models.ForeignKey( + "RackType", on_delete=models.SET_NULL, null=True, blank=True, + related_name="racks", + help_text="The cabinet model this rack is an instance of.", + ) status = models.ForeignKey( "Status", on_delete=models.PROTECT, null=True, blank=True, related_name="racks", @@ -2994,6 +3347,19 @@ class Rack(NumIdMixin, TimestampedModel, CustomFieldsMixin, TaggableMixin): desc_units = models.BooleanField( default=False, help_text="Number units top-to-bottom instead of bottom-up.", ) + # Cabinet outer dimensions — the physical footprint (frame included), used + # by the 3D room view and scaled drawings. Blank = plausible render + # defaults (depth 1000 mm; width = rail width + 150 mm frame). + outer_width_mm = models.PositiveSmallIntegerField( + null=True, blank=True, + validators=[MinValueValidator(100), MaxValueValidator(2000)], + help_text="Cabinet outer width in millimetres (blank = derived).", + ) + outer_depth_mm = models.PositiveSmallIntegerField( + null=True, blank=True, + validators=[MinValueValidator(100), MaxValueValidator(3000)], + help_text="Cabinet outer depth in millimetres (blank = 1000).", + ) description = models.TextField(blank=True) class Meta: @@ -3008,6 +3374,122 @@ def __str__(self) -> str: return self.name +class RackType(NumIdMixin, TimestampedModel, TaggableMixin): + """A reusable rack profile — manufacturer/model plus the physical + dimensions a cabinet of that model always has. Picking one on a rack + pre-fills the dims (client-side; the rack stays the source of truth), + and its accessories can stamp side-mounted 0U gear onto new racks.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + tenant = models.ForeignKey( + Tenant, on_delete=models.CASCADE, related_name="rack_types" + ) + manufacturer = models.ForeignKey( + Manufacturer, on_delete=models.SET_NULL, null=True, blank=True, + related_name="rack_types", + ) + name = models.CharField(max_length=128) + width = models.PositiveSmallIntegerField( + choices=Rack.WIDTH_CHOICES, default=19, + help_text="Rail-to-rail width, inches.", + ) + u_height = models.PositiveSmallIntegerField( + default=42, help_text="Height in rack units (U)." + ) + starting_unit = models.PositiveSmallIntegerField( + default=1, help_text="Number of the bottom unit." + ) + desc_units = models.BooleanField( + default=False, + help_text="Number units top-to-bottom instead of bottom-up.", + ) + outer_width_mm = models.PositiveSmallIntegerField( + null=True, blank=True, + validators=[MinValueValidator(100), MaxValueValidator(2000)], + help_text="Cabinet outer width in millimetres (blank = derived).", + ) + outer_depth_mm = models.PositiveSmallIntegerField( + null=True, blank=True, + validators=[MinValueValidator(100), MaxValueValidator(3000)], + help_text="Cabinet outer depth in millimetres (blank = 1000).", + ) + max_weight = models.DecimalField( + max_digits=8, decimal_places=2, null=True, blank=True, + help_text="Load budget this cabinet model is rated for.", + ) + max_weight_unit = models.CharField( + max_length=8, choices=DeviceType.WEIGHT_UNIT_CHOICES, + blank=True, default="", + ) + description = models.TextField(blank=True) + + class Meta: + ordering = ["name"] + constraints = [ + models.UniqueConstraint( + fields=["tenant", "name"], name="uniq_racktype_tenant_name" + ) + ] + + def __str__(self) -> str: + return self.name + + +class RackTypeAccessory(TimestampedModel): + """Factory-fitted 0U gear on a rack model — typically vertical PDU + strips. On rack creation the accessories can (opt-in) stamp one + side-mounted device each, named ``{rack}-{label}``.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + rack_type = models.ForeignKey( + RackType, on_delete=models.CASCADE, related_name="accessories" + ) + device_type = models.ForeignKey( + DeviceType, on_delete=models.PROTECT, related_name="+", + help_text="Must be a 0U device type (vertical strips side-mount).", + ) + label = models.CharField( + max_length=64, help_text="Suffix for stamped devices (e.g. PDU-A)." + ) + mount = models.CharField(max_length=12, choices=Device.MOUNT_CHOICES) + face = models.CharField( + max_length=5, choices=Device.FACE_CHOICES, blank=True, default="", + help_text=("Which channel the strip bolts into. Blank draws it on " + "both elevations, which is rarely what a real PDU does."), + ) + mount_offset_mm = models.PositiveSmallIntegerField(null=True, blank=True) + mount_span_u = models.PositiveSmallIntegerField( + null=True, blank=True, + validators=[MinValueValidator(1), MaxValueValidator(60)], + ) + order = models.PositiveSmallIntegerField(default=0) + + class Meta: + ordering = ["order", "label"] + constraints = [ + models.UniqueConstraint( + fields=["rack_type", "label"], + name="uniq_racktypeaccessory_type_label", + ) + ] + + def __str__(self) -> str: + return f"{self.rack_type.name}:{self.label}" + + @property + def tenant_id(self): + """Owning tenant through the parent type. The audit trail stamps + ``instance.tenant_id`` on every entry; without this the accessory + (tenant-less AND site-less) would log NULL/NULL and fail closed out + of its own tenant's history.""" + from django.core.exceptions import ObjectDoesNotExist + + try: + return self.rack_type.tenant_id + except ObjectDoesNotExist: + return None + + # ─── Device roles + platforms (shared by Device + VirtualMachine) ──────────── class DeviceRole(NumIdMixin, TimestampedModel, CustomFieldsMixin, TaggableMixin): """Functional role of a device or VM (core switch, hypervisor, …). Coloured.""" @@ -4527,8 +5009,8 @@ def __str__(self) -> str: def resolve_config_template(device): """The config template that renders this device's intended config — - device's own, else its role's, else its platform's (NetBox's resolution - order). None when nothing is bound anywhere.""" + device's own, else its role's, else its platform's. None when nothing is + bound anywhere.""" if device.config_template_id: return device.config_template if device.role_id and device.role.config_template_id: @@ -4575,6 +5057,11 @@ class FloorTileType(NumIdMixin, TimestampedModel): help_text="Tiles of this type get camera field-of-view controls " "(direction / angle / distance cone).", ) + perforated = models.BooleanField( + default=False, + help_text="Zone tiles of this type render as perforated/grate floor " + "in the 3D room — the cold-aisle supply-tile read.", + ) description = models.TextField(blank=True, default="") class Meta: @@ -4616,6 +5103,19 @@ class FloorPlan(NumIdMixin, TimestampedModel, CustomFieldsMixin, TaggableMixin): background_opacity = models.PositiveSmallIntegerField( default=60, validators=[MaxValueValidator(100)], help_text="Percent." ) + # Real-world scale. The grid itself is abstract; these give it physical + # meaning for the 3D room view, route-length estimation, and drawing scale + # bars. Defaults are deliberately plausible (600 mm = a standard raised + # floor tile) so existing plans render sensibly with zero data entry. + cell_mm = models.PositiveSmallIntegerField( + default=600, validators=[MinValueValidator(50), MaxValueValidator(5000)], + help_text="Physical size of one grid cell, in millimetres.", + ) + ceiling_mm = models.PositiveSmallIntegerField( + default=3000, + validators=[MinValueValidator(1000), MaxValueValidator(20000)], + help_text="Room ceiling height, in millimetres.", + ) # View prefs (default zoom/pan, overlay mode, grid on/off) — free schema, # same trick as TopologyView.state, so it evolves without migrations. state = models.JSONField(default=dict, blank=True) @@ -4899,8 +5399,9 @@ class FloorPlanTray(TimestampedModel): grid points that physical cables are assigned to follow. This is the buildable wiring layer — the thing you print and hand to contractors. - Routing is manual in v1 (a cable belongs to the trays it runs through); - auto-routing along the tray graph is a later phase.""" + Cables are assigned manually or by the auto-router (``api/pathfinding.py`` + via ``POST /api/cables/{id}/auto-route/``), which picks the best path + along the tray graph and estimates the physical length.""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) floor_plan = models.ForeignKey( @@ -4911,6 +5412,24 @@ class FloorPlanTray(TimestampedModel): # "ladder", "underfloor"… whatever the shop calls it. kind = models.CharField(max_length=32, blank=True, default="") color = models.CharField(max_length=7, blank=True, default="") + # Vertical placement — where the run physically lives. Drives the 3D + # render height and the vertical-drop term in route-length estimation. + LEVEL_CHOICES = [ + ("overhead", "Overhead"), + ("underfloor", "Underfloor"), + ("floor", "Floor level"), + ] + level = models.CharField( + max_length=16, choices=LEVEL_CHOICES, default="overhead" + ) + elevation_mm = models.IntegerField( + null=True, blank=True, + validators=[MinValueValidator(-2000), MaxValueValidator(20000)], + help_text="Height above finished floor in millimetres (negative = " + "below the raised floor). Blank derives from the level: overhead → " + "ceiling − 300, underfloor → the plenum of the raised-floor area " + "beneath the run (default 300), floor → 0.", + ) # [[x, y], …] in cell-corner coordinates (integers along grid lines). points = models.JSONField(default=list) description = models.TextField(blank=True, default="") @@ -4927,6 +5446,83 @@ def __str__(self) -> str: return self.name +class FloorPlanRaisedFloorArea(TimestampedModel): + """A raised-floor region of a plan: a rectangle of grid cells standing on + pedestals with a cable plenum underneath. Rooms are rarely uniformly + raised — the DC pad is, the adjoining corridor isn't — so the raised + floor is per-area, and an L-shaped pad is simply two rectangles. + + The plenum depth drives two things: how deep underfloor trays/cables sit + (in the 3D room and in route-length estimation's vertical-drop term), and + the plenum volume the 3D view draws below the finished floor. Areas may + not overlap, so the plenum under any point is unambiguous.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + floor_plan = models.ForeignKey( + FloorPlan, on_delete=models.CASCADE, related_name="raised_floor_areas" + ) + x = models.PositiveSmallIntegerField() + y = models.PositiveSmallIntegerField() + width = models.PositiveSmallIntegerField( + validators=[MinValueValidator(1)] + ) + height = models.PositiveSmallIntegerField( + validators=[MinValueValidator(1)] + ) + plenum_mm = models.PositiveSmallIntegerField( + default=300, + validators=[MinValueValidator(50), MaxValueValidator(2000)], + help_text="Void depth under the finished floor — how far below 0 the " + "structural slab sits here.", + ) + label = models.CharField(max_length=64, blank=True, default="") + color = models.CharField(max_length=7, blank=True, default="") + + class Meta: + ordering = ["y", "x"] + indexes = [models.Index(fields=["floor_plan"])] + + def __str__(self) -> str: + return self.label or f"raised floor @ ({self.x},{self.y})" + + +class FloorPlanWall(TimestampedModel): + """A wall drawn on a floor plan: a polyline on the same half-cell lattice + trays use, so a wall can run along the boundary BETWEEN two tiles or down + the centreline OF a tile. ``openings`` are door/passage spans along the + polyline's segments; v1 doors are pure geometry (no per-door metadata), + so they live as JSON on the wall — one PATCH keeps wall + doors atomic, + exactly how a tray keeps its whole geometry in ``points``. + + v1 walls are documentation geometry: they render in 2D and 3D but do NOT + constrain cable auto-routing, which follows the tray graph.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + floor_plan = models.ForeignKey( + FloorPlan, on_delete=models.CASCADE, related_name="walls" + ) + label = models.CharField(max_length=64, blank=True, default="") + # [[x, y], …] in cell units snapped to the half-cell lattice. + points = models.JSONField(default=list) + height_mm = models.PositiveSmallIntegerField( + null=True, blank=True, + validators=[MinValueValidator(200), MaxValueValidator(20000)], + help_text="Blank = full height (the plan's ceiling).", + ) + color = models.CharField(max_length=7, blank=True, default="") + # [{"seg": int, "from": float, "to": float, "height_mm": int|null}, …] — + # spans along segment ``seg`` measured in cell units from its start + # vertex; null height renders a standard 2100 mm door. + openings = models.JSONField(default=list, blank=True) + + class Meta: + ordering = ["label"] + indexes = [models.Index(fields=["floor_plan"])] + + def __str__(self) -> str: + return self.label or f"wall ({len(self.points or [])} pts)" + + class CableRoute(TimestampedModel): """A geographic cable run drawn on the site map: a named polyline of lat/lng waypoints that physical cables are assigned to follow — ducts, diff --git a/api/pathfinding.py b/api/pathfinding.py new file mode 100644 index 00000000..902566be --- /dev/null +++ b/api/pathfinding.py @@ -0,0 +1,320 @@ +"""Auto-routing a cable through a floor plan's tray network. + +The Python twin of the frontend's ``cable-route.ts`` (which renders assigned +trays): same half-cell-lattice geometry, same junction rules (shared vertices, +T-splits, mid-segment crossings), same Dijkstra — but this side also reports +*which trays* the winning path rides and estimates the physical cable length, +because the server persists the result (tray M2M + ``Cable.length``) and the +BOM/drawings consume it. + +Pure geometry: no Django imports, everything in grid-cell units until the +final length conversion. Coordinates are ``(x, y)`` tuples in cell units. +""" +from __future__ import annotations + +import heapq +import math +from dataclasses import dataclass, field + +Pt = tuple[float, float] + +#: How far (cells) a vertex may sit from another tray and still join it — +#: mirrors cable-route.ts's default `snap`. +SNAP_CELLS = 0.75 +#: Extra length allowance for service loops, dressing and termination waste. +SLACK_FACTOR = 0.10 +#: Plenum depth under a raised floor when no area records one (mm). The +#: single source for the old hardcoded −300; world.ts mirrors it. +DEFAULT_PLENUM_MM = 300 +#: Overhead trays hang this far below the ceiling when elevation is blank. +OVERHEAD_DROP_MM = 300 +#: Rack-top math for vertical drops: U pitch + plinth height (mm) — the same +#: constants world.ts renders cabinets with (PANEL_MM.uPitch, RACK_BASE_M). +U_PITCH_MM = 44.45 +RACK_PLINTH_MM = 100.0 +#: How far (cells) an endpoint may sit from the tray network and still count +#: as routable. The 2D renderer projects unbounded (it draws already-assigned +#: trays); the router DECIDES routability, so a rack 30 cells from any tray +#: must come back unreachable, not "reachable via a 20 m unsupported hop". +MAX_ENTRY_CELLS = 6.0 + + +def _dist(a: Pt, b: Pt) -> float: + return math.hypot(a[0] - b[0], a[1] - b[1]) + + +def _project_segment(p: Pt, s: Pt, e: Pt) -> tuple[Pt, float, float]: + """Nearest point on segment [s, e] to p → (point, t, distance).""" + dx, dy = e[0] - s[0], e[1] - s[1] + len2 = dx * dx + dy * dy or 1e-9 + t = ((p[0] - s[0]) * dx + (p[1] - s[1]) * dy) / len2 + t = max(0.0, min(1.0, t)) + pt = (s[0] + t * dx, s[1] + t * dy) + return pt, t, _dist(p, pt) + + +def _project_polyline(p: Pt, poly: list[Pt]) -> tuple[Pt, float, int, float]: + """Nearest point on a polyline → (point, distance, segment index, t).""" + best: tuple[Pt, float, int, float] = (poly[0], math.inf, 0, 0.0) + for i in range(len(poly) - 1): + pt, t, d = _project_segment(p, poly[i], poly[i + 1]) + if d < best[1]: + best = (pt, d, i, t) + return best + + +def _segment_intersect(a: Pt, b: Pt, c: Pt, d: Pt): + """Intersection of segments [a,b] and [c,d] → (point, t, u) or None.""" + rx, ry = b[0] - a[0], b[1] - a[1] + sx, sy = d[0] - c[0], d[1] - c[1] + denom = rx * sy - ry * sx + if abs(denom) < 1e-9: + return None + t = ((c[0] - a[0]) * sy - (c[1] - a[1]) * sx) / denom + u = ((c[0] - a[0]) * ry - (c[1] - a[1]) * rx) / denom + if t < -1e-6 or t > 1 + 1e-6 or u < -1e-6 or u > 1 + 1e-6: + return None + return (a[0] + t * rx, a[1] + t * ry), t, u + + +@dataclass +class RouteResult: + """A computed route. ``points`` in cell units; ``tray_indexes`` index into + the trays list the caller passed (in path order, deduplicated).""" + + reachable: bool + points: list[Pt] = field(default_factory=list) + tray_indexes: list[int] = field(default_factory=list) + #: Horizontal run along the path, in cells. + run_cells: float = 0.0 + + +def route_through_trays( + a: Pt, + b: Pt, + tray_polys: list[list[Pt]], + snap: float = SNAP_CELLS, + max_entry: float = MAX_ENTRY_CELLS, +) -> RouteResult: + """Best A→B route through the tray network (Dijkstra over tray segments, + junctions, and entry hops). ``reachable=False`` (with a straight A→B) when + there are no usable trays, an endpoint sits farther than ``max_entry`` + cells from every tray, or the network doesn't connect the ends.""" + trays = [t for t in tray_polys if len(t) >= 2] + straight = RouteResult( + reachable=False, points=[a, b], run_cells=_dist(a, b) + ) + if not trays: + return straight + + # ── Node registry (spatial merge so coincident points share a node) ── + nodes: list[Pt] = [] + merge_dist = snap * 0.5 + + def node_at(p: Pt) -> int: + for i, n in enumerate(nodes): + if _dist(n, p) <= merge_dist: + return i + nodes.append(p) + return len(nodes) - 1 + + # adjacency: u → {v: (weight, tray_index_or_None)} + adj: dict[int, dict[int, tuple[float, int | None]]] = {} + + def edge(u: int, v: int, w: float, tray: int | None) -> None: + if u == v: + return + for x, y in ((u, v), (v, u)): + m = adj.setdefault(x, {}) + cur = m.get(y) + if cur is None or w < cur[0]: + m[y] = (w, tray) + + # Arc-length position of each vertex, per tray. + arcs: list[list[float]] = [] + for poly in trays: + arc = [0.0] + for i in range(1, len(poly)): + arc.append(arc[i - 1] + _dist(poly[i - 1], poly[i])) + arcs.append(arc) + + def pos_on_tray(ti: int, seg: int, t: float) -> float: + return arcs[ti][seg] + t * _dist(trays[ti][seg], trays[ti][seg + 1]) + + # Breakpoints per tray: start with the vertices. + breakpoints: list[list[tuple[float, Pt]]] = [ + [(arcs[ti][i], p) for i, p in enumerate(poly)] + for ti, poly in enumerate(trays) + ] + + # Cross-tray vertex projections → junctions and T-splits. + for ti, poly in enumerate(trays): + for v in poly: + for tj, other in enumerate(trays): + if tj == ti: + continue + pt, d, seg, t = _project_polyline(v, other) + if d <= snap: + breakpoints[tj].append((pos_on_tray(tj, seg, t), pt)) + edge(node_at(v), node_at(pt), d, None) + + # Mid-segment crossings → junctions where two trays intersect. + for ti in range(len(trays)): + for tj in range(ti + 1, len(trays)): + for si in range(len(trays[ti]) - 1): + for sj in range(len(trays[tj]) - 1): + x = _segment_intersect( + trays[ti][si], trays[ti][si + 1], + trays[tj][sj], trays[tj][sj + 1], + ) + if x is None: + continue + p, t, u = x + breakpoints[ti].append((pos_on_tray(ti, si, t), p)) + breakpoints[tj].append((pos_on_tray(tj, sj, u), p)) + + # Entry/exit: project A and B onto their nearest tray — but only within + # max_entry, otherwise the endpoint simply isn't served by the network. + def entry_point(p: Pt) -> Pt | None: + best_d, best_pt, best_ti, best_seg, best_t = math.inf, p, 0, 0, 0.0 + for ti, poly in enumerate(trays): + pt, d, seg, t = _project_polyline(p, poly) + if d < best_d: + best_d, best_pt, best_ti, best_seg, best_t = d, pt, ti, seg, t + if best_d > max_entry: + return None + breakpoints[best_ti].append( + (pos_on_tray(best_ti, best_seg, best_t), best_pt) + ) + return best_pt + + entry_a = entry_point(a) + entry_b = entry_point(b) + if entry_a is None or entry_b is None: + return straight + + # Chain each tray's breakpoints in arc order (tray-tagged edges). + for ti in range(len(trays)): + bps = sorted(breakpoints[ti], key=lambda x: x[0]) + for i in range(len(bps) - 1): + edge( + node_at(bps[i][1]), + node_at(bps[i + 1][1]), + abs(bps[i + 1][0] - bps[i][0]), + ti, + ) + + # Endpoint hops (straight-line ends outside the network). + na, nb = node_at(a), node_at(b) + edge(na, node_at(entry_a), _dist(a, entry_a), None) + edge(nb, node_at(entry_b), _dist(b, entry_b), None) + + # ── Dijkstra ── + best = [math.inf] * len(nodes) + prev: list[tuple[int, int | None]] = [(-1, None)] * len(nodes) + best[na] = 0.0 + heap: list[tuple[float, int]] = [(0.0, na)] + while heap: + d, u = heapq.heappop(heap) + if d > best[u]: + continue + if u == nb: + break + for v, (w, tray) in adj.get(u, {}).items(): + nd = d + w + if nd < best[v]: + best[v] = nd + prev[v] = (u, tray) + heapq.heappush(heap, (nd, v)) + if best[nb] is math.inf or best[nb] == math.inf: + return straight + + points: list[Pt] = [] + tray_order: list[int] = [] + u = nb + while u != -1: + points.append(nodes[u]) + p, tray = prev[u] + if tray is not None and (not tray_order or tray_order[-1] != tray): + tray_order.append(tray) + u = p + points.reverse() + tray_order.reverse() + # Deduplicate while keeping first-use order. + seen: set[int] = set() + trays_used = [t for t in tray_order if not (t in seen or seen.add(t))] + return RouteResult( + reachable=True, + points=points, + tray_indexes=trays_used, + run_cells=best[nb], + ) + + +def estimate_length_m( + run_cells: float, + cell_mm: int, + drop_a_mm: float, + drop_b_mm: float, + slack: float = SLACK_FACTOR, +) -> float: + """Physical cable length: horizontal run + both vertical drops + slack.""" + run_mm = run_cells * cell_mm + drop_a_mm + drop_b_mm + return round(run_mm * (1 + slack) / 1000, 1) + + +def tray_elevation_mm( + level: str, + elevation_mm: int | None, + ceiling_mm: int, + plenum_mm: float = DEFAULT_PLENUM_MM, +) -> float: + """A tray's resolved elevation — the Python twin of world.ts's + ``trayElevationM`` derivation (overhead → ceiling−300, underfloor → + −plenum, floor → 0). ``plenum_mm`` comes from the raised-floor area the + run sits in; callers with no area data get the historical 300.""" + if elevation_mm is not None: + return float(elevation_mm) + if level == "underfloor": + return -float(plenum_mm) + if level == "floor": + return 0.0 + return float(ceiling_mm - OVERHEAD_DROP_MM) + + +def underfloor_plenum_mm( + areas: list[tuple[float, float, float, float, int]], + points: list, +) -> float: + """The plenum depth under a tray run: the deepest raised-floor area any + of its points sits in, else the default. ``areas`` are + ``(x, y, width, height, plenum_mm)`` rects in cell units; the max wins + when a run crosses areas because a cable dressed to the deeper void + needs the longer drop.""" + best = 0.0 + for px, py in points or []: + for ax, ay, aw, ah, plenum in areas: + if ax <= px <= ax + aw and ay <= py <= ay + ah: + if plenum > best: + best = float(plenum) + return best or float(DEFAULT_PLENUM_MM) + + +def rack_drop_mm( + rack_u_height: int | None, + level: str, + elevation_mm: int | None, + ceiling_mm: int, + plenum_mm: float = DEFAULT_PLENUM_MM, +) -> float: + """Vertical run between a rack's top and a tray's elevation (mm) — the + drop term in length estimation. Replaces two identical inline closures + that each hardcoded the U pitch and plinth. ``abs()`` makes underfloor + work unchanged: the run goes down instead of up.""" + top = ( + rack_u_height * U_PITCH_MM + RACK_PLINTH_MM + if rack_u_height is not None + else 0.0 + ) + elev = tray_elevation_mm(level, elevation_mm, ceiling_mm, plenum_mm) + return abs(elev - top) diff --git a/api/serializers.py b/api/serializers.py index d9182d01..6c9c7542 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -23,7 +23,8 @@ Device, DeviceRole, DeviceType, FHRPGroup, FHRPGroupAssignment, ImageAttachment, FiberSettings, - FloorPlan, FloorPlanTile, FloorPlanTray, FloorTileType, FrontPort, + FloorPlan, FloorPlanRaisedFloorArea, FloorPlanTile, FloorPlanTray, + FloorPlanWall, FloorTileType, FrontPort, FrontPortTemplate, InterfaceTemplate, IPAddress, IPRange, IPRole, Status, Interface, MACAddress, Manufacturer, DeviceBay, DeviceBayTemplate, InventoryItem, InventoryItemTemplate, @@ -32,7 +33,8 @@ NumIdMixin, Platform, PlatformGroup, weight_kg, ConfigContext, ExportTemplate, Location, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, - Prefix, Provider, ProviderNetwork, Rack, RackRole, RearPort, + Prefix, Provider, ProviderNetwork, Rack, RackRole, RackType, + RackTypeAccessory, RearPort, RearPortTemplate, Region, RIR, RouteTarget, Service, ServiceTemplate, SiteMarker, DeviceTypeService, Site, @@ -40,7 +42,7 @@ WirelessLAN, WirelessLANGroup, Tunnel, TunnelGroup, TunnelTermination, IPSecProfile, L2VPN, L2VPNTermination, VirtualChassis, - render_component_name, render_module_name, + materialize_device_components, render_component_name, render_module_name, ) @@ -339,7 +341,8 @@ class Meta: class TenantGroupSerializer(serializers.ModelSerializer): - """Org-scoped, self-nesting tenant grouping (NetBox tenantgroup parity).""" + """Org-scoped, self-nesting tenant grouping; NetBox ``tenantgroup`` + hierarchies import losslessly.""" slug = serializers.SlugField(required=False, allow_blank=True) parent = TenantGroupMiniSerializer(read_only=True) @@ -1376,6 +1379,7 @@ class DeviceTypeSerializer(OwningSiteSerializerMixin, ObjectPermsSerializerMixin write_only=True, required=False, many=True, ) device_count = serializers.SerializerMethodField() + component_count = serializers.SerializerMethodField() front_image = serializers.SerializerMethodField() rear_image = serializers.SerializerMethodField() @@ -1388,6 +1392,27 @@ def get_device_count(self, obj) -> int: v = getattr(obj, "device_count_annotated", None) return v if v is not None else obj.device_set.count() + def get_component_count(self, obj) -> int: + # The Components tab's total — every template kind summed. Detail + # page only: eleven counts per row would be an N+1 on the list, and + # the list never renders it. + view = self.context.get("view") + if view is not None and getattr(view, "action", None) == "list": + return 0 + return ( + obj.interface_templates.count() + + obj.console_port_templates.count() + + obj.console_server_port_templates.count() + + obj.power_port_templates.count() + + obj.power_outlet_templates.count() + + obj.front_port_templates.count() + + obj.rear_port_templates.count() + + obj.aux_port_templates.count() + + obj.device_bay_templates.count() + + obj.module_bay_templates.count() + + obj.inventory_item_templates.count() + ) + def get_front_image(self, obj) -> str | None: return _img_url(self, obj.front_image) @@ -1403,6 +1428,13 @@ def get_rear_image(self, obj) -> str | None: "power-outlet", "front-port", "rear-port", "aux-port", } + # Photo markers place the port kinds PLUS the physical things that are not + # ports: hardware parts (disk bays, PSUs) and module bays (line-card + # slots). Those two are photo-only — the schematic faceplate stays + # port-only, and a module bay appears there as a group's `bay` placeholder + # instead. + _PHOTO_MARKER_KINDS = _FACEPLATE_SLOT_KINDS | {"inventory-item", "module-bay"} + def validate_faceplate(self, value): if value is None: return None @@ -1473,6 +1505,43 @@ def validate_faceplate(self, value): raise serializers.ValidationError("Too many slots (max 1024).") return value + # Port markers anchored on the front/rear photo. Shape-checked like the + # faceplate; names are NOT cross-checked against templates (renamed later → + # the renderer just drops the marker). + def validate_image_ports(self, value): + if value is None: + return None + if not isinstance(value, dict): + raise serializers.ValidationError( + 'image_ports must be {"front": [...], "rear": [...]}.') + total = 0 + for side in ("front", "rear"): + markers = value.get(side, []) + if not isinstance(markers, list): + raise serializers.ValidationError( + f"{side} must be a list of markers.") + total += len(markers) + for m in markers: + if not isinstance(m, dict): + raise serializers.ValidationError( + "Each marker must be an object.") + kind = m.get("kind", "interface") + if kind not in self._PHOTO_MARKER_KINDS: + raise serializers.ValidationError( + f"Unknown marker kind {kind!r}.") + name = m.get("name") + if not isinstance(name, str) or not name or len(name) > 64: + raise serializers.ValidationError( + "Markers need a name (≤64 chars).") + for k in ("x", "y", "w", "h"): + v = m.get(k) + if not isinstance(v, (int, float)) or not (0 <= v <= 1): + raise serializers.ValidationError( + f"Marker {k} must be a number in 0..1.") + if total > 512: + raise serializers.ValidationError("Too many markers (max 512).") + return value + lifecycle_state = serializers.ReadOnlyField() class Meta: @@ -1481,13 +1550,15 @@ class Meta: "owning_site", "owning_site_id", "permissions", "id", "name", "manufacturer", "manufacturer_id", "model", "part_number", "platform", "platform_id", "u_height", "rack_width", "description", - "front_image", "rear_image", "faceplate", + "front_image", "rear_image", "faceplate", "image_ports", "is_full_depth", "airflow", "weight", "weight_unit", "subdevice_role", "exclude_from_utilization", "custom_fields", *LIFECYCLE_FIELDS, - "tags", "tag_ids", "device_count", "created_at", "updated_at"] - read_only_fields = ["id", "device_count", "front_image", "rear_image", + "tags", "tag_ids", "device_count", "component_count", + "created_at", "updated_at"] + read_only_fields = ["id", "device_count", "component_count", + "front_image", "rear_image", "lifecycle_state", "created_at", "updated_at"] @@ -1529,6 +1600,8 @@ class DeviceSerializer(StatusSerializerMixin, ObjectPermsSerializerMixin, Custom role = serializers.SerializerMethodField() platform = serializers.SerializerMethodField() effective_platform = serializers.SerializerMethodField() + # Model property: the device's own airflow, else its type's default. + effective_airflow = serializers.CharField(read_only=True) location = serializers.SerializerMethodField() cluster = serializers.SerializerMethodField() @@ -1749,6 +1822,47 @@ def validate(self, attrs): # Full-width devices never carry a side — keep stale values out. side = "" attrs["rack_side"] = "" + + # ── Zero-U side mounting (vertical PDU strips) ─────────────────── + mount = attrs.get("mount", getattr(self.instance, "mount", "")) + if mount: + if rack is None: + raise serializers.ValidationError( + {"mount": "Side mounting needs a rack to bolt to."} + ) + if dt is not None and dt.u_height > 0: + raise serializers.ValidationError( + {"mount": "Only 0U device types side-mount — this type " + f"is {dt.u_height}U and takes a U position."} + ) + if position is not None: + raise serializers.ValidationError( + {"position": "A side-mounted device hangs on a rail — " + "it can't also occupy a U position."} + ) + if side: + raise serializers.ValidationError( + {"rack_side": "Half-width sides are for gear in a U — a " + "side-mounted strip hangs on the rail."} + ) + # `face` is NOT excluded: on a 0U strip it means which CHANNEL the + # thing bolts into (a vertical PDU usually lives in the rear), and + # the elevation draws it only on that face. Blank = visible from + # both, which is what everything mounted before this existed is. + span = attrs.get( + "mount_span_u", getattr(self.instance, "mount_span_u", None) + ) + if span is not None and span > rack.u_height: + raise serializers.ValidationError( + {"mount_span_u": f"Longer than the rack ({rack.u_height}U)."} + ) + else: + # No mount → the mount extras must not linger. + if attrs.get("mount_offset_mm") or attrs.get("mount_span_u"): + raise serializers.ValidationError( + {"mount": "Set a mount side before offset/span."} + ) + if rack is None or position is None: return attrs if width == "half" and not side: @@ -1828,13 +1942,14 @@ class Meta: "role", "role_id", "platform", "platform_id", "effective_platform", "rack", "rack_id", "position", "face", "rack_side", + "mount", "mount_offset_mm", "mount_span_u", "u_height", "rack_width", "location", "location_id", "cluster", "cluster_id", "virtual_chassis", "virtual_chassis_id", "vc_position", "vc_priority", "vc_renamed_interfaces", "config_template", "config_template_id", "status", "status_id", "serial_number", "asset_tag", - "description", "comments", "airflow", + "description", "comments", "airflow", "effective_airflow", "latitude", "longitude", "fov_direction", "fov_deg", "fov_distance_m", "fov_ptz", "primary_ip", "primary_ip_id", @@ -2052,10 +2167,11 @@ def validate(self, attrs): class Meta: model = Interface - fields = ["id", "device", "device_id", "name", "type", "type_display", + fields = ["id", "device", "device_id", "name", "snmp_name", "snmp_ignore", "type", + "type_display", "speed", "mtu", "enabled", "mgmt_only", "duplex", "poe_mode", "poe_type", - "wwn", "mac_address", "mac_addresses", + "wwn", "mac_address", "mac_addresses", "description", "mode", "mode_display", "vlan", "vlan_id", "tagged_vlans", "tagged_vlan_ids", "vrf", "vrf_id", "tags", "tag_ids", @@ -2177,7 +2293,7 @@ def validate(self, attrs): class Meta: model = RearPort fields = ["id", "device", "device_id", "name", "positions", - "is_splitter", "type", + "is_splitter", "type", "description", "tags", "tag_ids", "cable", "front_port_count", "created_at", "updated_at"] read_only_fields = ["id", "created_at", "updated_at"] @@ -2230,7 +2346,8 @@ class Meta: model = FrontPort fields = ["id", "device", "device_id", "name", "rear_port", "rear_port_id", "rear_port_position", "positions", "type", - "tags", "tag_ids", "cable", "created_at", "updated_at"] + "description", "tags", "tag_ids", "cable", + "created_at", "updated_at"] read_only_fields = ["id", "created_at", "updated_at"] @@ -2379,6 +2496,7 @@ class Meta(_ComponentTemplateSerializer.Meta): model = InventoryItemTemplate fields = _ComponentTemplateSerializer.Meta.fields + [ "manufacturer", "manufacturer_id", "part_id", + "kind", "media", "capacity_bytes", "speed", ] @@ -2466,7 +2584,9 @@ class Meta: read_only_fields = ["id", "created_at", "updated_at"] -class InventoryItemSerializer(TaggableSerializerMixin, NumIdModelSerializer): +class InventoryItemSerializer( + StatusSerializerMixin, TaggableSerializerMixin, NumIdModelSerializer +): device = DeviceMiniSerializer(read_only=True) device_id = TenantScopedPrimaryKeyRelatedField( source="device", queryset=Device.objects.all(), write_only=True, @@ -2516,6 +2636,8 @@ class Meta: fields = ["id", "device", "device_id", "parent", "parent_id", "name", "manufacturer", "manufacturer_id", "part_id", "serial_number", "asset_tag", "description", + "kind", "media", "capacity_bytes", "speed", + "status", "status_id", "tags", "tag_ids", "created_at", "updated_at"] read_only_fields = ["id", "created_at", "updated_at"] @@ -3316,6 +3438,93 @@ class Meta: read_only_fields = ["id", "rack_count", "created_at", "updated_at"] +class RackTypeAccessorySerializer(serializers.ModelSerializer): + """Factory-fitted 0U gear on a rack model (vertical PDU strips). The + device type must be 0U — accessories side-mount, they never take units.""" + + rack_type_id = TenantScopedPrimaryKeyRelatedField( + source="rack_type", queryset=RackType.objects.all(), write_only=True + ) + device_type = serializers.SerializerMethodField() + device_type_id = TenantScopedPrimaryKeyRelatedField( + source="device_type", queryset=DeviceType.objects.all(), write_only=True + ) + + @extend_schema_field(OpenApiTypes.OBJECT) + def get_device_type(self, obj): + dt = obj.device_type + return { + "id": str(dt.id), + "name": dt.name, + "manufacturer": dt.manufacturer.name if dt.manufacturer else None, + "u_height": dt.u_height, + } + + def validate(self, attrs): + dt = attrs.get("device_type", getattr(self.instance, "device_type", None)) + if dt is not None and dt.u_height != 0: + raise serializers.ValidationError( + {"device_type_id": + "Accessories side-mount on a rail: pick a 0U device type."} + ) + return attrs + + class Meta: + model = RackTypeAccessory + fields = ["id", "rack_type_id", "device_type", "device_type_id", + "label", "mount", "face", "mount_offset_mm", "mount_span_u", + "order", "created_at", "updated_at"] + read_only_fields = ["id", "created_at", "updated_at"] + + +class RackTypeMiniSerializer(NumIdModelSerializer): + """Picker/embed shape. Carries the full dimension set so the rack form + can pre-fill client-side — the rack stays the source of truth.""" + + manufacturer = serializers.SerializerMethodField() + + @extend_schema_field(OpenApiTypes.OBJECT) + def get_manufacturer(self, obj): + m = obj.manufacturer + return {"id": str(m.id), "name": m.name} if m else None + + class Meta: + model = RackType + fields = ["id", "name", "manufacturer", "width", "u_height", + "starting_unit", "desc_units", "outer_width_mm", + "outer_depth_mm", "max_weight", "max_weight_unit"] + + +class RackTypeSerializer(TaggableSerializerMixin, NumIdModelSerializer): + manufacturer = ManufacturerMiniSerializer(read_only=True) + manufacturer_id = TenantScopedPrimaryKeyRelatedField( + source="manufacturer", queryset=Manufacturer.objects.all(), + write_only=True, required=False, allow_null=True, + ) + tags = TagSerializer(many=True, read_only=True) + tag_ids = TenantScopedPrimaryKeyRelatedField( + source="tags", queryset=Tag.objects.all(), + write_only=True, required=False, many=True, + ) + accessories = RackTypeAccessorySerializer(many=True, read_only=True) + rack_count = serializers.SerializerMethodField() + + def get_rack_count(self, obj) -> int: + v = getattr(obj, "rack_count_annotated", None) + return v if v is not None else obj.racks.count() + + class Meta: + model = RackType + fields = ["id", "name", "manufacturer", "manufacturer_id", + "width", "u_height", "starting_unit", "desc_units", + "outer_width_mm", "outer_depth_mm", + "max_weight", "max_weight_unit", "description", + "accessories", "rack_count", + "tags", "tag_ids", "created_at", "updated_at"] + read_only_fields = ["id", "accessories", "rack_count", + "created_at", "updated_at"] + + class RackMiniSerializer(NumIdModelSerializer): class Meta: model = Rack @@ -3332,6 +3541,17 @@ class RackSerializer(StatusSerializerMixin, TaggableSerializerMixin, NumIdModelS source="role", queryset=RackRole.objects.all(), write_only=True, required=False, allow_null=True, ) + rack_type = RackTypeMiniSerializer(read_only=True) + rack_type_id = TenantScopedPrimaryKeyRelatedField( + source="rack_type", queryset=RackType.objects.all(), + write_only=True, required=False, allow_null=True, + ) + # Create-only opt-in: stamp the rack type's accessories as side-mounted + # devices named "{rack}-{label}". Ignored on update (re-stamping an + # existing rack would duplicate its strips). + create_accessories = serializers.BooleanField( + write_only=True, required=False, default=False + ) location = serializers.SerializerMethodField() location_id = TenantScopedPrimaryKeyRelatedField( source="location", queryset=Location.objects.all(), @@ -3361,6 +3581,74 @@ def validate(self, attrs): ) return attrs + def create(self, validated_data): + stamp = validated_data.pop("create_accessories", False) + rack_type = validated_data.get("rack_type") + if stamp and rack_type is not None: + # Authorize BEFORE creating anything: stamping writes Devices, so + # a caller without device-add scope at the rack's site must get a + # clean 403 with no partial rack left behind. + self._assert_can_stamp_devices(validated_data.get("site")) + with transaction.atomic(): + rack = super().create(validated_data) + if stamp and rack.rack_type_id: + self._stamp_accessories(rack) + return rack + + def update(self, instance, validated_data): + validated_data.pop("create_accessories", None) + return super().update(instance, validated_data) + + def _assert_can_stamp_devices(self, site): + from rest_framework.exceptions import PermissionDenied + + from auth_api import rbac + + request = self.context.get("request") + user = getattr(request, "user", None) if request else None + if user is None or not getattr(user, "is_authenticated", False): + raise PermissionDenied("Authentication required.") + if user.is_superuser: + return + from api.views import _get_active_tenant + + tenant = _get_active_tenant(request) + # None = unrestricted; set() = no device-add grant at all; + # {ids} = site-scoped — the rack's site must be inside the scope. + scope = rbac.site_scope(user, tenant, "device", "add") + if scope is None: + return + if site is not None and site.pk in scope: + return + raise PermissionDenied( + "Stamping accessories requires permission to add devices " + "at the rack's site." + ) + + def _stamp_accessories(self, rack): + """One side-mounted 0U device per accessory, named {rack}-{label} + (deduped with -2/-3… against the tenant's device names). Components + materialise from the type's templates — a PDU gets its outlets.""" + for acc in rack.rack_type.accessories.select_related("device_type").all(): + base = f"{rack.name}-{acc.label}" + name, n = base, 2 + while Device.objects.filter(tenant=rack.tenant, name=name).exists(): + name = f"{base}-{n}" + n += 1 + device = Device.objects.create( + tenant=rack.tenant, + name=name, + site=rack.site, + location=rack.location, + rack=rack, + device_type=acc.device_type, + mount=acc.mount, + face=acc.face, + mount_offset_mm=acc.mount_offset_mm, + mount_span_u=acc.mount_span_u, + ) + materialize_device_components(device) + def get_device_count(self, obj) -> int: return obj.devices.count() @@ -3373,7 +3661,11 @@ def get_used_units(self, obj) -> int: continue if d.device_type and d.device_type.exclude_from_utilization: continue # blanking panels / cable management don't count - h = (d.device_type.u_height if d.device_type else 1) or 1 + h = d.device_type.u_height if d.device_type else 1 + if h <= 0: + # 0U gear (vertical strips, shelf appliances) occupies no + # units — the old `or 1` here charged each one a full U. + continue units.update(range(d.position, d.position + h)) return len(units) @@ -3399,7 +3691,7 @@ def get_max_weight_kg(self, obj) -> float | None: @extend_schema_field(OpenApiTypes.OBJECT) def get_power(self, obj): """Rack power rollup. Supply = primary feeds delivered to the rack - (V × A × max-utilisation%, three-phase × √3, NetBox semantics). + (V × A × max-utilisation%, three-phase × √3). Demand = the racked devices' power-port draws — allocated where recorded, with the nameplate (maximum) sum alongside.""" available = 0.0 @@ -3412,6 +3704,12 @@ def get_power(self, obj): available += watts allocated = maximum = 0 for d in obj.devices.all(): + # A device WITH outlets is a distributor (a PDU): its inlet draw + # restates its children's draws, so counting both doubled the + # rack's demand. Distributors contribute supply topology, not + # demand. + if d.power_outlets.exists(): + continue for pp in d.power_ports.all(): allocated += pp.allocated_draw or 0 maximum += pp.maximum_draw or 0 @@ -3424,8 +3722,10 @@ def get_power(self, obj): class Meta: model = Rack fields = ["id", "numid", "name", "facility_id", "site", "site_id", "role", - "role_id", "status", "status_id", "location", "location_id", + "role_id", "rack_type", "rack_type_id", "create_accessories", + "status", "status_id", "location", "location_id", "width", "u_height", + "outer_width_mm", "outer_depth_mm", "max_weight", "max_weight_unit", "total_weight_kg", "max_weight_kg", "power", "starting_unit", "desc_units", "description", @@ -5043,7 +5343,8 @@ class Meta: model = FloorTileType fields = ["id", "numid", "name", "slug", "color", "icon", "default_width", "default_height", "is_zone", "has_fov", - "description", "tile_count", "created_at", "updated_at"] + "perforated", "description", "tile_count", + "created_at", "updated_at"] read_only_fields = ["id", "numid", "tile_count", "created_at", "updated_at"] @@ -5109,7 +5410,8 @@ def validate(self, attrs): class Meta: model = FloorPlan fields = ["id", "numid", "name", "location", "location_id", "site", - "grid_width", "grid_height", "background_image", + "grid_width", "grid_height", "cell_mm", "ceiling_mm", + "background_image", "background_opacity", "state", "description", "tile_count", "tags", "tag_ids", "custom_fields", "created_at", "updated_at"] @@ -5240,6 +5542,27 @@ class Meta: read_only_fields = ["id", "linked", "created_at", "updated_at"] +def validate_lattice_points(v): + """Shared geometry rule for plan polylines (trays, walls): 2–256 [x, y] + pairs inside the grid, snapped to the half-cell lattice — twice as fine + as the tile grid, so runs can follow tile boundaries OR centrelines.""" + if not isinstance(v, list) or not (2 <= len(v) <= 256): + raise serializers.ValidationError( + "points must be a list of 2–256 [x, y] pairs" + ) + for p in v: + if ( + not isinstance(p, (list, tuple)) + or len(p) != 2 + or not all(isinstance(n, (int, float)) for n in p) + or not all(0 <= n <= 512 for n in p) + ): + raise serializers.ValidationError( + "each point must be an [x, y] pair within the grid" + ) + return [[round(p[0] * 2) / 2, round(p[1] * 2) / 2] for p in v] + + class FloorPlanTraySerializer(NumIdModelSerializer): """A tray/conduit run on a plan. Cables are assigned manually in v1 — the tray lists the physical cables routed through it.""" @@ -5269,31 +5592,149 @@ def get_cables(self, obj): ] def validate_points(self, v): - # Trays route on a half-cell lattice (twice as fine as the tile grid), - # so cells can carry parallel/crossing runs. Values are in cell units - # snapped to the nearest 0.5. - if not isinstance(v, list) or not (2 <= len(v) <= 256): + # Shared with walls: same half-cell lattice, same snap. + return validate_lattice_points(v) + + class Meta: + model = FloorPlanTray + fields = ["id", "floor_plan_id", "name", "kind", "color", "points", + "level", "elevation_mm", + "description", "cables", "cable_ids", + "created_at", "updated_at"] + read_only_fields = ["id", "cables", "created_at", "updated_at"] + + +class FloorPlanWallSerializer(NumIdModelSerializer): + """A wall polyline with door/passage ``openings``. Doors are JSON spans on + the wall (not child rows): v1 doors carry no metadata of their own, and + one PATCH keeps wall + doors atomic — the tray-points precedent. + + v1 walls are documentation geometry; they do not constrain routing.""" + + floor_plan_id = TenantScopedPrimaryKeyRelatedField( + source="floor_plan", queryset=FloorPlan.objects.all(), + write_only=True, required=False, + ) + points = serializers.JSONField(required=False) + openings = serializers.JSONField(required=False) + + def validate_points(self, v): + return validate_lattice_points(v) + + def validate(self, attrs): + attrs = super().validate(attrs) + points = attrs.get( + "points", self.instance.points if self.instance else None + ) + openings = attrs.get( + "openings", self.instance.openings if self.instance else [] + ) + if not openings: + return attrs + if not points: raise serializers.ValidationError( - "points must be a list of 2–256 [x, y] pairs" + {"openings": "Openings need wall points to sit on."} ) - for p in v: + import math + + seg_lens = [ + math.hypot(b[0] - a[0], b[1] - a[1]) + for a, b in zip(points, points[1:]) + ] + if not isinstance(openings, list) or len(openings) > 64: + raise serializers.ValidationError( + {"openings": "openings must be a list of at most 64 spans"} + ) + wall_h = attrs.get( + "height_mm", self.instance.height_mm if self.instance else None + ) + by_seg: dict[int, list[tuple[float, float]]] = {} + for o in openings: + if not isinstance(o, dict): + raise serializers.ValidationError( + {"openings": "each opening must be an object"} + ) + seg = o.get("seg") + start, end = o.get("from"), o.get("to") if ( - not isinstance(p, (list, tuple)) - or len(p) != 2 - or not all(isinstance(n, (int, float)) for n in p) - or not all(0 <= n <= 512 for n in p) + not isinstance(seg, int) + or not (0 <= seg < len(seg_lens)) + or not isinstance(start, (int, float)) + or not isinstance(end, (int, float)) + or not (0 <= start < end <= seg_lens[seg] + 1e-6) ): raise serializers.ValidationError( - "each point must be an [x, y] pair within the grid" + {"openings": "each opening needs seg + 0 ≤ from < to ≤ " + "that segment's length (cell units)"} ) - return [[round(p[0] * 2) / 2, round(p[1] * 2) / 2] for p in v] + h = o.get("height_mm") + if h is not None and ( + not isinstance(h, int) + or h < 200 + or (wall_h is not None and h > wall_h) + ): + raise serializers.ValidationError( + {"openings": "opening height must be ≥200 mm and not " + "taller than the wall"} + ) + spans = by_seg.setdefault(seg, []) + if any(start < e and s < end for s, e in spans): + raise serializers.ValidationError( + {"openings": "openings on one segment must not overlap"} + ) + spans.append((float(start), float(end))) + return attrs class Meta: - model = FloorPlanTray - fields = ["id", "floor_plan_id", "name", "kind", "color", "points", - "description", "cables", "cable_ids", - "created_at", "updated_at"] - read_only_fields = ["id", "cables", "created_at", "updated_at"] + model = FloorPlanWall + fields = ["id", "floor_plan_id", "label", "points", "height_mm", + "color", "openings", "created_at", "updated_at"] + read_only_fields = ["id", "created_at", "updated_at"] + + +class FloorPlanRaisedFloorAreaSerializer(NumIdModelSerializer): + """A raised-floor rectangle on a plan. Areas may not overlap — the plenum + under any grid point must be unambiguous, because it drives underfloor + tray depth in the 3D room and the drop term in route-length estimates.""" + + floor_plan_id = TenantScopedPrimaryKeyRelatedField( + source="floor_plan", queryset=FloorPlan.objects.all(), + write_only=True, required=False, + ) + + def validate(self, attrs): + attrs = super().validate(attrs) + plan = attrs.get("floor_plan") or ( + self.instance.floor_plan if self.instance else None + ) + if plan is None: + return attrs + get = lambda k: attrs.get( # noqa: E731 — tiny create-or-update getter + k, getattr(self.instance, k, None) if self.instance else None + ) + x, y = get("x") or 0, get("y") or 0 + w, h = get("width") or 1, get("height") or 1 + if x + w > plan.grid_width or y + h > plan.grid_height: + raise serializers.ValidationError( + "The area must fit inside the plan grid." + ) + siblings = plan.raised_floor_areas.all() + if self.instance: + siblings = siblings.exclude(pk=self.instance.pk) + for s in siblings: + if x < s.x + s.width and s.x < x + w and y < s.y + s.height and s.y < y + h: + raise serializers.ValidationError( + f"Overlaps {s.label or 'another raised-floor area'} — " + "areas must not overlap so the plenum depth under any " + "point stays unambiguous." + ) + return attrs + + class Meta: + model = FloorPlanRaisedFloorArea + fields = ["id", "floor_plan_id", "x", "y", "width", "height", + "plenum_mm", "label", "color", "created_at", "updated_at"] + read_only_fields = ["id", "created_at", "updated_at"] class CableRouteSerializer(NumIdModelSerializer): diff --git a/api/signals.py b/api/signals.py new file mode 100644 index 00000000..5f54f014 --- /dev/null +++ b/api/signals.py @@ -0,0 +1,67 @@ +"""Model signals that keep load-bearing constraints satisfiable. + +Nothing here implements product behaviour — each receiver exists because a +constraint the schema deliberately enforces would otherwise make an ordinary +operation impossible. +""" + +import logging + +from django.db.models.signals import pre_delete +from django.dispatch import receiver + +log = logging.getLogger(__name__) + + +@receiver(pre_delete, sender="api.Interface", dispatch_uid="api.release_macs") +def release_macs_before_interface_delete(sender, instance, **kwargs): + """Keep an interface's MACs from colliding as they are orphaned. + + ``MACAddress.assigned_interface`` is ``SET_NULL`` so a MAC outlives the + port that bore it — it's a first-class object with its own tags and + history. But ``uniq_macaddress_tenant_addr_iface`` spans + ``(tenant, mac_address, assigned_interface)`` with ``nulls_distinct=False``, + so NULL counts as a value: at most one *unassigned* row per address per + tenant. Orphaning a MAC that already exists unassigned — routine once + discovery has seen the same address twice — violates it, and the delete + fails with a 409 that no amount of retrying fixes. + + So decide per MAC, before the cascade's own UPDATE runs: + + * an unassigned twin already exists → drop this row, the address is + already on file unassigned; + * otherwise → unassign it here, so it becomes that twin. + + Unassigning eagerly (rather than leaving it to the cascade) is what makes + a batch safe: when two interfaces in one delete bear the same MAC, the + second receiver call sees the first one's now-unassigned row and drops its + duplicate instead of racing it to NULL. The cascade's later UPDATE then + either re-NULLs a row that is already NULL or matches nothing — both + no-ops. + + Instance-level ``delete()``/``save()`` are deliberate: they keep the audit + trail honest about a MAC that was dropped or unassigned. + """ + from .models import MACAddress + + for mac in instance.mac_addresses.all(): + twin_exists = ( + MACAddress.objects.filter( + tenant_id=mac.tenant_id, + mac_address=mac.mac_address, + assigned_interface__isnull=True, + ) + .exclude(pk=mac.pk) + .exists() + ) + if twin_exists: + log.info( + "Dropping MAC %s from deleted interface %s — already on file " + "unassigned for this tenant.", + mac.mac_address, + instance.pk, + ) + mac.delete() + else: + mac.assigned_interface = None + mac.save(update_fields=["assigned_interface", "updated_at"]) diff --git a/api/status_registry.py b/api/status_registry.py index 508ca26d..d7459daf 100644 --- a/api/status_registry.py +++ b/api/status_registry.py @@ -24,6 +24,7 @@ ("wirelesslan", "Wireless LANs"), ("tunnel", "Tunnels"), ("location", "Locations"), + ("inventoryitem", "Inventory items"), ] STATUSABLE_MODEL_VALUES = {m[0] for m in STATUSABLE_MODELS} @@ -46,6 +47,8 @@ "inventory": "#a1a1aa", "container": "#a1a1aa", "disabled": "#a1a1aa", + "spare": "#a1a1aa", + "empty": "#71717a", "not_connected": "#71717a", "decommissioned": "#71717a", "retired": "#71717a", @@ -67,6 +70,7 @@ ("wirelesslan", "WirelessLAN", "active"), ("tunnel", "Tunnel", "active"), ("location", "Location", "active"), + ("inventoryitem", "InventoryItem", "active"), ] # Built-in status values per object type, mirroring the historical per-model @@ -88,6 +92,9 @@ "wirelesslan": ["active", "reserved", "disabled", "deprecated"], "tunnel": ["planned", "active", "disabled"], "location": ["active", "planned", "decommissioning", "retired"], + # Hardware parts: health/lifecycle — "failed" lights the faceplate red, + # "empty" is a bay a chassis template stamped that holds nothing. + "inventoryitem": ["active", "planned", "failed", "spare", "empty"], } # model slug → default status value (None for ipaddress → fall back to "active"). diff --git a/api/tests.py b/api/tests.py index e443eda1..08ed0644 100644 --- a/api/tests.py +++ b/api/tests.py @@ -610,3 +610,22 @@ def test_used_units_counts_shared_unit_once(self): self._post("sw2", self.dt_half, 10, side="right") r = self.client.get(f"/api/racks/{self.rack.id}/") self.assertEqual(r.json()["used_units"], 1) + + def test_outer_dimensions_roundtrip(self): + # Blank by default (renderers derive plausible values). + r = self.client.get(f"/api/racks/{self.rack.id}/") + self.assertIsNone(r.json()["outer_width_mm"]) + self.assertIsNone(r.json()["outer_depth_mm"]) + r = self.client.patch( + f"/api/racks/{self.rack.id}/", + {"outer_width_mm": 600, "outer_depth_mm": 1200}, + format="json", + ) + self.assertEqual(r.status_code, 200, r.content) + self.assertEqual(r.json()["outer_width_mm"], 600) + self.assertEqual(r.json()["outer_depth_mm"], 1200) + # Validator bounds enforced. + r = self.client.patch( + f"/api/racks/{self.rack.id}/", {"outer_depth_mm": 9}, format="json" + ) + self.assertEqual(r.status_code, 400) diff --git a/api/tests_catalog_scope.py b/api/tests_catalog_scope.py index f0419d88..2516e568 100644 --- a/api/tests_catalog_scope.py +++ b/api/tests_catalog_scope.py @@ -12,7 +12,10 @@ from django.contrib.auth.models import User from rest_framework.test import APITestCase -from api.models import Device, DeviceType, Manufacturer, Site, VLAN, Zone +from api.models import ( + Device, DeviceType, InterfaceTemplate, Manufacturer, Site, VLAN, Zone, +) +from audit.models import ChangeAction, ChangeLogEntry from auth_api.models import ObjectPermission, UserProfile from core.models import DeploymentSettings, Organization, Tag, Tenant @@ -191,6 +194,123 @@ def test_flag_off_create_lands_global(self): self.assertIsNone(DeviceType.objects.get(name="GT2").owning_site_id) +class DeviceTypeBulkDeleteTests(_CatalogBase): + """``POST /api/device-types/bulk-delete/``. + + The id list is a *request*, never a grant: tenant and (with separation on) + site scope are re-derived server-side, so an id the caller may see but not + delete — or one they can't even see — falls out of the set. + """ + + def _post(self, ids): + return self.client.post( + "/api/device-types/bulk-delete/", + {"ids": [str(i) for i in ids]}, + format="json", + ) + + def test_deletes_selection_and_counts_types_not_cascade(self): + """A type takes its component templates with it, but the count the + operator is shown is types — the toast must not say "49 deleted" + because one switch had 48 interface templates.""" + self._login(self.hq) + keep = DeviceType.objects.create( + tenant=self.tenant, manufacturer=self.mfr_global, name="K", model="K" + ) + for n in ("eth0", "eth1", "eth2"): + InterfaceTemplate.objects.create(device_type=self.dt_global, name=n) + res = self._post([self.dt_global.id, self.dt_b.id]) + self.assertEqual(res.status_code, 200, res.content) + self.assertEqual(res.json(), {"deleted": 2}) + self.assertFalse(DeviceType.objects.filter(pk=self.dt_global.pk).exists()) + self.assertFalse(DeviceType.objects.filter(pk=self.dt_b.pk).exists()) + self.assertTrue(DeviceType.objects.filter(pk=keep.pk).exists()) + self.assertEqual(InterfaceTemplate.objects.count(), 0) # cascaded + + def test_devices_survive_and_lose_their_type_reference(self): + """``Device.device_type`` is SET_NULL — the warning the bulk confirm + shows is the real behaviour, not a scare message.""" + self._login(self.hq) + dev = Device.objects.create( + tenant=self.tenant, name="sw-1", device_type=self.dt_global, + site=self.a, + ) + res = self._post([self.dt_global.id]) + self.assertEqual(res.status_code, 200, res.content) + dev.refresh_from_db() + self.assertIsNone(dev.device_type_id) + + def test_delete_grant_required(self): + """A view+change editor must not be able to empty the catalog: the + @action default maps to `change`, which is why the viewset pins + bulk_delete → delete.""" + editor = User.objects.create_user("editor", password="x") + UserProfile.objects.create(user=editor).tenants.add(self.tenant) + grant = ObjectPermission.objects.create( + name="dt-editor", object_types=["devicetype"], + actions=["view", "change"], + ) + grant.users.add(editor) + self._login(editor) + res = self._post([self.dt_global.id]) + self.assertEqual(res.status_code, 403, res.content) + self.assertTrue(DeviceType.objects.filter(pk=self.dt_global.pk).exists()) + + def test_ids_outside_tenant_and_site_scope_are_not_deleted(self): + """The important one. A site-A editor submits four ids: their own local + type, a type local to site B, a tenant-global type they can see but not + write, and one belonging to another tenant entirely. Only their own + dies, and the count reflects that — no silent cross-scope deletion.""" + other_org = Organization.objects.create(name="OO", slug="oo") + other_tenant = Tenant.objects.create(org=other_org, name="TO", slug="to") + other_mfr = Manufacturer.objects.create( + tenant=other_tenant, name="OtherCo", slug="otherco" + ) + foreign = DeviceType.objects.create( + tenant=other_tenant, manufacturer=other_mfr, name="FT", model="FT" + ) + mine = DeviceType.objects.create( + tenant=self.tenant, manufacturer=self.mfr_global, + name="A-own", model="A-own", owning_site=self.a, + ) + + self._login(self.site_user) + res = self._post([mine.id, self.dt_b.id, self.dt_global.id, foreign.id]) + self.assertEqual(res.status_code, 200, res.content) + self.assertEqual(res.json(), {"deleted": 1}) + + self.assertFalse(DeviceType.objects.filter(pk=mine.pk).exists()) + self.assertTrue(DeviceType.objects.filter(pk=self.dt_b.pk).exists()) + self.assertTrue(DeviceType.objects.filter(pk=self.dt_global.pk).exists()) + self.assertTrue(DeviceType.objects.filter(pk=foreign.pk).exists()) + + def test_writes_exactly_one_audit_entry_per_deleted_type(self): + """A catalog wipe must leave a trace — and exactly one per row. The + cascade means Django can't fast-delete, so post_delete fires for every + DeviceType; an extra explicit log_bulk_delete() would double every + entry (which is what the sibling bulk-delete endpoints do today).""" + self._login(self.hq) + ChangeLogEntry.objects.all().delete() + res = self._post([self.dt_global.id, self.dt_b.id]) + self.assertEqual(res.status_code, 200, res.content) + entries = ChangeLogEntry.objects.filter( + object_type="api.devicetype", action=ChangeAction.DELETE + ) + self.assertEqual(entries.count(), 2) + self.assertEqual( + {e.object_id for e in entries}, + {str(self.dt_global.id), str(self.dt_b.id)}, + ) + self.assertEqual({e.user_name for e in entries}, {"hq"}) + + def test_empty_id_list_is_a_400(self): + self._login(self.hq) + res = self.client.post( + "/api/device-types/bulk-delete/", {"ids": []}, format="json" + ) + self.assertEqual(res.status_code, 400) + + class TagTenancyTests(APITestCase): """Tags are tenant-scoped (flag-independent): no cross-tenant visibility; legacy NULL-tenant tags are readable everywhere, superuser-writable.""" diff --git a/api/tests_component_bulk.py b/api/tests_component_bulk.py index fccce5c7..9fc4438a 100644 --- a/api/tests_component_bulk.py +++ b/api/tests_component_bulk.py @@ -7,8 +7,8 @@ from rest_framework.test import APIClient from api.models import ( - ConsolePort, Device, DeviceType, Interface, InterfaceTemplate, - VLAN, VirtualMachine, Cluster, ClusterType, VMInterface, + ConsolePort, Device, DeviceType, FrontPort, Interface, InterfaceTemplate, + RearPort, VLAN, VirtualMachine, Cluster, ClusterType, VMInterface, ) from core.models import Organization, Tag, Tenant @@ -150,6 +150,27 @@ def test_console_ports_and_bulk_delete(self): self.assertEqual(r.status_code, 200, r.content) self.assertEqual(ConsolePort.objects.count(), 0) + def test_description_bulk_editable_on_ports_and_interfaces(self): + # The bulk bar offers Description on every component table, so the + # allow-list and the model must agree on all of them. + rear = RearPort.objects.create(device=self.dev, name="R1", positions=2) + front = FrontPort.objects.create( + device=self.dev, name="F1", rear_port=rear + ) + for path, obj in ( + ("interfaces", self.if1), + ("rear-ports", rear), + ("front-ports", front), + ): + r = self.client_api.post( + f"/api/{path}/bulk-update/", + {"ids": [str(obj.id)], "fields": {"description": "row 3"}}, + format="json", + ) + self.assertEqual(r.status_code, 200, r.content) + obj.refresh_from_db() + self.assertEqual(obj.description, "row 3") + def test_interface_templates(self): t1 = InterfaceTemplate.objects.create(device_type=self.dt, name="eth0") r = self.client_api.post( diff --git a/api/tests_device_library.py b/api/tests_device_library.py new file mode 100644 index 00000000..d327de55 --- /dev/null +++ b/api/tests_device_library.py @@ -0,0 +1,241 @@ +"""Portable device-type bundles — export, import, and the rules that keep an +imported bundle from doing something the importer didn't ask for. +""" +from __future__ import annotations + +from django.contrib.auth import get_user_model +from rest_framework.test import APITestCase + +from core.models import Organization, Tenant +from monitoring.models import SnmpSensor + +from .models import ( + DeviceType, + FrontPortTemplate, + InterfaceTemplate, + InventoryItemTemplate, + Manufacturer, + PowerOutletTemplate, + PowerPortTemplate, + RearPortTemplate, +) + +User = get_user_model() + +FACEPLATE = { + "v": 1, "rear": [], + "front": [{"id": "a", "rows": 1, "bank": 0, + "slots": [{"t": "port", "name": "Gi1/0/1"}]}], +} +IMAGE_PORTS = { + "front": [{"kind": "interface", "name": "Gi1/0/1", + "x": 0.1, "y": 0.5, "w": 0.03, "h": 0.4}], + "rear": [], +} + + +class DeviceBundleTests(APITestCase): + def setUp(self): + self.org = Organization.objects.create(name="Acme", slug="acme") + self.tenant = Tenant.objects.create(org=self.org, name="Acme", slug="acme") + admin = User.objects.create_superuser("admin", "a@example.com", "x") + self.client.force_login(admin) + session = self.client.session + session["current_tenant_id"] = str(self.tenant.id) + session.save() + + self.mfr = Manufacturer.objects.create(tenant=self.tenant, name="Lenovo") + self.dt = DeviceType.objects.create( + tenant=self.tenant, name="System x3650 M5", manufacturer=self.mfr, + u_height=2, faceplate=FACEPLATE, image_ports=IMAGE_PORTS, + description="A test chassis", + ) + InterfaceTemplate.objects.create( + device_type=self.dt, name="Gi1/0/1", type="1000base-t", + description="uplink", + ) + inlet = PowerPortTemplate.objects.create( + device_type=self.dt, name="Psu 1", maximum_draw=750, + ) + PowerOutletTemplate.objects.create( + device_type=self.dt, name="Out 1", power_port_template=inlet, + feed_leg="A", + ) + rear = RearPortTemplate.objects.create( + device_type=self.dt, name="Rear 1", positions=12, + ) + FrontPortTemplate.objects.create( + device_type=self.dt, name="Front 1", rear_port_template=rear, + rear_port_position=3, + ) + InventoryItemTemplate.objects.create( + device_type=self.dt, name="disk0", kind="disk", media="nvme", + ) + self.sensor = SnmpSensor.objects.create( + tenant=self.tenant, name="Drive health", slug="drive-health", + device_type=self.dt, oid="1.3.6.1.4.1.2.3.51.3.1.12.2.1.3", + walk=True, item_kind="disk", name_template="disk{index}", + value_map={"Normal": "active", "Critical": "failed"}, + absent_status="empty", + # Deliberately auto here, to prove import forces it back to drift. + apply_mode=SnmpSensor.APPLY_AUTO, + ) + + def _export(self): + resp = self.client.get(f"/api/device-types/{self.dt.id}/library-export/") + self.assertEqual(resp.status_code, 200, resp.content) + return resp.json() + + def _import(self, bundle, **params): + qs = "&".join(f"{k}={v}" for k, v in params.items()) + return self.client.post( + f"/api/device-types/import-bundle/{'?' + qs if qs else ''}", + bundle, format="json", + ) + + # ── export ─────────────────────────────────────────────────────────────── + + def test_export_carries_the_whole_model_setup(self): + b = self._export() + self.assertEqual(b["danbyte_device_type"], 1) + self.assertEqual(b["name"], "System x3650 M5") + self.assertEqual(b["manufacturer"], "Lenovo") + self.assertEqual(b["u_height"], 2) + self.assertEqual(b["faceplate"], FACEPLATE) + self.assertEqual(b["image_ports"], IMAGE_PORTS) + c = b["components"] + self.assertEqual(c["interfaces"][0]["name"], "Gi1/0/1") + self.assertEqual(c["power_ports"][0]["maximum_draw"], 750) + self.assertEqual(c["inventory_items"][0]["media"], "nvme") + self.assertEqual(len(b["sensors"]), 1) + self.assertEqual(b["sensors"][0]["oid"], "1.3.6.1.4.1.2.3.51.3.1.12.2.1.3") + + def test_export_references_components_by_name_not_id(self): + c = self._export()["components"] + # Ids are per-deployment; the far side re-resolves these by name. + self.assertEqual(c["power_outlets"][0]["power_port"], "Psu 1") + self.assertEqual(c["front_ports"][0]["rear_port"], "Rear 1") + for rows in c.values(): + for row in rows: + self.assertNotIn("id", row) + + def test_export_leaks_no_credentials_or_local_ids(self): + b = self._export() + blob = str(b) + for forbidden in ("secret", "community", "password", "token", + str(self.tenant.id), str(self.dt.id)): + self.assertNotIn(forbidden, blob, forbidden) + # apply_mode is a local policy decision, not part of the definition. + self.assertNotIn("apply_mode", b["sensors"][0]) + + # ── import ─────────────────────────────────────────────────────────────── + + def _reimport_into_clean_tenant(self, bundle): + """Wipe the local copy so the import is a genuine first-time create.""" + SnmpSensor.objects.all().delete() + self.dt.delete() + return self._import(bundle) + + def test_round_trip_rebuilds_everything(self): + bundle = self._export() + resp = self._reimport_into_clean_tenant(bundle) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()["action"], "create") + + dt = DeviceType.objects.get(tenant=self.tenant, name="System x3650 M5") + self.assertEqual(dt.manufacturer.name, "Lenovo") + self.assertEqual(dt.u_height, 2) + self.assertEqual(dt.faceplate, FACEPLATE) + self.assertEqual(dt.image_ports, IMAGE_PORTS) + self.assertEqual(dt.interface_templates.count(), 1) + self.assertEqual(dt.inventory_item_templates.get().media, "nvme") + # The by-name cross-references got re-hooked to the NEW rows. + self.assertEqual( + dt.power_outlet_templates.get().power_port_template.name, "Psu 1" + ) + front = dt.front_port_templates.get() + self.assertEqual(front.rear_port_template.name, "Rear 1") + self.assertEqual(front.rear_port_position, 3) + + def test_imported_sensor_cannot_write_intent(self): + """The rule that matters: a bundle from someone else must never arrive + able to overwrite a status a human here set.""" + bundle = self._export() + self._reimport_into_clean_tenant(bundle) + s = SnmpSensor.objects.get(tenant=self.tenant, slug="drive-health") + self.assertEqual(s.apply_mode, SnmpSensor.APPLY_DRIFT) + self.assertEqual(s.value_map, {"Normal": "active", "Critical": "failed"}) + self.assertEqual(s.device_type_id, DeviceType.objects.get().id) + + def test_dry_run_writes_nothing_but_reports_the_plan(self): + bundle = self._export() + SnmpSensor.objects.all().delete() + self.dt.delete() + resp = self._import(bundle, dry_run=1) + self.assertEqual(resp.status_code, 200, resp.content) + body = resp.json() + self.assertTrue(body["dry_run"]) + self.assertEqual(body["action"], "create") + self.assertEqual(body["components"]["interfaces"], 1) + self.assertTrue(body["faceplate"]) + self.assertFalse(DeviceType.objects.exists()) + + def test_existing_type_is_skipped_without_replace(self): + bundle = self._export() + bundle["u_height"] = 9 + resp = self._import(bundle) + self.assertEqual(resp.json()["action"], "skipped") + self.dt.refresh_from_db() + self.assertEqual(self.dt.u_height, 2) # untouched + + resp = self._import(bundle, replace=1) + self.assertEqual(resp.json()["action"], "update") + self.dt.refresh_from_db() + self.assertEqual(self.dt.u_height, 9) + + def test_missing_photo_is_reported_not_silently_broken(self): + """Marker coordinates are meaningless without the image they were placed + on, so the report has to say so rather than importing dead markers.""" + bundle = self._export() + bundle["images"] = {"front": True, "rear": False} + SnmpSensor.objects.all().delete() + self.dt.delete() + body = self._import(bundle).json() + self.assertEqual(body["missing_images"], ["front"]) + self.assertTrue(any("upload it" in w for w in body["warnings"])) + + def test_front_port_naming_a_missing_rear_port_is_dropped_loudly(self): + bundle = self._export() + bundle["components"]["rear_ports"] = [] + SnmpSensor.objects.all().delete() + self.dt.delete() + body = self._import(bundle).json() + dt = DeviceType.objects.get() + self.assertEqual(dt.front_port_templates.count(), 0) + self.assertTrue(any("rear port" in w for w in body["warnings"])) + + def test_rejects_anything_that_is_not_a_bundle(self): + for bad in ( + {"sensors": []}, # no envelope + {"danbyte_device_type": 99, "name": "x"}, # wrong version + {"danbyte_device_type": 1}, # no name + {"danbyte_device_type": 1, "name": "x", "components": "nope"}, + ): + self.assertEqual(self._import(bad).status_code, 400, bad) + + def test_import_lands_in_the_active_tenant_only(self): + other_org = Organization.objects.create(name="Evil", slug="evil") + other = Tenant.objects.create(org=other_org, name="Evil", slug="evil") + bundle = self._export() + bundle["name"] = "Imported Chassis" + self._import(bundle) + self.assertTrue( + DeviceType.objects.filter(tenant=self.tenant, name="Imported Chassis") + .exists() + ) + self.assertFalse(DeviceType.objects.filter(tenant=other).exists()) + + def test_export_requires_view_access(self): + self.client.logout() + resp = self.client.get(f"/api/device-types/{self.dt.id}/library-export/") + self.assertIn(resp.status_code, (401, 403)) diff --git a/api/tests_devicetype_import.py b/api/tests_devicetype_import.py index 565261b1..f0873dec 100644 --- a/api/tests_devicetype_import.py +++ b/api/tests_devicetype_import.py @@ -1,11 +1,20 @@ """Import from the NetBox devicetype-library (YAML → DeviceType + templates).""" from __future__ import annotations +import tempfile + from django.contrib.auth import get_user_model +from django.test import override_settings from rest_framework.test import APITestCase from core.models import Organization, Tenant -from .devicetype_import import positionize, to_raw_url +from .devicetype_import import ( + elevation_image_base, + expand_github_dir, + is_github_dir, + positionize, + to_raw_url, +) from .models import DeviceType User = get_user_model() @@ -90,6 +99,137 @@ def test_to_raw_url(self): self.assertEqual(to_raw_url("https://example.com/x.yaml"), "https://example.com/x.yaml") + def test_elevation_image_base(self): + raw = "https://raw.githubusercontent.com" + # Plain owner/name shorthand → default branch via HEAD. + self.assertEqual( + elevation_image_base("danbyte-net/device-library"), + f"{raw}/danbyte-net/device-library/HEAD/elevation-images", + ) + # Repo page URL (trailing slash tolerated). + self.assertEqual( + elevation_image_base("https://github.com/danbyte-net/device-library/"), + f"{raw}/danbyte-net/device-library/HEAD/elevation-images", + ) + # /tree/ pins the ref; an explicit elevation-images path is kept. + self.assertEqual( + elevation_image_base( + "https://github.com/netbox-community/devicetype-library/tree/master" + ), + f"{raw}/netbox-community/devicetype-library/master/elevation-images", + ) + self.assertEqual( + elevation_image_base("https://github.com/o/r/tree/main/elevation-images"), + f"{raw}/o/r/main/elevation-images", + ) + # A full raw base passes through; a bare base gains the folder. + self.assertEqual( + elevation_image_base(f"{raw}/o/r/master/elevation-images"), + f"{raw}/o/r/master/elevation-images", + ) + self.assertEqual( + elevation_image_base("https://mirror.example/devicetype-library"), + "https://mirror.example/devicetype-library/elevation-images", + ) + # https only; garbage is refused with a readable message. + with self.assertRaises(ValueError): + elevation_image_base("http://github.com/o/r") + with self.assertRaises(ValueError): + elevation_image_base("not a repo") + with self.assertRaises(ValueError): + elevation_image_base("") + + def test_is_github_dir(self): + base = "https://github.com/netbox-community/devicetype-library" + self.assertTrue(is_github_dir(f"{base}/tree/master/device-types")) + self.assertTrue(is_github_dir(f"{base}/tree/master/device-types/Cisco")) + self.assertTrue(is_github_dir(f"{base}/tree/master")) + # /blob/ pointing at a folder (no extension) is still a folder — people + # paste either form from the address bar. + self.assertTrue(is_github_dir(f"{base}/blob/master/device-types/Cisco")) + # /blob/ pointing at a file is NOT a folder. + self.assertFalse(is_github_dir(f"{base}/blob/master/x.yaml")) + self.assertFalse( + is_github_dir(f"{base}/tree/master/device-types/Cisco/C9300.yaml") + ) + self.assertFalse(is_github_dir("https://example.com/dir/")) + + def test_expand_github_dir(self): + # A fake SSRF-guarded fetcher returning the trees API payload. + class FakeResp: + def raise_for_status(self): + pass + + def json(self): + return { + "truncated": False, + "tree": [ + {"type": "tree", "path": "device-types"}, + {"type": "blob", "path": "device-types/Cisco/A.yaml"}, + {"type": "blob", "path": "device-types/Cisco/B.yml"}, + {"type": "blob", "path": "device-types/Cisco/logo.png"}, + {"type": "blob", "path": "device-types/Arista/C.yaml"}, + {"type": "blob", "path": "README.md"}, + ], + } + + calls = {} + + def fake_get(url, timeout=30): + calls["url"] = url + return FakeResp() + + base = "https://github.com/netbox-community/devicetype-library" + raw = "https://raw.githubusercontent.com/netbox-community/devicetype-library/master/" + # A vendor sub-folder → only that vendor's YAML (png filtered out). + got = expand_github_dir(f"{base}/tree/master/device-types/Cisco", fake_get) + self.assertEqual(got, [raw + "device-types/Cisco/A.yaml", + raw + "device-types/Cisco/B.yml"]) + self.assertIn("git/trees/master?recursive=1", calls["url"]) + # The whole device-types dir → every vendor's YAML. + allf = expand_github_dir(f"{base}/tree/master/device-types", fake_get) + self.assertEqual(len(allf), 3) + self.assertNotIn(raw + "README.md", allf) + + def test_expand_unquotes_pasted_urls_and_fails_loud_on_empty(self): + """Address-bar URLs carry %20 where the trees API answers with real + spaces ("Palo Alto Networks") — the prefix must compare unquoted, and + the built raw URLs must re-quote. An empty match is a raised error, + not a silent 0-file success that looks stuck in the UI.""" + + class FakeResp: + def raise_for_status(self): + pass + + def json(self): + return { + "truncated": False, + "tree": [ + {"type": "blob", + "path": "device-types/Palo Alto Networks/pa-5410.yaml"}, + {"type": "blob", + "path": "device-types/Palo Alto Networks/pa-440.yaml"}, + ], + } + + def fake_get(url, timeout=30): + return FakeResp() + + base = "https://github.com/danbyte-net/device-library" + got = expand_github_dir( + f"{base}/tree/master/device-types/Palo%20Alto%20Networks", fake_get + ) + self.assertEqual(len(got), 2) + # Raw URLs are re-quoted so the fetch layer gets a valid URL. + self.assertIn( + "https://raw.githubusercontent.com/danbyte-net/device-library/" + "master/device-types/Palo%20Alto%20Networks/pa-5410.yaml", + got, + ) + with self.assertRaises(ValueError) as ctx: + expand_github_dir(f"{base}/tree/master/device-types/Nope", fake_get) + self.assertIn("No YAML files found", str(ctx.exception)) + class ImportEndpointTests(APITestCase): def setUp(self): @@ -108,6 +248,25 @@ def _import(self, items, stack=False): format="json", ) + def test_import_yaml_rejects_html_response(self): + # A github folder (tree) URL fetched raw would return HTML; guard it. + from unittest import mock + + class HtmlResp: + text = "oops" + + def raise_for_status(self): + pass + + with mock.patch("core.ssrf.safe_get", return_value=HtmlResp()): + resp = self._import(["https://example.com/page"]) + self.assertEqual(resp.status_code, 200, resp.content) + r = resp.json()["results"][0] + self.assertFalse(r["ok"]) + self.assertIn("HTML page", r["error"]) + + + def test_imports_full_type(self): resp = self._import([SAMPLE_YAML]) self.assertEqual(resp.status_code, 200, resp.content) @@ -187,3 +346,549 @@ def test_garbage_yaml_reports_error(self): def test_empty_items_rejected(self): self.assertEqual(self._import([]).status_code, 400) + + + +class BackgroundImportTests(APITestCase): + DIR = ("https://github.com/netbox-community/devicetype-library/" + "tree/master/device-types/Cisco") + + def setUp(self): + self.org = Organization.objects.create(name="Acme", slug="acme") + self.tenant = Tenant.objects.create(org=self.org, name="Acme", slug="acme") + self.other = Tenant.objects.create(org=self.org, name="Other", slug="oth") + admin = User.objects.create_superuser("admin", "admin@example.com", "x") + self.client.force_login(admin) + session = self.client.session + session["current_tenant_id"] = str(self.tenant.id) + session.save() + + def test_start_enqueues_and_returns_run(self): + from unittest import mock + + with mock.patch("django_rq.get_queue") as gq: + resp = self.client.post( + "/api/device-types/import-folder/", + {"url": self.DIR}, format="json", + ) + gq.return_value.enqueue.assert_called_once() + self.assertEqual(resp.status_code, 201, resp.content) + body = resp.json() + self.assertEqual(body["status"], "queued") + self.assertTrue(body["id"]) + + def test_start_rejects_non_folder_url(self): + resp = self.client.post( + "/api/device-types/import-folder/", + {"url": "https://github.com/x/y/blob/master/a.yaml"}, + format="json", + ) + self.assertEqual(resp.status_code, 400) + + def test_poll_and_tenant_isolation(self): + from .models import DeviceTypeImportRun + + run = DeviceTypeImportRun.objects.create( + tenant=self.tenant, source_url=self.DIR, status="running", + progress={"done": 3, "total": 10, "created": 3, "failed": 0}, + ) + resp = self.client.get(f"/api/device-types/import-runs/{run.id}/") + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()["progress"]["total"], 10) + # A run in another tenant is invisible. + hidden = DeviceTypeImportRun.objects.create( + tenant=self.other, source_url=self.DIR, status="queued" + ) + self.assertEqual( + self.client.get( + f"/api/device-types/import-runs/{hidden.id}/" + ).status_code, + 404, + ) + + def test_task_imports_each_file_and_records_progress(self): + from unittest import mock + + from .devicetype_import_tasks import run_devicetype_import + from .models import DeviceTypeImportRun + + run = DeviceTypeImportRun.objects.create( + tenant=self.tenant, source_url=self.DIR, status="queued" + ) + + class Resp: + text = "manufacturer: Cisco\nmodel: X\nu_height: 1\n" + + def raise_for_status(self): + pass + + with mock.patch( + "api.devicetype_import.expand_github_dir", + return_value=["u1", "u2", "u3"], + ), mock.patch("core.ssrf.safe_get", return_value=Resp()), mock.patch( + "api.devicetype_import.import_yaml_auto", + side_effect=[ + {"ok": True, "name": "a"}, + {"ok": False, "name": "b", "error": "duplicate"}, + {"ok": True, "name": "c"}, + ], + ): + run_devicetype_import(str(run.id)) + + run.refresh_from_db() + self.assertEqual(run.status, "success") + self.assertEqual(run.progress["total"], 3) + self.assertEqual(run.progress["created"], 2) + self.assertEqual(run.progress["failed"], 1) + self.assertEqual(run.failures[0]["error"], "duplicate") + self.assertIsNotNone(run.finished_at) + + def test_task_records_failure_when_listing_blows_up(self): + from unittest import mock + + from .devicetype_import_tasks import run_devicetype_import + from .models import DeviceTypeImportRun + + run = DeviceTypeImportRun.objects.create( + tenant=self.tenant, source_url=self.DIR, status="queued" + ) + with mock.patch( + "api.devicetype_import.expand_github_dir", + side_effect=ValueError("truncated tree"), + ): + run_devicetype_import(str(run.id)) + run.refresh_from_db() + self.assertEqual(run.status, "failed") + self.assertIn("truncated", run.error) + + +# ─── Reimporting images for existing types ────────────────────────────────── + +class _Resp: + def __init__(self, status_code: int, content: bytes = b""): + self.status_code = status_code + self.content = content + + +class FakeRepo: + """In-memory raw.githubusercontent.com: ``{"Cisco/slug.front.png": b"…"}``. + Lookup keys on the URL's trailing ``/`` segments, so + any normalised base works. Stands in for BOTH ``safe_request`` (HEAD + probes) and ``safe_get`` (downloads) — tests never touch the network.""" + + def __init__(self, files: dict[str, bytes]): + self.files = files + self.calls: list[str] = [] + + def _body(self, url: str) -> bytes | None: + from urllib.parse import unquote + + self.calls.append(url) + return self.files.get(unquote("/".join(url.split("/")[-2:]))) + + def request(self, method: str, url: str, **kw) -> _Resp: # safe_request + body = self._body(url) + return _Resp(404) if body is None else _Resp(200) + + def get(self, url: str, **kw) -> _Resp: # safe_get + body = self._body(url) + return _Resp(404) if body is None else _Resp(200, body) + + +@override_settings(MEDIA_ROOT=tempfile.mkdtemp(prefix="danbyte-test-media-")) +class ReimportImagesTests(APITestCase): + """The media-loss recovery flow: match EXISTING types against a + library-layout repo and re-download only the elevation images.""" + + URL = "/api/device-types/reimport-images/" + + def setUp(self): + from .models import Manufacturer + + self.org = Organization.objects.create(name="Acme", slug="acme") + self.tenant = Tenant.objects.create(org=self.org, name="Acme", slug="acme") + self.other = Tenant.objects.create(org=self.org, name="Other", slug="oth") + admin = User.objects.create_superuser("admin", "admin@example.com", "x") + self.client.force_login(admin) + session = self.client.session + session["current_tenant_id"] = str(self.tenant.id) + session.save() + self.cisco = Manufacturer.objects.create( + tenant=self.tenant, name="Cisco", slug="cisco" + ) + + def _repo(self, *slugs: str, faces=("front", "rear")) -> FakeRepo: + return FakeRepo({ + f"Cisco/{slug}.{face}.png": f"PNG:{slug}.{face}".encode() + for slug in slugs + for face in faces + }) + + def _post(self, repo: FakeRepo, *, dry_run=False, overwrite=False, body=None): + from unittest import mock + + payload = {"repo": "danbyte-net/device-library", **(body or {})} + qs = [] + if dry_run: + qs.append("dry_run=1") + if overwrite: + qs.append("overwrite=1") + url = self.URL + ("?" + "&".join(qs) if qs else "") + with mock.patch("core.ssrf.safe_request", side_effect=repo.request), \ + mock.patch("core.ssrf.safe_get", side_effect=repo.get): + return self.client.post(url, payload, format="json") + + def _attach(self, dt, face: str, content=b"OLD"): + from django.core.files.base import ContentFile + + field = dt.front_image if face == "front" else dt.rear_image + field.save(f"cisco-{dt.name.lower()}.{face}.png", ContentFile(content), + save=True) + + def test_dry_run_classification(self): + # A: no images, repo has it → matched. B: no images, repo doesn't → + # no_match. C: both faces present with real files on disk → skipped. + a = DeviceType.objects.create( + tenant=self.tenant, name="C9300-48P", manufacturer=self.cisco + ) + b = DeviceType.objects.create( + tenant=self.tenant, name="Unobtainium X1", manufacturer=self.cisco + ) + c = DeviceType.objects.create( + tenant=self.tenant, name="C9200-24T", manufacturer=self.cisco + ) + self._attach(c, "front") + self._attach(c, "rear") + + resp = self._post(self._repo("cisco-c9300-48p"), dry_run=True) + self.assertEqual(resp.status_code, 200, resp.content) + data = resp.json() + self.assertTrue(data["dry_run"]) + by_id = {r["id"]: r for r in data["results"]} + self.assertEqual(by_id[str(a.id)]["status"], "matched") + self.assertEqual(by_id[str(a.id)]["slug"], "cisco-c9300-48p") + self.assertEqual(by_id[str(a.id)]["faces"]["front"], "available") + self.assertEqual(by_id[str(b.id)]["status"], "no_match") + self.assertEqual(by_id[str(c.id)]["status"], "skipped_has_images") + self.assertEqual(data["totals"]["matched"], 1) + self.assertEqual(data["totals"]["no_match"], 1) + self.assertEqual(data["totals"]["skipped_has_images"], 1) + # Dry run never writes. + a.refresh_from_db() + self.assertFalse(a.front_image) + + def test_corrupt_media_counts_as_gap(self): + # DB says the type has a front image; the file is GONE from storage + # (the lost-media case). It must classify as matched, not skipped — + # and the surviving filename is itself the matching signal. + dt = DeviceType.objects.create( + tenant=self.tenant, name="Nexus Something Odd", + manufacturer=self.cisco, + ) + from django.core.files.base import ContentFile + + # Filename carries a slug the *name* would never derive. + dt.front_image.save( + "cisco-n9k-c93180yc-ex.front.png", ContentFile(b"x"), save=True + ) + dt.front_image.storage.delete(dt.front_image.name) + + resp = self._post(self._repo("cisco-n9k-c93180yc-ex"), dry_run=True) + row = resp.json()["results"][0] + self.assertEqual(row["status"], "matched") + self.assertEqual(row["slug"], "cisco-n9k-c93180yc-ex") + self.assertEqual(row["faces"]["front"], "available") + + def test_apply_fills_gaps_only(self): + dt = DeviceType.objects.create( + tenant=self.tenant, name="C9300-48P", manufacturer=self.cisco + ) + self._attach(dt, "front", b"OLD") # intact on disk → kept + + resp = self._post(self._repo("cisco-c9300-48p")) + self.assertEqual(resp.status_code, 200, resp.content) + row = resp.json()["results"][0] + self.assertEqual(row["status"], "matched") + self.assertEqual(row["faces"], {"front": "kept", "rear": "downloaded"}) + self.assertEqual(row["downloaded"], 1) + dt.refresh_from_db() + with dt.front_image.open() as fh: + self.assertEqual(fh.read(), b"OLD") # untouched + with dt.rear_image.open() as fh: + self.assertEqual(fh.read(), b"PNG:cisco-c9300-48p.rear") + + def test_apply_overwrite_replaces_intact_images(self): + dt = DeviceType.objects.create( + tenant=self.tenant, name="C9300-48P", manufacturer=self.cisco + ) + self._attach(dt, "front", b"OLD") + + resp = self._post(self._repo("cisco-c9300-48p"), overwrite=True) + row = resp.json()["results"][0] + self.assertEqual(row["faces"]["front"], "downloaded") + dt.refresh_from_db() + with dt.front_image.open() as fh: + self.assertEqual(fh.read(), b"PNG:cisco-c9300-48p.front") + + def test_apply_writes_changelog(self): + from audit.models import ChangeLogEntry + + dt = DeviceType.objects.create( + tenant=self.tenant, name="C9300-48P", manufacturer=self.cisco + ) + ChangeLogEntry.objects.all().delete() + self._post(self._repo("cisco-c9300-48p")) + self.assertTrue( + ChangeLogEntry.objects.filter( + object_type="api.devicetype", object_id=str(dt.id), + action="update", + ).exists() + ) + + def test_tenant_scoping_never_touches_other_tenant(self): + from .models import Manufacturer + + theirs_mfr = Manufacturer.objects.create( + tenant=self.other, name="Cisco", slug="cisco" + ) + theirs = DeviceType.objects.create( + tenant=self.other, name="C9300-48P", manufacturer=theirs_mfr + ) + resp = self._post(self._repo("cisco-c9300-48p")) + data = resp.json() + self.assertNotIn(str(theirs.id), {r["id"] for r in data["results"]}) + theirs.refresh_from_db() + self.assertFalse(theirs.front_image) + self.assertFalse(theirs.rear_image) + + def test_airgapped_refuses_before_any_fetch(self): + from unittest import mock + + from core.models import DeploymentSettings + + dep = DeploymentSettings.load() + dep.disable_update_check = True + dep.save() + DeviceType.objects.create( + tenant=self.tenant, name="C9300-48P", manufacturer=self.cisco + ) + with mock.patch("core.ssrf.safe_request") as req, \ + mock.patch("core.ssrf.safe_get") as get: + resp = self.client.post( + self.URL, {"repo": "danbyte-net/device-library"}, format="json" + ) + req.assert_not_called() + get.assert_not_called() + self.assertEqual(resp.status_code, 409, resp.content) + self.assertIn("airgapped", resp.json()["detail"]) + + def test_fetch_failure_is_reported_not_500(self): + from unittest import mock + + dt = DeviceType.objects.create( + tenant=self.tenant, name="C9300-48P", manufacturer=self.cisco + ) + with mock.patch( + "core.ssrf.safe_request", side_effect=OSError("connection refused") + ), mock.patch( + "core.ssrf.safe_get", side_effect=OSError("connection refused") + ): + resp = self.client.post( + self.URL, {"repo": "danbyte-net/device-library"}, format="json" + ) + self.assertEqual(resp.status_code, 200, resp.content) + row = resp.json()["results"][0] + self.assertEqual(row["id"], str(dt.id)) + self.assertEqual(row["status"], "fetch_failed") + self.assertEqual(resp.json()["totals"]["fetch_failed"], 1) + + def test_bad_repo_is_a_400(self): + resp = self.client.post( + self.URL, {"repo": "http://github.com/o/r"}, format="json" + ) + self.assertEqual(resp.status_code, 400) + + def test_over_cap_enqueues_background_run(self): + from unittest import mock + + for i in range(3): + DeviceType.objects.create( + tenant=self.tenant, name=f"T{i}", manufacturer=self.cisco + ) + with mock.patch("api.devicetype_import.REIMPORT_SYNC_CAP", 2), \ + mock.patch("django_rq.get_queue") as gq: + resp = self.client.post( + self.URL + "?overwrite=1", + {"repo": "danbyte-net/device-library"}, + format="json", + ) + gq.return_value.enqueue.assert_called_once() + self.assertEqual(resp.status_code, 202, resp.content) + run = resp.json()["run"] + self.assertEqual(run["kind"], "image_reimport") + self.assertEqual(run["options"], {"overwrite": True, "dry_run": False}) + self.assertIn("elevation-images", run["source_url"]) + + def test_background_task_applies_and_records_totals(self): + from unittest import mock + + from .devicetype_import_tasks import run_devicetype_image_reimport + from .models import DeviceTypeImportRun + + DeviceType.objects.create( + tenant=self.tenant, name="C9300-48P", manufacturer=self.cisco + ) + DeviceType.objects.create( + tenant=self.tenant, name="Unobtainium X1", manufacturer=self.cisco + ) + admin = User.objects.get(username="admin") + run = DeviceTypeImportRun.objects.create( + tenant=self.tenant, kind="image_reimport", + source_url="https://raw.githubusercontent.com/o/r/HEAD/elevation-images", + options={"overwrite": False, "dry_run": False}, + created_by=admin, status="queued", + ) + repo = self._repo("cisco-c9300-48p") + with mock.patch("core.ssrf.safe_request", side_effect=repo.request), \ + mock.patch("core.ssrf.safe_get", side_effect=repo.get): + run_devicetype_image_reimport(str(run.id)) + run.refresh_from_db() + self.assertEqual(run.status, "success", run.error) + self.assertEqual(run.progress["total"], 2) + self.assertEqual(run.progress["matched"], 1) + self.assertEqual(run.progress["no_match"], 1) + self.assertEqual(run.progress["images_downloaded"], 2) + self.assertEqual(run.failures[0]["name"], "Unobtainium X1") + + def test_background_task_rechecks_airgap(self): + from unittest import mock + + from core.models import DeploymentSettings + + from .devicetype_import_tasks import run_devicetype_image_reimport + from .models import DeviceTypeImportRun + + admin = User.objects.get(username="admin") + run = DeviceTypeImportRun.objects.create( + tenant=self.tenant, kind="image_reimport", + source_url="https://raw.githubusercontent.com/o/r/HEAD/elevation-images", + options={}, created_by=admin, status="queued", + ) + dep = DeploymentSettings.load() + dep.disable_update_check = True + dep.save() + with mock.patch("core.ssrf.safe_request") as req: + run_devicetype_image_reimport(str(run.id)) + req.assert_not_called() + run.refresh_from_db() + self.assertEqual(run.status, "failed") + self.assertIn("airgapped", run.error) + + +class RepoInventoryTests(APITestCase): + """The one-shot repo listing that turns catalog matching into set lookups. + The speed contract is behavioural: with an inventory, matching makes ZERO + per-image probe requests.""" + + BASE = ( + "https://raw.githubusercontent.com/danbyte-net/device-library/" + "HEAD/elevation-images" + ) + + @staticmethod + def _trees_get(url: str, **kw): + """Fake api.github.com: top tree → subtree sha; subtree → blobs.""" + import json + + if url.endswith("/git/trees/HEAD"): + body = {"tree": [ + {"path": "elevation-images", "type": "tree", "sha": "sub123"}, + {"path": "device-types", "type": "tree", "sha": "other"}, + ]} + elif "/git/trees/sub123" in url: + body = {"truncated": False, "tree": [ + {"path": "Cisco/catalyst-9300-24p.front.png", "type": "blob"}, + {"path": "Cisco/catalyst-9300-24p.rear.png", "type": "blob"}, + {"path": "APC/ap8853.front.jpg", "type": "blob"}, + ]} + else: + return _Resp(404) + return _Resp(200, json.dumps(body).encode()) + + def test_inventory_two_calls_and_contents(self): + from unittest import mock + + from .devicetype_import import repo_image_inventory + + with mock.patch("core.ssrf.safe_get", side_effect=self._trees_get) as g: + inv = repo_image_inventory(self.BASE) + self.assertEqual(g.call_count, 2) + self.assertIn("Cisco/catalyst-9300-24p.front.png", inv) + self.assertIn("APC/ap8853.front.jpg", inv) + + def test_non_github_base_and_truncated_fall_back_to_none(self): + from unittest import mock + + from .devicetype_import import repo_image_inventory + + self.assertIsNone(repo_image_inventory("https://mirror.example/images")) + + def truncated(url, **kw): + import json + + if url.endswith("/git/trees/HEAD"): + return _Resp(200, json.dumps({"tree": [ + {"path": "elevation-images", "type": "tree", "sha": "s"}, + ]}).encode()) + return _Resp(200, json.dumps( + {"truncated": True, "tree": []} + ).encode()) + + with mock.patch("core.ssrf.safe_get", side_effect=truncated): + self.assertIsNone(repo_image_inventory(self.BASE)) + + def test_missing_dir_is_an_honest_empty_set(self): + """A repo with no elevation-images dir yields set() — every type + reports no_match immediately instead of probing for an hour.""" + import json + from unittest import mock + + def no_dir(url, **kw): + if url.endswith("/git/trees/HEAD"): + return _Resp(200, json.dumps({"tree": []}).encode()) + return _Resp(404) + + from .devicetype_import import repo_image_inventory + + with mock.patch("core.ssrf.safe_get", side_effect=no_dir): + self.assertEqual(repo_image_inventory(self.BASE), set()) + + def test_matching_with_inventory_makes_zero_probe_requests(self): + from unittest import mock + + from .devicetype_import import reimport_images_for_type + from .models import DeviceType, Manufacturer + + org = Organization.objects.create(name="Inv", slug="inv") + tenant = Tenant.objects.create(org=org, name="Inv", slug="inv") + mfr = Manufacturer.objects.create(tenant=tenant, name="Cisco", slug="cisco") + dt = DeviceType.objects.create( + tenant=tenant, manufacturer=mfr, name="Catalyst 9300-24P", + u_height=1, + ) + inv = { + "Cisco/catalyst-9300-24p.front.png", + "Cisco/catalyst-9300-24p.rear.png", + } + with mock.patch( + "core.ssrf.safe_request", + side_effect=AssertionError("probe fired despite inventory"), + ): + row = reimport_images_for_type( + dt, self.BASE, apply=False, inventory=inv + ) + self.assertEqual(row["status"], "matched") + self.assertEqual(row["slug"], "catalyst-9300-24p") + self.assertEqual( + row["faces"], {"front": "available", "rear": "available"} + ) diff --git a/api/tests_faceplate.py b/api/tests_faceplate.py index 67116440..0cd80ef6 100644 --- a/api/tests_faceplate.py +++ b/api/tests_faceplate.py @@ -7,10 +7,18 @@ from __future__ import annotations from django.contrib.auth import get_user_model +from django.utils import timezone from rest_framework.test import APITestCase from core.models import Organization, Tenant -from .models import AuxPortTemplate, Device, DeviceType +from .models import ( + AuxPortTemplate, + Cable, + CableTermination, + Device, + DeviceType, + Interface, +) User = get_user_model() @@ -118,6 +126,75 @@ def test_rejects_non_string_bay(self): } self.assertEqual(self._patch(doc).status_code, 400) + def _patch_ports(self, ports): + return self.client.patch( + f"/api/device-types/{self.dt.id}/", + {"image_ports": ports}, format="json", + ) + + def test_image_ports_roundtrip(self): + ports = { + "front": [ + {"kind": "interface", "name": "Gi1/0/1", "x": 0.1, "y": 0.5, + "w": 0.03, "h": 0.4}, + ], + "rear": [], + } + resp = self._patch_ports(ports) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual( + resp.json()["image_ports"]["front"][0]["name"], "Gi1/0/1" + ) + # Clearable back to null. + self.assertEqual(self._patch_ports(None).status_code, 200) + self.assertIsNone( + self.client.get( + f"/api/device-types/{self.dt.id}/" + ).json()["image_ports"] + ) + + def test_image_ports_reject_out_of_bounds_and_bad_kind(self): + for bad in ( + {"front": [{"name": "x", "x": 1.5, "y": 0.5, "w": 0.1, "h": 0.1}]}, + {"front": [{"kind": "flux", "name": "x", "x": 0.1, "y": 0.1, + "w": 0.1, "h": 0.1}]}, + {"front": [{"name": "", "x": 0.1, "y": 0.1, "w": 0.1, "h": 0.1}]}, + {"front": "nope"}, + ): + self.assertEqual(self._patch_ports(bad).status_code, 400, bad) + + def test_image_ports_accept_photo_only_kinds(self): + """Hardware parts and MODULE BAYS are placeable on a photo — you mark + where a chassis's line-card slots physically are.""" + ports = { + "front": [ + {"kind": "inventory-item", "name": "Disk 0", "x": 0.1, + "y": 0.5, "w": 0.03, "h": 0.35}, + {"kind": "module-bay", "name": "Slot 1", "x": 0.4, "y": 0.5, + "w": 0.2, "h": 0.45}, + ], + "rear": [], + } + resp = self._patch_ports(ports) + self.assertEqual(resp.status_code, 200, resp.content) + kinds = [m["kind"] for m in resp.json()["image_ports"]["front"]] + self.assertEqual(kinds, ["inventory-item", "module-bay"]) + + def test_faceplate_still_rejects_photo_only_kinds(self): + """The boundary is the point: the schematic faceplate stays PORT-only. + A module bay appears there as a group's `bay` placeholder (which the + device render composes an installed module into), never as a slot.""" + for kind in ("module-bay", "inventory-item"): + doc = { + "v": 1, "rear": [], + "front": [ + {"id": "a", "rows": 1, "bank": 0, "slots": [ + {"t": "port", "kind": kind, "name": "Slot 1"}, + ]} + ], + } + self.assertEqual(self._patch(doc).status_code, 400, kind) + def test_rejects_duplicate_kind_name(self): doc = { "v": 1, "rear": [], @@ -216,3 +293,189 @@ def test_template_stamps_on_device_create(self): names = set(device.aux_ports.values_list("name", flat=True)) # Standalone device: {position} resolves to its default (1). self.assertEqual(names, {"USB1", "HDMI"}) + + +class FacePortsResolveTests(APITestCase): + """GET /api/devices/{id}/face-ports/ turns a device type's photo-port + markers into the device's real components (id, kind, cabled?), which the 3D + room view needs to cable a clicked port.""" + + def setUp(self): + self.org = Organization.objects.create(name="Acme", slug="acme") + self.tenant = Tenant.objects.create(org=self.org, name="Acme", slug="acme") + admin = User.objects.create_superuser("admin", "admin@example.com", "x") + self.client.force_login(admin) + session = self.client.session + session["current_tenant_id"] = str(self.tenant.id) + session.save() + self.dt = DeviceType.objects.create( + tenant=self.tenant, name="C9300", u_height=1, + image_ports={ + "front": [ + {"kind": "interface", "name": "Gi1/0/1", + "x": 0.1, "y": 0.5, "w": 0.03, "h": 0.4}, + {"kind": "interface", "name": "Gi1/0/99", + "x": 0.2, "y": 0.5, "w": 0.03, "h": 0.4}, + ], + "rear": [], + }, + ) + self.dev = Device.objects.create( + tenant=self.tenant, name="sw1", device_type=self.dt + ) + self.eth = Interface.objects.create( + device=self.dev, name="Gi1/0/1", speed="25G", enabled=True + ) + + def _get(self): + return self.client.get(f"/api/devices/{self.dev.id}/face-ports/") + + def test_resolves_marker_to_interface(self): + resp = self._get() + self.assertEqual(resp.status_code, 200, resp.content) + front = resp.json()["front"] + self.assertEqual(front[0]["name"], "Gi1/0/1") + self.assertEqual(front[0]["kind"], "interface") + self.assertEqual(front[0]["id"], str(self.eth.id)) + self.assertFalse(front[0]["connected"]) + # Speed/enabled ride along so 3D can reuse the 2D port-state colouring. + self.assertEqual(front[0]["speed"], "25G") + self.assertTrue(front[0]["enabled"]) + # A marker with no matching component resolves to a null id, not a 500. + self.assertIsNone(front[1]["id"]) + self.assertIsNone(front[1]["kind"]) + + def test_resolves_inventory_marker_with_status(self): + from api.models import InventoryItem + from api.status_registry import seed_builtin_statuses + from api.models import Status + + seed_builtin_statuses(self.tenant) + failed = Status.objects.get(tenant=self.tenant, slug="failed") + self.dt.image_ports = { + "front": [{"kind": "inventory-item", "name": "Bay 1", + "x": 0.3, "y": 0.5, "w": 0.02, "h": 0.6}], + "rear": [], + } + self.dt.save(update_fields=["image_ports"]) + InventoryItem.objects.create( + device=self.dev, name="Bay 1", kind="disk", media="nvme", + status=failed, + ) + front = self._get().json()["front"] + self.assertEqual(front[0]["name"], "Bay 1") + self.assertIsNotNone(front[0]["id"]) + self.assertIsNone(front[0]["kind"]) # not cable-able + self.assertEqual(front[0]["status"]["name"], "Failed") + # The id joins the marker to the tenant's Status catalog — the legend + # keys hardware by it, since StatusMini carries no slug. + self.assertEqual(front[0]["status"]["id"], str(failed.id)) + + def test_drift_rides_along_for_hardware(self): + """A part whose observed health disagrees with its set status carries a + drift line, so the 3D room can flag it without a second request.""" + from api.models import InventoryItem, Status + from api.status_registry import seed_builtin_statuses + from monitoring.models import DeviceSnmp + + seed_builtin_statuses(self.tenant) + active = Status.objects.get(tenant=self.tenant, slug="active") + self.dt.image_ports = { + "front": [{"kind": "inventory-item", "name": "disk0", + "x": 0.3, "y": 0.5, "w": 0.02, "h": 0.6}], + "rear": [], + } + self.dt.save(update_fields=["image_ports"]) + part = InventoryItem.objects.create( + device=self.dev, name="disk0", kind="disk", status=active + ) + DeviceSnmp.objects.create( + device=self.dev, tenant=self.tenant, polled_at=timezone.now(), + sensors=[{"name": "disk0", "status": "failed", "raw": "Critical", + "kind": "disk", "sensor": "Drive health"}], + ) + front = self._get().json()["front"] + self.assertEqual(front[0]["id"], str(part.id)) + # Intent is untouched — the status still reads Active, drift sits beside. + self.assertEqual(front[0]["status"]["name"], "Active") + self.assertEqual(front[0]["drift"], "SNMP says failed") + + def test_drift_is_null_when_they_agree(self): + front = self._get().json()["front"] + self.assertIsNone(front[0]["drift"]) + self.assertIsNone(front[1]["drift"]) + + def _bay_markers(self): + self.dt.image_ports = { + "front": [{"kind": "module-bay", "name": "Slot 1", + "x": 0.3, "y": 0.5, "w": 0.2, "h": 0.45}], + "rear": [], + } + self.dt.save(update_fields=["image_ports"]) + + def test_module_bay_marker_empty_then_installed(self): + """The whole chain a bay marker travels: a module-bay TEMPLATE on the + type → a bay stamped onto a new device → a marker naming that template + → resolved empty, then occupied once a module is seated. "Empty" is a + real answer; "not on this device" is not.""" + from api.models import Manufacturer, ModuleBayTemplate, ModuleType + + ModuleBayTemplate.objects.create(device_type=self.dt, name="Slot 1") + # Stamped by device creation, exactly as the palette's template implies. + resp = self.client.post( + "/api/devices/", + {"name": "c9400", "device_type_id": str(self.dt.id)}, + format="json", + ) + self.assertEqual(resp.status_code, 201, resp.content) + chassis = Device.objects.get(name="c9400") + bay = chassis.module_bays.get(name="Slot 1") + self._bay_markers() + + front = self.client.get( + f"/api/devices/{chassis.id}/face-ports/" + ).json()["front"] + self.assertEqual(front[0]["id"], str(bay.id)) + self.assertIsNone(front[0]["kind"]) # not cable-able + self.assertIsNone(front[0]["module"]) + # A bay is not a hardware part — it has no lifecycle status of its own. + self.assertIsNone(front[0]["status"]) + + mfr = Manufacturer.objects.create(tenant=self.tenant, name="Cisco") + mt = ModuleType.objects.create( + tenant=self.tenant, manufacturer=mfr, name="C9400-LC-48U" + ) + resp = self.client.post( + "/api/modules/", + {"device_id": str(chassis.id), "module_bay_id": str(bay.id), + "module_type_id": str(mt.id), "serial_number": "FOC123"}, + format="json", + ) + self.assertEqual(resp.status_code, 201, resp.content) + + front = self.client.get( + f"/api/devices/{chassis.id}/face-ports/" + ).json()["front"] + self.assertEqual(front[0]["id"], str(bay.id)) + self.assertEqual(front[0]["module"]["id"], resp.json()["id"]) + self.assertEqual(front[0]["module"]["module_type"]["name"], + "C9400-LC-48U") + self.assertEqual(front[0]["module"]["serial_number"], "FOC123") + + def test_module_bay_marker_with_no_bay_is_a_ghost(self): + self._bay_markers() + front = self._get().json()["front"] + self.assertIsNone(front[0]["id"]) + self.assertIsNone(front[0]["module"]) + + def test_connected_flag_and_cable_id(self): + peer = Device.objects.create( + tenant=self.tenant, name="sw2", device_type=self.dt + ) + p_eth = Interface.objects.create(device=peer, name="Gi1/0/1") + cable = Cable.objects.create(tenant=self.tenant, type="cat6") + CableTermination.objects.create(cable=cable, end="A", interface=self.eth) + CableTermination.objects.create(cable=cable, end="B", interface=p_eth) + front = self._get().json()["front"] + self.assertTrue(front[0]["connected"]) + self.assertEqual(front[0]["cable_id"], str(cable.id)) diff --git a/api/tests_floorplan.py b/api/tests_floorplan.py index 1f339c48..4bf05b5d 100644 --- a/api/tests_floorplan.py +++ b/api/tests_floorplan.py @@ -144,6 +144,32 @@ def test_crud_and_tenant_isolation(self): self.assertEqual(resp.status_code, 200) self.assertEqual(resp.json()["state"]["overlay"], "power") + def test_physical_scale_defaults_and_roundtrip(self): + # Defaults give existing plans plausible real-world scale for free. + resp = self.client.post( + "/api/floor-plans/", + {"name": "Hall B", "location_id": str(self.loc.id)}, + format="json", + ) + self.assertEqual(resp.status_code, 201, resp.content) + body = resp.json() + self.assertEqual(body["cell_mm"], 600) + self.assertEqual(body["ceiling_mm"], 3000) + # And they're editable within their validator bounds. + resp = self.client.patch( + f"/api/floor-plans/{body['id']}/", + {"cell_mm": 500, "ceiling_mm": 2700}, + format="json", + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()["cell_mm"], 500) + self.assertEqual(resp.json()["ceiling_mm"], 2700) + # Out-of-range values are rejected, not clamped silently. + resp = self.client.patch( + f"/api/floor-plans/{body['id']}/", {"cell_mm": 10}, format="json" + ) + self.assertEqual(resp.status_code, 400) + class TileTests(_Base): def setUp(self): @@ -454,6 +480,36 @@ def test_tray_tenant_isolation(self): got = self.client.get("/api/floor-plan-trays/").json() self.assertEqual(got["count"], 0) + def test_tray_level_and_elevation_roundtrip(self): + resp = self.client.post( + "/api/floor-plan-trays/", + {"floor_plan_id": str(self.plan.id), "name": "OH-1", + "points": [[0, 0], [4, 0]]}, + format="json", + ) + self.assertEqual(resp.status_code, 201, resp.content) + body = resp.json() + # Defaults: overhead run, elevation derived (null) until set. + self.assertEqual(body["level"], "overhead") + self.assertIsNone(body["elevation_mm"]) + + resp = self.client.patch( + f"/api/floor-plan-trays/{body['id']}/", + {"level": "underfloor", "elevation_mm": -300}, + format="json", + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()["level"], "underfloor") + self.assertEqual(resp.json()["elevation_mm"], -300) + + # Unknown level value rejected by the choices validator. + resp = self.client.patch( + f"/api/floor-plan-trays/{body['id']}/", + {"level": "orbit"}, + format="json", + ) + self.assertEqual(resp.status_code, 400) + def test_tile_rack_filter(self): tt = FloorTileType.objects.create( tenant=self.tenant, name="Rack", slug="rack" @@ -468,6 +524,163 @@ def test_tile_rack_filter(self): self.assertEqual(got["count"], 1) +class SceneTests(_Base): + """GET /api/floor-plans/{id}/scene/ — the 3D room view's one-fetch payload.""" + + def setUp(self): + super().setUp() + self.plan = FloorPlan.objects.create( + tenant=self.tenant, location=self.loc, name="Hall A", + cell_mm=500, ceiling_mm=2800, + ) + self.tt = FloorTileType.objects.create( + tenant=self.tenant, name="Rack", slug="rack" + ) + + def test_scene_payload_shape(self): + from .models import DeviceType + + dt = DeviceType.objects.create( + tenant=self.tenant, name="1U Switch", u_height=1, + image_ports={ + "front": [ + {"kind": "interface", "name": "Gi1/0/1", + "x": 0.1, "y": 0.5, "w": 0.03, "h": 0.4} + ], + "rear": [], + }, + ) + Device.objects.create( + tenant=self.tenant, name="sw1", device_type=dt, + rack=self.rack, position=10, face="front", + ) + # A racked device with no position must not appear in the scene. + Device.objects.create( + tenant=self.tenant, name="loose", device_type=dt, rack=self.rack + ) + FloorPlanTile.objects.create( + floor_plan=self.plan, tile_type=self.tt, x=2, y=3, + rack=self.rack, link_kind="rack", + ) + FloorPlanTray.objects.create( + floor_plan=self.plan, name="OH-1", level="overhead", + points=[[0, 0], [4, 0]], + ) + + body = self.client.get(f"/api/floor-plans/{self.plan.id}/scene/").json() + self.assertEqual(body["plan"]["cell_mm"], 500) + self.assertEqual(body["plan"]["ceiling_mm"], 2800) + self.assertEqual(len(body["tiles"]), 1) + tile = body["tiles"][0] + self.assertEqual(tile["kind"], "rack") + self.assertEqual(tile["x"], 2) + self.assertEqual(tile["rack"]["name"], "R01") + self.assertEqual(tile["rack"]["u_height"], 42) + devs = tile["rack"]["devices"] + self.assertEqual([d["name"] for d in devs], ["sw1"]) + self.assertEqual(devs[0]["position"], 10) + self.assertEqual(devs[0]["u_height"], 1) + self.assertEqual(devs[0]["rack_width"], "full") + # Photo-anchored port markers flow through for the 3D face overlay. + self.assertEqual(devs[0]["image_ports"]["front"][0]["name"], "Gi1/0/1") + self.assertEqual(len(body["trays"]), 1) + self.assertEqual(body["trays"][0]["level"], "overhead") + self.assertIsNone(body["trays"][0]["elevation_mm"]) + + def test_scene_tenant_isolation(self): + hidden = FloorPlan.objects.create( + tenant=self.other, location=self.other_loc, name="Hidden" + ) + resp = self.client.get(f"/api/floor-plans/{hidden.id}/scene/") + self.assertEqual(resp.status_code, 404) + + def test_scene_carries_type_name_for_unlinked_tiles(self): + """Build-in-advance: a typed tile with NO linked object still lands + in the scene with its type's name, so 3D can draw planning massing.""" + FloorPlanTile.objects.create( + floor_plan=self.plan, tile_type=self.tt, x=4, y=4, + label="future row", + ) + body = self.client.get(f"/api/floor-plans/{self.plan.id}/scene/").json() + tile = next(t for t in body["tiles"] if t["label"] == "future row") + self.assertEqual(tile["kind"], "other") + self.assertIsNone(tile["rack"]) + self.assertEqual(tile["type_name"], "Rack") + + def test_scene_carries_perforated_from_the_tile_type(self): + """A perforated zone type marks its scene tiles, so the 3D room can + draw grate floor where the cold-aisle supply tiles sit.""" + grate = FloorTileType.objects.create( + tenant=self.tenant, name="Cold aisle grate", slug="cold-grate", + is_zone=True, perforated=True, + ) + FloorPlanTile.objects.create( + floor_plan=self.plan, tile_type=grate, x=6, y=6, width=2, + label="supply", + ) + FloorPlanTile.objects.create( + floor_plan=self.plan, tile_type=self.tt, x=0, y=9, label="plain", + ) + body = self.client.get(f"/api/floor-plans/{self.plan.id}/scene/").json() + tile = next(t for t in body["tiles"] if t["label"] == "supply") + self.assertTrue(tile["is_zone"]) + self.assertTrue(tile["perforated"]) + # And the flag is additive: ordinary tiles carry it as False. + other = next(t for t in body["tiles"] if t["label"] == "plain") + self.assertFalse(other["perforated"]) + + def test_scene_device_airflow_is_effective_and_additive(self): + """The scene carries EFFECTIVE airflow (device override beats the + type's default), added without disturbing the rest of device_geo — + the 2D canvas and older consumers read the same keys they always did. + """ + from .models import DeviceType + + dt = DeviceType.objects.create( + tenant=self.tenant, name="FTR Switch", u_height=1, + airflow="front-to-rear", + ) + Device.objects.create( + tenant=self.tenant, name="inherits", device_type=dt, + rack=self.rack, position=1, face="front", + ) + Device.objects.create( + tenant=self.tenant, name="overrides", device_type=dt, + rack=self.rack, position=3, face="front", + airflow="rear-to-front", + ) + FloorPlanTile.objects.create( + floor_plan=self.plan, tile_type=self.tt, x=0, y=0, + rack=self.rack, link_kind="rack", + ) + body = self.client.get(f"/api/floor-plans/{self.plan.id}/scene/").json() + devs = {d["name"]: d for d in body["tiles"][0]["rack"]["devices"]} + self.assertEqual(devs["inherits"]["airflow"], "front-to-rear") + self.assertEqual(devs["overrides"]["airflow"], "rear-to-front") + # Additive: the established key set is intact alongside the new one. + for key in ( + "id", "name", "position", "face", "rack_side", "u_height", + "rack_width", "is_full_depth", "role_color", "role_name", + "device_type", "status", "primary_ip", "serial_number", + "front_image", "rear_image", "has_faceplate", "image_ports", + ): + self.assertIn(key, devs["inherits"]) + + def test_effective_airflow_on_the_device_serializer(self): + from .models import DeviceType + + dt = DeviceType.objects.create( + tenant=self.tenant, name="RTF Router", u_height=1, + airflow="rear-to-front", + ) + dev = Device.objects.create( + tenant=self.tenant, name="edge1", device_type=dt + ) + body = self.client.get(f"/api/devices/{dev.id}/").json() + self.assertEqual(body["airflow"], "") + self.assertEqual(body["effective_airflow"], "rear-to-front") + + class CablePathTests(_Base): def test_cable_paths_resolve_to_rack_tiles(self): plan = FloorPlan.objects.create( @@ -515,6 +728,124 @@ def test_cable_paths_resolve_to_rack_tiles(self): self.assertEqual(entry["a_tiles"], [str(tile_a.id)]) self.assertEqual(entry["b_tiles"], [str(tile_b.id)]) self.assertEqual(entry["tray_ids"], [str(tray.id)]) + # Endpoint device + port ride along (3D anchors runs to port quads). + self.assertEqual( + entry["a_points"], [{"device": str(dev_a.id), "port": "eth0"}] + ) + self.assertEqual( + entry["b_points"], [{"device": str(dev_b.id), "port": "eth0"}] + ) + + +class CableRoutingTests(_Base): + """Reading and setting what a cable FOLLOWS on a plan — trays in a chosen + order, or point-to-point. Before this the assignment was only writable by + auto-route, so an operator had no way to see or correct it.""" + + def setUp(self): + super().setUp() + from .models import Device, Interface + + self.plan = FloorPlan.objects.create( + tenant=self.tenant, location=self.loc, name="Hall A" + ) + dev = Device.objects.create( + tenant=self.tenant, name="sw", site=self.site, rack=self.rack + ) + iface = Interface.objects.create(device=dev, name="e0") + self.cable = Cable.objects.create(tenant=self.tenant, label="c1") + CableTermination.objects.create( + cable=self.cable, end="A", interface=iface + ) + self.t1 = FloorPlanTray.objects.create( + floor_plan=self.plan, name="North", points=[[0, 0], [10, 0]] + ) + self.t2 = FloorPlanTray.objects.create( + floor_plan=self.plan, name="Riser", points=[[10, 0], [10, 8]] + ) + + def _url(self): + return f"/api/cables/{self.cable.id}/routing/" + + def test_unrouted_cable_reads_point_to_point(self): + r = self.client.get(f"{self._url()}?floor_plan={self.plan.id}") + self.assertEqual(r.status_code, 200, r.content) + body = r.json() + self.assertEqual(body["mode"], "point-to-point") + self.assertEqual(body["trays"], []) + # Every tray on the plan is offered as a choice. + self.assertEqual( + {t["name"] for t in body["available"]}, {"North", "Riser"} + ) + + def test_set_multiple_trays_in_order(self): + r = self.client.put( + self._url(), + { + "floor_plan": str(self.plan.id), + "tray_ids": [str(self.t2.id), str(self.t1.id)], + }, + format="json", + ) + self.assertEqual(r.status_code, 200, r.content) + self.assertEqual(r.json()["mode"], "trays") + self.assertEqual( + [t["name"] for t in r.json()["trays"]], ["Riser", "North"] + ) + self.assertEqual(self.cable.trays.count(), 2) + + def test_empty_list_is_point_to_point(self): + self.cable.trays.add(self.t1) + r = self.client.put( + self._url(), + {"floor_plan": str(self.plan.id), "tray_ids": []}, + format="json", + ) + self.assertEqual(r.status_code, 200, r.content) + self.assertEqual(r.json()["mode"], "point-to-point") + self.assertEqual(self.cable.trays.count(), 0) + + def test_foreign_tray_rejected(self): + other_plan = FloorPlan.objects.create( + tenant=self.tenant, location=self.loc, name="Hall B" + ) + stranger = FloorPlanTray.objects.create( + floor_plan=other_plan, name="Elsewhere", points=[[0, 0], [4, 0]] + ) + r = self.client.put( + self._url(), + { + "floor_plan": str(self.plan.id), + "tray_ids": [str(stranger.id)], + }, + format="json", + ) + self.assertEqual(r.status_code, 400) + self.assertEqual(self.cable.trays.count(), 0) + + def test_other_plans_assignments_survive(self): + other_plan = FloorPlan.objects.create( + tenant=self.tenant, location=self.loc, name="Hall B" + ) + elsewhere = FloorPlanTray.objects.create( + floor_plan=other_plan, name="Elsewhere", points=[[0, 0], [4, 0]] + ) + self.cable.trays.add(elsewhere) + self.client.put( + self._url(), + {"floor_plan": str(self.plan.id), "tray_ids": [str(self.t1.id)]}, + format="json", + ) + self.assertEqual( + set(self.cable.trays.values_list("name", flat=True)), + {"Elsewhere", "North"}, + ) + + def test_unknown_plan_rejected(self): + r = self.client.get( + f"{self._url()}?floor_plan=00000000-0000-0000-0000-000000000000" + ) + self.assertEqual(r.status_code, 400) class CableFloorPlanResolverTests(_Base): diff --git a/api/tests_mac_orphan.py b/api/tests_mac_orphan.py new file mode 100644 index 00000000..1d35c658 --- /dev/null +++ b/api/tests_mac_orphan.py @@ -0,0 +1,109 @@ +"""Deleting an interface must not deadlock on the MAC uniqueness constraint. + +``MACAddress.assigned_interface`` is SET_NULL so a MAC survives the port that +bore it, but ``uniq_macaddress_tenant_addr_iface`` is ``nulls_distinct=False`` +— only one unassigned row per (tenant, address). Orphaning a MAC that already +exists unassigned used to raise IntegrityError, surfacing as a 409 the user +could never get past. +""" +from __future__ import annotations + +from django.contrib.auth.models import User +from rest_framework.test import APITestCase + +from api.models import ( + Device, DeviceRole, DeviceType, Interface, MACAddress, Manufacturer, Site, +) +from api.test_utils import status_for +from auth_api.models import UserProfile +from core.models import Organization, Tenant + +MAC = "08:94:ef:00:dd:cc" + + +class MacOrphanOnInterfaceDeleteTests(APITestCase): + def setUp(self): + org = Organization.objects.create(name="O", slug="o") + self.tenant = Tenant.objects.create(org=org, name="T", slug="t") + self.su = User.objects.create_user("su", password="x", is_superuser=True) + prof = UserProfile.objects.create(user=self.su) + prof.tenants.add(self.tenant) + prof.current_tenant = self.tenant + prof.save() + site = Site.objects.create(tenant=self.tenant, name="AMS") + mfr = Manufacturer.objects.create(tenant=self.tenant, name="Lenovo", slug="lenovo") + dt = DeviceType.objects.create(tenant=self.tenant, manufacturer=mfr, model="x3650") + role = DeviceRole.objects.create(tenant=self.tenant, name="Server", slug="server") + self.dev = Device.objects.create( + tenant=self.tenant, name="srv1", device_type=dt, site=site, + role=role, status=status_for(self.tenant), + ) + self.client.force_login(self.su) + self.client.post(f"/api/tenants/{self.tenant.id}/switch/") + + def _iface(self, name: str) -> Interface: + return Interface.objects.create(device=self.dev, name=name) + + def test_delete_drops_mac_that_already_exists_unassigned(self): + """The reported case: discovery left both an assigned and a free row.""" + MACAddress.objects.create(tenant=self.tenant, mac_address=MAC) + iface = self._iface("eth0") + MACAddress.objects.create( + tenant=self.tenant, mac_address=MAC, assigned_interface=iface + ) + + resp = self.client.delete(f"/api/interfaces/{iface.id}/") + + self.assertEqual(resp.status_code, 204, resp.content) + self.assertFalse(Interface.objects.filter(pk=iface.pk).exists()) + # The address stays on file exactly once, unassigned. + remaining = MACAddress.objects.filter(tenant=self.tenant, mac_address=MAC) + self.assertEqual(remaining.count(), 1) + self.assertIsNone(remaining.get().assigned_interface_id) + + def test_delete_keeps_a_mac_with_no_unassigned_twin(self): + """SET_NULL semantics still hold — the MAC outlives its port.""" + iface = self._iface("eth0") + MACAddress.objects.create( + tenant=self.tenant, mac_address=MAC, assigned_interface=iface + ) + + resp = self.client.delete(f"/api/interfaces/{iface.id}/") + + self.assertEqual(resp.status_code, 204, resp.content) + mac = MACAddress.objects.get(tenant=self.tenant, mac_address=MAC) + self.assertIsNone(mac.assigned_interface_id) + + def test_bulk_delete_of_two_interfaces_sharing_one_mac(self): + """Both rows would orphan to NULL and collide with each other.""" + a, b = self._iface("eth0"), self._iface("eth1") + for iface in (a, b): + MACAddress.objects.create( + tenant=self.tenant, mac_address=MAC, assigned_interface=iface + ) + + resp = self.client.post( + "/api/interfaces/bulk-delete/", + {"ids": [str(a.id), str(b.id)]}, + format="json", + ) + + self.assertIn(resp.status_code, (200, 204), resp.content) + self.assertEqual(Interface.objects.filter(device=self.dev).count(), 0) + remaining = MACAddress.objects.filter(tenant=self.tenant, mac_address=MAC) + self.assertEqual(remaining.count(), 1) + self.assertIsNone(remaining.get().assigned_interface_id) + + def test_device_delete_cascades_without_colliding(self): + """Interfaces vanish via cascade, which fires the same receiver.""" + MACAddress.objects.create(tenant=self.tenant, mac_address=MAC) + iface = self._iface("eth0") + MACAddress.objects.create( + tenant=self.tenant, mac_address=MAC, assigned_interface=iface + ) + + self.dev.delete() + + remaining = MACAddress.objects.filter(tenant=self.tenant, mac_address=MAC) + self.assertEqual(remaining.count(), 1) + self.assertIsNone(remaining.get().assigned_interface_id) diff --git a/api/tests_modules.py b/api/tests_modules.py index 4f227ee1..2abed780 100644 --- a/api/tests_modules.py +++ b/api/tests_modules.py @@ -395,6 +395,114 @@ def test_crud_and_nesting(self): self.assertIn("same device", str(bad.content)) +class InventoryHardwareTests(_Base): + """P-H0: hardware kind/media/capacity/speed + lifecycle status on + inventory items, stamped from templates and settable via the API.""" + + def setUp(self): + super().setUp() + from api.status_registry import seed_builtin_statuses + + seed_builtin_statuses(self.tenant) + self.dt = DeviceType.objects.create(tenant=self.tenant, name="R750") + self.device = Device.objects.create( + tenant=self.tenant, name="srv1", device_type=self.dt + ) + + def test_hardware_fields_round_trip_with_status(self): + from api.models import Status + + failed = Status.objects.get(tenant=self.tenant, slug="failed") + self.assertIn("inventoryitem", failed.available_to) + resp = self.client.post( + "/api/inventory-items/", + {"device_id": str(self.device.id), "name": "Disk 1", + "kind": "disk", "media": "nvme", "capacity_bytes": 1_920_000_000_000, + "speed": "PCIe 4.0 x4", "status_id": str(failed.id)}, + format="json", + ) + self.assertEqual(resp.status_code, 201, resp.content) + data = resp.json() + self.assertEqual(data["kind"], "disk") + self.assertEqual(data["media"], "nvme") + self.assertEqual(data["capacity_bytes"], 1_920_000_000_000) + self.assertEqual(data["speed"], "PCIe 4.0 x4") + self.assertEqual(data["status"]["name"], "Failed") + + def test_rejects_unknown_kind(self): + resp = self.client.post( + "/api/inventory-items/", + {"device_id": str(self.device.id), "name": "X", + "kind": "flux-capacitor"}, + format="json", + ) + self.assertEqual(resp.status_code, 400) + + def test_template_stamps_hardware_fields(self): + from api.models import InventoryItemTemplate, materialize_device_components + + InventoryItemTemplate.objects.create( + device_type=self.dt, name="Bay {position}", kind="disk", + media="ssd", capacity_bytes=960_000_000_000, speed="6Gb/s", + ) + made = materialize_device_components(self.device) + self.assertEqual(made["inventory_items"], 1) + item = self.device.inventory_items.get(name="Bay 1") + self.assertEqual(item.kind, "disk") + self.assertEqual(item.media, "ssd") + self.assertEqual(item.capacity_bytes, 960_000_000_000) + self.assertEqual(item.speed, "6Gb/s") + + def test_bulk_update_status_and_hardware(self): + from api.models import InventoryItem, Status + + failed = Status.objects.get(tenant=self.tenant, slug="failed") + items = [ + InventoryItem.objects.create( + device=self.device, name=f"disk{i}", kind="disk" + ) + for i in range(1, 4) + ] + resp = self.client.post( + "/api/inventory-items/bulk-update/", + {"ids": [str(i.id) for i in items], + "fields": {"status_id": str(failed.id), "media": "nvme", + "capacity_bytes": 1_920_000_000_000}}, + format="json", + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()["updated"], 3) + for i in items: + i.refresh_from_db() + self.assertEqual(i.status_id, failed.id) + self.assertEqual(i.media, "nvme") + self.assertEqual(i.capacity_bytes, 1_920_000_000_000) + # Choice-backed fields reject typos. + bad = self.client.post( + "/api/inventory-items/bulk-update/", + {"ids": [str(items[0].id)], "fields": {"media": "floppy"}}, + format="json", + ) + self.assertEqual(bad.status_code, 400) + + def test_natural_name_ordering(self): + from api.models import InventoryItem + + for n in ("disk10", "disk2", "disk1"): + InventoryItem.objects.create(device=self.device, name=n) + resp = self.client.get( + f"/api/inventory-items/?device={self.device.id}&page_size=100" + ) + names = [r["name"] for r in resp.json()["results"]] + self.assertEqual(names, ["disk1", "disk2", "disk10"]) + + def test_seeded_inventory_statuses(self): + resp = self.client.get("/api/statuses/?available_to=inventoryitem") + self.assertEqual(resp.status_code, 200, resp.content) + slugs = {s["slug"] for s in resp.json()["results"]} + self.assertTrue({"active", "planned", "failed", "spare"} <= slugs) + + class DefaultModuleTests(_Base): """A bay template can name a default module type, pre-seated when a device is created and into an *empty* matching bay on sync-from-type — never diff --git a/api/tests_pathfinding.py b/api/tests_pathfinding.py new file mode 100644 index 00000000..37ca5dc3 --- /dev/null +++ b/api/tests_pathfinding.py @@ -0,0 +1,247 @@ +"""Auto-routing (Phase 3): the tray-graph router, length estimation, and the +route/auto-route endpoints.""" +from __future__ import annotations + +from django.contrib.auth import get_user_model +from rest_framework.test import APITestCase + +from core.models import Organization, Tenant + +from .pathfinding import ( + estimate_length_m, + route_through_trays, + tray_elevation_mm, +) +from .models import ( + Cable, CableTermination, Device, FloorPlan, FloorPlanTile, FloorPlanTray, + FloorTileType, Interface, Location, Rack, Site, +) + +User = get_user_model() + + +class RouterTests(APITestCase): + """Pure-geometry tests — no DB.""" + + def test_no_trays_is_straight_and_unreachable(self): + r = route_through_trays((0, 0), (10, 0), []) + self.assertFalse(r.reachable) + self.assertEqual(r.points, [(0, 0), (10, 0)]) + + def test_single_tray_rides_it(self): + # A at (0,2), B at (10,2), tray straight along y=1 from x=0..10. + tray = [(0.0, 1.0), (10.0, 1.0)] + r = route_through_trays((0, 2), (10, 2), [tray]) + self.assertTrue(r.reachable) + self.assertEqual(r.tray_indexes, [0]) + # Entry hop (1) + 10 along + exit hop (1) = 12 cells. + self.assertAlmostEqual(r.run_cells, 12.0, places=3) + + def test_t_split_branches(self): + # Main run along y=0; branch drops from (5,0) to (5,5) near B. + main = [(0.0, 0.0), (10.0, 0.0)] + branch = [(5.0, 0.0), (5.0, 5.0)] + r = route_through_trays((0, 1), (5, 6), [main, branch]) + self.assertTrue(r.reachable) + self.assertEqual(r.tray_indexes, [0, 1]) + # 1 (entry) + 5 (main) + 5 (branch) + 1 (exit) = 12. + self.assertAlmostEqual(r.run_cells, 12.0, places=2) + + def test_mid_segment_crossing_connects(self): + # Two trays crossing at (5,5) with no shared vertex. + h = [(0.0, 5.0), (10.0, 5.0)] + v = [(5.0, 0.0), (5.0, 10.0)] + r = route_through_trays((0, 4), (6, 10), [h, v]) + self.assertTrue(r.reachable) + self.assertEqual(r.tray_indexes, [0, 1]) + + def test_disconnected_trays_fall_back_straight(self): + # Two parallel trays far apart — no junction, so B's side is only + # reachable via its own entry… which IS connected through B's hop. + # Truly unreachable needs the graph split: A hops onto tray 0, B onto + # tray 1, and nothing links them. + t0 = [(0.0, 0.0), (2.0, 0.0)] + t1 = [(0.0, 10.0), (2.0, 10.0)] + r = route_through_trays((0, 1), (2, 9), [t0, t1]) + self.assertFalse(r.reachable) + self.assertEqual(r.points, [(0, 1), (2, 9)]) + + def test_shorter_of_two_paths_wins(self): + # A ring: top run is shorter than bottom. + top = [(0.0, 0.0), (10.0, 0.0)] + bottom = [(0.0, 0.0), (0.0, 6.0), (10.0, 6.0), (10.0, 0.0)] + r = route_through_trays((0, 0), (10, 0), [top, bottom]) + self.assertEqual(r.tray_indexes, [0]) + self.assertAlmostEqual(r.run_cells, 10.0, places=2) + + def test_length_estimate(self): + # 10 cells × 600mm = 6m run, 2m + 1m drops → 9m, +10% slack = 9.9. + self.assertAlmostEqual( + estimate_length_m(10, 600, 2000, 1000), 9.9, places=2 + ) + + def test_tray_elevation_derivation(self): + self.assertEqual(tray_elevation_mm("overhead", None, 3000), 2700) + self.assertEqual(tray_elevation_mm("underfloor", None, 3000), -300) + self.assertEqual(tray_elevation_mm("floor", None, 3000), 0) + self.assertEqual(tray_elevation_mm("overhead", 2400, 3000), 2400) + + +class RouteApiTests(APITestCase): + def setUp(self): + self.org = Organization.objects.create(name="Acme", slug="acme") + self.tenant = Tenant.objects.create(org=self.org, name="Acme", slug="acme") + admin = User.objects.create_superuser("admin", "admin@example.com", "x") + self.client.force_login(admin) + session = self.client.session + session["current_tenant_id"] = str(self.tenant.id) + session.save() + + self.site = Site.objects.create(tenant=self.tenant, name="AMS") + self.loc = Location.objects.create( + tenant=self.tenant, site=self.site, name="Hall", slug="hall" + ) + self.plan = FloorPlan.objects.create( + tenant=self.tenant, location=self.loc, name="Hall A", + cell_mm=600, ceiling_mm=3000, + ) + self.tt = FloorTileType.objects.create( + tenant=self.tenant, name="Rack", slug="rack" + ) + self.rack_a = Rack.objects.create( + tenant=self.tenant, site=self.site, name="RA", u_height=42 + ) + self.rack_b = Rack.objects.create( + tenant=self.tenant, site=self.site, name="RB", u_height=42 + ) + FloorPlanTile.objects.create( + floor_plan=self.plan, tile_type=self.tt, x=0, y=2, + rack=self.rack_a, link_kind="rack", + ) + FloorPlanTile.objects.create( + floor_plan=self.plan, tile_type=self.tt, x=10, y=2, + rack=self.rack_b, link_kind="rack", + ) + # One overhead tray connecting the two rack rows along y=1. + self.tray = FloorPlanTray.objects.create( + floor_plan=self.plan, name="OH-1", level="overhead", + points=[[0, 1], [11, 1]], + ) + self.dev_a = Device.objects.create( + tenant=self.tenant, name="sw-a", rack=self.rack_a + ) + self.dev_b = Device.objects.create( + tenant=self.tenant, name="sw-b", rack=self.rack_b + ) + + def test_route_preview(self): + resp = self.client.post( + f"/api/floor-plans/{self.plan.id}/route/", + {"from": {"kind": "rack", "id": str(self.rack_a.id)}, + "to": {"kind": "rack", "id": str(self.rack_b.id)}}, + format="json", + ) + self.assertEqual(resp.status_code, 200, resp.content) + body = resp.json() + self.assertTrue(body["reachable"]) + self.assertEqual(body["tray_ids"], [str(self.tray.id)]) + self.assertGreater(body["length_m"], 0) + # Drops: 42U rack top ≈ 1967mm, overhead tray at 2700 → ~733 each end. + self.assertAlmostEqual(body["drops_mm"][0], 733, delta=2) + + def test_route_devices_resolve_via_rack(self): + resp = self.client.post( + f"/api/floor-plans/{self.plan.id}/route/", + {"from": {"kind": "device", "id": str(self.dev_a.id)}, + "to": {"kind": "device", "id": str(self.dev_b.id)}}, + format="json", + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertTrue(resp.json()["reachable"]) + + def test_route_unplaced_endpoint_400(self): + ghost = Rack.objects.create( + tenant=self.tenant, site=self.site, name="GHOST" + ) + resp = self.client.post( + f"/api/floor-plans/{self.plan.id}/route/", + {"from": {"kind": "rack", "id": str(ghost.id)}, + "to": {"kind": "rack", "id": str(self.rack_b.id)}}, + format="json", + ) + self.assertEqual(resp.status_code, 400) + + def test_route_tenant_isolation(self): + other = Tenant.objects.create(org=self.org, name="Other", slug="other") + o_site = Site.objects.create(tenant=other, name="LON") + o_loc = Location.objects.create( + tenant=other, site=o_site, name="X", slug="x" + ) + hidden = FloorPlan.objects.create( + tenant=other, location=o_loc, name="Hidden" + ) + resp = self.client.post( + f"/api/floor-plans/{hidden.id}/route/", + {"from": {}, "to": {}}, format="json", + ) + self.assertEqual(resp.status_code, 404) + + def _cable(self): + cable = Cable.objects.create(tenant=self.tenant, label="C-1") + ia = Interface.objects.create(device=self.dev_a, name="eth0") + ib = Interface.objects.create(device=self.dev_b, name="eth0") + CableTermination.objects.create(cable=cable, end="A", interface=ia) + CableTermination.objects.create(cable=cable, end="B", interface=ib) + return cable + + def test_auto_route_persists_trays_and_length(self): + cable = self._cable() + resp = self.client.post( + f"/api/cables/{cable.id}/auto-route/", + {"floor_plan": str(self.plan.id)}, + format="json", + ) + self.assertEqual(resp.status_code, 200, resp.content) + body = resp.json() + self.assertTrue(body["reachable"]) + self.assertTrue(body["length_set"]) + cable.refresh_from_db() + self.assertEqual( + list(cable.trays.values_list("id", flat=True)), [self.tray.id] + ) + self.assertIsNotNone(cable.length) + self.assertEqual(cable.length_unit, "m") + + def test_auto_route_keeps_recorded_length_unless_overwrite(self): + cable = self._cable() + cable.length = 99 + cable.length_unit = "m" + cable.save() + self.client.post( + f"/api/cables/{cable.id}/auto-route/", + {"floor_plan": str(self.plan.id)}, format="json", + ) + cable.refresh_from_db() + self.assertEqual(float(cable.length), 99) + self.client.post( + f"/api/cables/{cable.id}/auto-route/", + {"floor_plan": str(self.plan.id), "overwrite": True}, + format="json", + ) + cable.refresh_from_db() + self.assertNotEqual(float(cable.length), 99) + + def test_auto_route_no_path_reports_unreachable(self): + self.tray.points = [[0, 1], [2, 1]] # stops far from rack B + self.tray.save() + # Rack B far outside snap distance of the truncated tray. + FloorPlanTile.objects.filter(rack=self.rack_b).update(x=40, y=30) + cable = self._cable() + resp = self.client.post( + f"/api/cables/{cable.id}/auto-route/", + {"floor_plan": str(self.plan.id)}, format="json", + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertFalse(resp.json()["reachable"]) + cable.refresh_from_db() + self.assertEqual(cable.trays.count(), 0) \ No newline at end of file diff --git a/api/tests_rack_types.py b/api/tests_rack_types.py new file mode 100644 index 00000000..26a86082 --- /dev/null +++ b/api/tests_rack_types.py @@ -0,0 +1,440 @@ +"""Rack type catalog: CRUD + tenancy, the 0U accessory rules, and the +opt-in stamping that turns a rack model's factory PDU strips into real +side-mounted devices (with their outlets materialised) on rack creation.""" + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import User + +from rest_framework.test import APITestCase + +from auth_api.models import ObjectPermission, UserProfile +from core.models import Organization, Tenant + +from .models import ( + Device, + DeviceType, + Manufacturer, + PowerOutletTemplate, + Rack, + RackType, + RackTypeAccessory, + Site, +) + + +class RackTypeCatalogTests(APITestCase): + def setUp(self): + org = Organization.objects.create(name="Acme", slug="acme") + self.tenant = Tenant.objects.create(org=org, name="Acme", slug="acme") + self.site = Site.objects.create(tenant=self.tenant, name="dc1") + self.mfr = Manufacturer.objects.create(tenant=self.tenant, name="APC") + self.dt_pdu = DeviceType.objects.create( + tenant=self.tenant, manufacturer=self.mfr, + name="Rack PDU Advanced", u_height=0, + ) + self.dt_1u = DeviceType.objects.create( + tenant=self.tenant, name="R650", u_height=1 + ) + # A second tenant to prove the fences. + org2 = Organization.objects.create(name="Evil", slug="evil") + self.tenant2 = Tenant.objects.create(org=org2, name="Evil", slug="evil") + self.mfr2 = Manufacturer.objects.create(tenant=self.tenant2, name="X") + self.dt2_pdu = DeviceType.objects.create( + tenant=self.tenant2, name="Their PDU", u_height=0 + ) + self.rt2 = RackType.objects.create( + tenant=self.tenant2, name="Their cabinet" + ) + admin = get_user_model().objects.create_superuser("admin", "a@b.c", "pw") + self._login(admin) + + def _login(self, user): + self.client.force_login(user) + sess = self.client.session + sess["current_tenant_id"] = str(self.tenant.id) + sess.save() + + def _limited_user(self, name, object_types, actions, sites=None): + u = User.objects.create_user(name, password="x") + prof = UserProfile.objects.create(user=u, role="custom") + prof.tenants.add(self.tenant) + perm = ObjectPermission.objects.create( + name=f"{name}-grant", object_types=object_types, actions=actions + ) + if sites: + perm.sites.set(sites) + perm.users.add(u) + return u + + def _rack_type(self, name="NetShelter SX 42U", **extra): + return self.client.post( + "/api/rack-types/", + {"name": name, "manufacturer_id": str(self.mfr.id), + "u_height": 42, "width": 19, "outer_width_mm": 600, + "outer_depth_mm": 1070, **extra}, + format="json", + ) + + def _accessory(self, rt_id, label="PDU-A", **extra): + return self.client.post( + "/api/rack-type-accessories/", + {"rack_type_id": str(rt_id), "label": label, + "device_type_id": str(self.dt_pdu.id), "mount": "side_left", + "mount_offset_mm": 100, "mount_span_u": 38, **extra}, + format="json", + ) + + # ── Catalog CRUD ───────────────────────────────────────────────────── + + def test_rack_type_roundtrips(self): + r = self._rack_type() + self.assertEqual(r.status_code, 201, r.content) + body = r.json() + self.assertEqual(body["manufacturer"]["name"], "APC") + self.assertEqual(body["u_height"], 42) + self.assertEqual(body["outer_depth_mm"], 1070) + self.assertEqual(body["rack_count"], 0) + self.assertEqual(body["accessories"], []) + + def test_duplicate_name_rejected_cleanly(self): + self.assertEqual(self._rack_type().status_code, 201) + r = self._rack_type() + self.assertEqual(r.status_code, 400) + self.assertIn("name", r.json()) + + def test_picker_returns_dims_for_prefill(self): + self._rack_type() + rows = self.client.get("/api/rack-types/?picker=1").json()["results"] + self.assertEqual(rows[0]["u_height"], 42) + self.assertEqual(rows[0]["outer_width_mm"], 600) + self.assertEqual(rows[0]["manufacturer"]["name"], "APC") + + def test_delete_refused_while_racks_use_it(self): + rt_id = self._rack_type().json()["id"] + Rack.objects.create( + tenant=self.tenant, site=self.site, name="r1", + rack_type_id=rt_id, + ) + r = self.client.delete(f"/api/rack-types/{rt_id}/") + self.assertEqual(r.status_code, 409) + + def test_other_tenants_types_invisible(self): + self._rack_type() + rows = self.client.get("/api/rack-types/?page_size=100").json() + names = [x["name"] for x in rows["results"]] + self.assertNotIn("Their cabinet", names) + + # ── Accessories ────────────────────────────────────────────────────── + + def test_accessory_requires_zero_u_type(self): + rt_id = self._rack_type().json()["id"] + r = self.client.post( + "/api/rack-type-accessories/", + {"rack_type_id": rt_id, "label": "shelf", + "device_type_id": str(self.dt_1u.id), "mount": "side_left"}, + format="json", + ) + self.assertEqual(r.status_code, 400) + self.assertIn("device_type_id", r.json()) + + def test_accessory_roundtrips_and_lists_by_type(self): + rt_id = self._rack_type().json()["id"] + r = self._accessory(rt_id) + self.assertEqual(r.status_code, 201, r.content) + self._accessory(rt_id, label="PDU-B", mount="side_right") + rows = self.client.get( + f"/api/rack-type-accessories/?rack_type={rt_id}" + ).json()["results"] + self.assertEqual([a["label"] for a in rows], ["PDU-A", "PDU-B"]) + self.assertEqual(rows[0]["device_type"]["u_height"], 0) + + def test_cross_tenant_rack_type_rejected(self): + r = self._accessory(self.rt2.id) + self.assertEqual(r.status_code, 400) + + def test_cross_tenant_device_type_rejected(self): + rt_id = self._rack_type().json()["id"] + r = self.client.post( + "/api/rack-type-accessories/", + {"rack_type_id": rt_id, "label": "PDU-A", + "device_type_id": str(self.dt2_pdu.id), "mount": "side_left"}, + format="json", + ) + self.assertEqual(r.status_code, 400) + + def test_cross_tenant_manufacturer_rejected(self): + r = self._rack_type(manufacturer_id=str(self.mfr2.id)) + self.assertEqual(r.status_code, 400) + + # ── Stamping on rack creation ──────────────────────────────────────── + + def _typed(self): + rt_id = self._rack_type().json()["id"] + self._accessory(rt_id, label="PDU-A", mount="side_left") + self._accessory(rt_id, label="PDU-B", mount="side_right") + return rt_id + + def _post_rack(self, name, rt_id, stamp): + return self.client.post( + "/api/racks/", + {"name": name, "site_id": str(self.site.id), + "rack_type_id": rt_id, "create_accessories": stamp}, + format="json", + ) + + def test_stamp_creates_mounted_devices_with_outlets(self): + PowerOutletTemplate.objects.create( + device_type=self.dt_pdu, name="out1" + ) + rt_id = self._typed() + r = self._post_rack("rack-01", rt_id, True) + self.assertEqual(r.status_code, 201, r.content) + rack = Rack.objects.get(name="rack-01") + devs = {d.name: d for d in rack.devices.all()} + self.assertEqual(set(devs), {"rack-01-PDU-A", "rack-01-PDU-B"}) + a = devs["rack-01-PDU-A"] + self.assertEqual(a.mount, "side_left") + self.assertEqual(a.mount_offset_mm, 100) + self.assertEqual(a.mount_span_u, 38) + self.assertIsNone(a.position) + self.assertEqual(a.site_id, rack.site_id) + # The type's outlet templates materialised — the PDU is a real PDU. + self.assertEqual(a.power_outlets.count(), 1) + # And the rack detail carries the type for the UI. + body = self.client.get(f"/api/racks/{rack.id}/").json() + self.assertEqual(body["rack_type"]["u_height"], 42) + + def test_stamp_dedupes_names(self): + rt_id = self._typed() + Device.objects.create(tenant=self.tenant, name="rack-02-PDU-A") + self._post_rack("rack-02", rt_id, True) + names = set( + Device.objects.filter(tenant=self.tenant) + .values_list("name", flat=True) + ) + self.assertIn("rack-02-PDU-A-2", names) + self.assertIn("rack-02-PDU-B", names) + + def test_no_stamp_unless_asked(self): + rt_id = self._typed() + self._post_rack("rack-03", rt_id, False) + rack = Rack.objects.get(name="rack-03") + self.assertEqual(rack.devices.count(), 0) + self.assertEqual(str(rack.rack_type_id), rt_id) + + def test_stamp_without_device_grant_is_403_and_atomic(self): + rt_id = self._typed() + self._login(self._limited_user( + "rackonly", ["rack", "racktype"], ["view", "add"] + )) + r = self._post_rack("rack-04", rt_id, True) + self.assertEqual(r.status_code, 403) + self.assertFalse(Rack.objects.filter(name="rack-04").exists()) + self.assertFalse( + Device.objects.filter(name__startswith="rack-04").exists() + ) + + def test_stamp_denied_outside_device_site_scope(self): + rt_id = self._typed() + other = Site.objects.create(tenant=self.tenant, name="dc2") + self._login(self._limited_user( + "scoped", ["rack", "racktype", "device"], ["view", "add"], + sites=[other], + )) + r = self._post_rack("rack-05", rt_id, True) + self.assertEqual(r.status_code, 403) + self.assertFalse(Rack.objects.filter(name="rack-05").exists()) + + def test_rack_only_user_can_still_create_untyped_racks(self): + self._login(self._limited_user("plain", ["rack"], ["view", "add"])) + r = self.client.post( + "/api/racks/", + {"name": "rack-06", "site_id": str(self.site.id)}, + format="json", + ) + self.assertEqual(r.status_code, 201, r.content) + + # ── Re-syncing an existing rack with its type ──────────────────────── + + def test_sync_dry_run_reports_drift_without_touching_anything(self): + rt_id = self._typed() + self._post_rack("rack-sync", rt_id, False) + rack = Rack.objects.get(name="rack-sync") + rack.u_height = 24 + rack.save(update_fields=["u_height"]) + + r = self.client.post( + f"/api/racks/{rack.id}/sync-from-type/", {}, format="json" + ) + self.assertEqual(r.status_code, 200, r.content) + body = r.json() + self.assertFalse(body["applied"]) + self.assertEqual(body["diff"]["dims"]["u_height"], + {"rack": 24, "type": 42}) + self.assertEqual( + sorted(body["diff"]["accessories"]["add"]), ["PDU-A", "PDU-B"] + ) + rack.refresh_from_db() + self.assertEqual(rack.u_height, 24) # dry run changed nothing + self.assertEqual(rack.devices.count(), 0) + + def test_sync_applies_dims_and_stamps_missing_accessories(self): + rt_id = self._typed() + self._post_rack("rack-apply", rt_id, False) + rack = Rack.objects.get(name="rack-apply") + rack.u_height = 24 + rack.save(update_fields=["u_height"]) + + r = self.client.post( + f"/api/racks/{rack.id}/sync-from-type/", {"apply": True}, + format="json", + ) + self.assertEqual(r.status_code, 200, r.content) + self.assertTrue(r.json()["applied"]) + rack.refresh_from_db() + self.assertEqual(rack.u_height, 42) + self.assertEqual( + set(rack.devices.values_list("name", flat=True)), + {"rack-apply-PDU-A", "rack-apply-PDU-B"}, + ) + + def test_sync_is_idempotent(self): + rt_id = self._typed() + self._post_rack("rack-twice", rt_id, True) + rack = Rack.objects.get(name="rack-twice") + r = self.client.post( + f"/api/racks/{rack.id}/sync-from-type/", {"apply": True}, + format="json", + ) + self.assertEqual(r.status_code, 200, r.content) + # Already stamped at create time — a second sync adds nothing. + self.assertEqual(r.json()["result"]["accessories"], []) + self.assertEqual(rack.devices.count(), 2) + + def test_sync_retypes_a_strip_when_the_accessory_changed(self): + # The reported bug: swap the accessory's device type and sync said + # "already matches" — it only ever asked whether a strip with that + # label existed, never whether it still agreed with the accessory. + rt_id = self._rack_type().json()["id"] + acc_id = self._accessory(rt_id, label="PDU").json()["id"] + self._post_rack("rack-retype", rt_id, True) + dev = Device.objects.get(name="rack-retype-PDU") + self.assertEqual(dev.device_type_id, self.dt_pdu.id) + + newer = DeviceType.objects.create( + tenant=self.tenant, manufacturer=self.mfr, + name="Rack PDU Advanced Gen 2", u_height=0, + ) + self.client.patch( + f"/api/rack-type-accessories/{acc_id}/", + {"device_type_id": str(newer.id), "face": "rear"}, + format="json", + ) + + rack = Rack.objects.get(name="rack-retype") + r = self.client.post( + f"/api/racks/{rack.id}/sync-from-type/", {}, format="json" + ) + changes = r.json()["diff"]["accessories"]["update"][0]["changes"] + self.assertEqual(changes["device_type"]["type"], + "Rack PDU Advanced Gen 2") + self.assertEqual(changes["face"]["type"], "rear") + + r = self.client.post( + f"/api/racks/{rack.id}/sync-from-type/", {"apply": True}, + format="json", + ) + self.assertEqual(r.status_code, 200, r.content) + self.assertEqual(r.json()["result"]["updated"], ["rack-retype-PDU"]) + dev.refresh_from_db() + self.assertEqual(dev.device_type_id, newer.id) + self.assertEqual(dev.face, "rear") + # Re-pointing a type never duplicates the strip. + self.assertEqual(rack.devices.count(), 1) + + def test_sync_never_deletes_an_extra_strip(self): + rt_id = self._typed() + self._post_rack("rack-extra", rt_id, True) + rack = Rack.objects.get(name="rack-extra") + # A strip nobody's type defines any more — real, possibly cabled gear. + Device.objects.create( + tenant=self.tenant, name="rack-extra-PDU-Z", site=self.site, + rack=rack, device_type=self.dt_pdu, mount="side_left", + ) + r = self.client.post( + f"/api/racks/{rack.id}/sync-from-type/", {"apply": True}, + format="json", + ) + self.assertEqual(r.status_code, 200, r.content) + self.assertEqual( + r.json()["diff"]["accessories"]["extra"], ["rack-extra-PDU-Z"] + ) + self.assertTrue( + Device.objects.filter(name="rack-extra-PDU-Z").exists() + ) + + def test_sync_can_take_dims_only(self): + rt_id = self._typed() + self._post_rack("rack-dims", rt_id, False) + rack = Rack.objects.get(name="rack-dims") + rack.u_height = 12 + rack.save(update_fields=["u_height"]) + r = self.client.post( + f"/api/racks/{rack.id}/sync-from-type/", + {"apply": True, "accessories": False}, + format="json", + ) + self.assertEqual(r.status_code, 200, r.content) + rack.refresh_from_db() + self.assertEqual(rack.u_height, 42) + self.assertEqual(rack.devices.count(), 0) + + def test_sync_refused_without_a_type(self): + rack = Rack.objects.create( + tenant=self.tenant, site=self.site, name="rack-bare" + ) + r = self.client.post( + f"/api/racks/{rack.id}/sync-from-type/", {}, format="json" + ) + self.assertEqual(r.status_code, 400) + + def test_accessory_face_stamps_onto_the_device(self): + # The channel an accessory names must reach the stamped device — + # otherwise every factory PDU lands face-blank and draws on both + # elevations, which is the thing this field exists to stop. + rt_id = self._rack_type().json()["id"] + self._accessory(rt_id, label="PDU-A", mount="side_left", face="rear") + self._post_rack("rack-face", rt_id, True) + dev = Device.objects.get(name="rack-face-PDU-A") + self.assertEqual(dev.face, "rear") + self.assertEqual(dev.mount, "side_left") + + def test_accessory_face_defaults_to_unspecified(self): + rt_id = self._rack_type().json()["id"] + r = self._accessory(rt_id) + self.assertEqual(r.status_code, 201, r.content) + self.assertEqual(r.json()["face"], "") + + def test_accessory_audit_entries_carry_the_owning_tenant(self): + # RackTypeAccessory has no tenant column; the audit trail stamps + # instance.tenant_id, so the model resolves it through the parent — + # otherwise these rows would log NULL/NULL and fail closed out of + # the tenant's own history. + from audit.models import ChangeLogEntry + + rt_id = self._rack_type().json()["id"] + self._accessory(rt_id) + entry = ChangeLogEntry.objects.filter( + object_type="api.racktypeaccessory" + ).latest("timestamp") + self.assertEqual(entry.tenant_id, self.tenant.id) + + def test_accessory_of_foreign_tenant_hidden(self): + RackTypeAccessory.objects.create( + rack_type=self.rt2, device_type=self.dt2_pdu, + label="theirs", mount="side_left", + ) + rows = self.client.get( + "/api/rack-type-accessories/?page_size=100" + ).json()["results"] + self.assertEqual(rows, []) diff --git a/api/tests_raised_floor.py b/api/tests_raised_floor.py new file mode 100644 index 00000000..42b4fdc7 --- /dev/null +++ b/api/tests_raised_floor.py @@ -0,0 +1,227 @@ +"""Raised-floor areas: CRUD, isolation, overlap rules, scene payload, and the +plenum-driven routing math that replaced the hardcoded −300.""" +from django.contrib.auth import get_user_model + +from rest_framework.test import APITestCase + +from core.models import Organization, Tenant + +from .models import ( + FloorPlan, + FloorPlanRaisedFloorArea, + FloorPlanTray, + Location, + Site, +) +from .pathfinding import ( + DEFAULT_PLENUM_MM, + rack_drop_mm, + tray_elevation_mm, + underfloor_plenum_mm, +) + + +class _Base(APITestCase): + def setUp(self): + org = Organization.objects.create(name="O", slug="o") + self.tenant = Tenant.objects.create(org=org, name="T", slug="t") + other_org = Organization.objects.create(name="OO", slug="oo") + self.other = Tenant.objects.create(org=other_org, name="X", slug="x") + U = get_user_model() + self.user = U.objects.create_superuser("rf", "rf@x.io", "pw") + self.client.force_authenticate(self.user) + s = self.client.session + s["tenant_id"] = str(self.tenant.id) + s.save() + self.site = Site.objects.create(tenant=self.tenant, name="S1") + self.loc = Location.objects.create( + tenant=self.tenant, site=self.site, name="DC" + ) + self.plan = FloorPlan.objects.create( + tenant=self.tenant, location=self.loc, name="Hall", + grid_width=20, grid_height=12, cell_mm=600, ceiling_mm=3000, + ) + + def _mk(self, **over): + body = { + "floor_plan_id": str(self.plan.id), + "x": 2, "y": 2, "width": 6, "height": 4, + "plenum_mm": 400, "label": "Pad A", + } + body.update(over) + return self.client.post( + "/api/floor-plan-raised-floors/", body, format="json" + ) + + +class RaisedFloorCrudTests(_Base): + def test_create_list_update_delete(self): + r = self._mk() + self.assertEqual(r.status_code, 201, r.content) + area_id = r.json()["id"] + + listed = self.client.get( + f"/api/floor-plan-raised-floors/?floor_plan={self.plan.id}" + ).json()["results"] + self.assertEqual([a["label"] for a in listed], ["Pad A"]) + + patched = self.client.patch( + f"/api/floor-plan-raised-floors/{area_id}/", + {"plenum_mm": 600}, + format="json", + ) + self.assertEqual(patched.status_code, 200) + self.assertEqual(patched.json()["plenum_mm"], 600) + + gone = self.client.delete(f"/api/floor-plan-raised-floors/{area_id}/") + self.assertEqual(gone.status_code, 204) + + def test_must_fit_the_grid(self): + r = self._mk(x=18, width=6) # 18+6 > 20 + self.assertEqual(r.status_code, 400) + self.assertIn("fit inside", str(r.content)) + + def test_overlap_rejected_but_touching_edges_allowed(self): + self.assertEqual(self._mk().status_code, 201) + # Overlapping by one cell → rejected. + r = self.client.post( + "/api/floor-plan-raised-floors/", + {"floor_plan_id": str(self.plan.id), + "x": 7, "y": 5, "width": 4, "height": 4}, + format="json", + ) + self.assertEqual(r.status_code, 400) + self.assertIn("Overlaps", str(r.content)) + # Sharing an edge (x = 8 starts where 2+6 ends) → fine: rectangles + # compose L-shaped pads by abutting. + ok = self.client.post( + "/api/floor-plan-raised-floors/", + {"floor_plan_id": str(self.plan.id), + "x": 8, "y": 2, "width": 4, "height": 4}, + format="json", + ) + self.assertEqual(ok.status_code, 201, ok.content) + + def test_cross_tenant_plan_rejected(self): + other_site = Site.objects.create(tenant=self.other, name="S2") + other_loc = Location.objects.create( + tenant=self.other, site=other_site, name="DC2" + ) + hidden = FloorPlan.objects.create( + tenant=self.other, location=other_loc, name="Hidden" + ) + r = self._mk(floor_plan_id=str(hidden.id)) + self.assertEqual(r.status_code, 400) + + def test_other_tenants_areas_invisible(self): + other_site = Site.objects.create(tenant=self.other, name="S2") + other_loc = Location.objects.create( + tenant=self.other, site=other_site, name="DC2" + ) + hidden_plan = FloorPlan.objects.create( + tenant=self.other, location=other_loc, name="Hidden" + ) + FloorPlanRaisedFloorArea.objects.create( + floor_plan=hidden_plan, x=0, y=0, width=2, height=2 + ) + listed = self.client.get("/api/floor-plan-raised-floors/").json() + self.assertEqual(listed["count"], 0) + + def test_scene_includes_raised_floors(self): + self._mk() + body = self.client.get( + f"/api/floor-plans/{self.plan.id}/scene/" + ).json() + self.assertEqual(len(body["raised_floors"]), 1) + rf = body["raised_floors"][0] + self.assertEqual( + (rf["x"], rf["y"], rf["w"], rf["h"], rf["plenum_mm"]), + (2, 2, 6, 4, 400), + ) + + +class PlenumMathTests(_Base): + """The pure helpers that replaced −300 ×3 and the two drop_mm closures.""" + + def test_tray_elevation_uses_the_plenum(self): + self.assertEqual(tray_elevation_mm("underfloor", None, 3000), -300.0) + self.assertEqual( + tray_elevation_mm("underfloor", None, 3000, plenum_mm=600), -600.0 + ) + # Explicit elevation always wins; overhead/floor unaffected by plenum. + self.assertEqual( + tray_elevation_mm("underfloor", -450, 3000, plenum_mm=600), -450.0 + ) + self.assertEqual( + tray_elevation_mm("overhead", None, 3000, plenum_mm=600), 2700.0 + ) + + def test_underfloor_plenum_containment_max_and_fallback(self): + areas = [(0, 0, 10, 10, 400), (10, 0, 10, 10, 700)] + # Run entirely in the first area. + self.assertEqual(underfloor_plenum_mm(areas, [[2, 2], [8, 2]]), 400.0) + # Run crossing both → the deeper void wins. + self.assertEqual(underfloor_plenum_mm(areas, [[8, 2], [12, 2]]), 700.0) + # Run outside every area → historical default. + self.assertEqual( + underfloor_plenum_mm(areas, [[2, 11], [8, 11]]), + float(DEFAULT_PLENUM_MM), + ) + self.assertEqual( + underfloor_plenum_mm([], [[1, 1]]), float(DEFAULT_PLENUM_MM) + ) + + def test_rack_drop_parity_with_the_old_inline_math(self): + # Old closure: abs(elev - (u_height * 44.45 + 100)). + self.assertAlmostEqual( + rack_drop_mm(42, "overhead", None, 3000), + abs((3000 - 300) - (42 * 44.45 + 100)), + ) + # Underfloor with a deep plenum: the drop grows with the void. + self.assertAlmostEqual( + rack_drop_mm(42, "underfloor", None, 3000, plenum_mm=600), + abs(-600 - (42 * 44.45 + 100)), + ) + # No rack (a panel end): drop measured from the floor. + self.assertAlmostEqual( + rack_drop_mm(None, "underfloor", None, 3000), 300.0 + ) + + def test_route_preview_drop_reflects_area_plenum(self): + """End-to-end: the same plan routes with a longer estimate once its + underfloor tray runs through a 600 mm plenum instead of the default.""" + from .models import FloorPlanTile, FloorTileType, Rack + + tt = FloorTileType.objects.create( + tenant=self.tenant, name="Rack", slug="rack" + ) + rack_a = Rack.objects.create(tenant=self.tenant, site=self.site, name="A") + rack_b = Rack.objects.create(tenant=self.tenant, site=self.site, name="B") + FloorPlanTile.objects.create( + floor_plan=self.plan, tile_type=tt, x=1, y=5, + rack=rack_a, link_kind="rack", + ) + FloorPlanTile.objects.create( + floor_plan=self.plan, tile_type=tt, x=15, y=5, + rack=rack_b, link_kind="rack", + ) + FloorPlanTray.objects.create( + floor_plan=self.plan, name="UF-1", level="underfloor", + points=[[1.5, 5.5], [15.5, 5.5]], + ) + body = {"from": {"kind": "rack", "id": str(rack_a.id)}, + "to": {"kind": "rack", "id": str(rack_b.id)}} + before = self.client.post( + f"/api/floor-plans/{self.plan.id}/route/", body, format="json" + ).json() + self.assertTrue(before["reachable"]) + + FloorPlanRaisedFloorArea.objects.create( + floor_plan=self.plan, x=0, y=0, width=20, height=12, + plenum_mm=600, + ) + after = self.client.post( + f"/api/floor-plans/{self.plan.id}/route/", body, format="json" + ).json() + # 300 mm deeper at both ends = 0.6 m more raw run; slack scales it. + self.assertGreater(after["length_m"], before["length_m"]) diff --git a/api/tests_tier1_parity.py b/api/tests_tier1_parity.py index 54e4875b..efb7700b 100644 --- a/api/tests_tier1_parity.py +++ b/api/tests_tier1_parity.py @@ -1,4 +1,4 @@ -"""Tier-1 NetBox-parity tests: circuit/tunnel terminations, console + device +"""Tier-1 migration-parity tests: circuit/tunnel terminations, console + device power components (incl. cable termination arms), and device-type component template materialization.""" from __future__ import annotations @@ -306,6 +306,40 @@ def test_interface_extras_roundtrip_and_stamp(self): self.assertEqual(upd.json()["duplex"], "full") self.assertEqual(upd.json()["wwn"], "10:00:00:90:fa:12:34:56") + def test_component_description_roundtrip_and_stamp(self): + InterfaceTemplate.objects.create( + device_type=self.dt, name="uplink", description="to spine", + ) + rear = RearPortTemplate.objects.create( + device_type=self.dt, name="R9", positions=2, description="trunk A", + ) + FrontPortTemplate.objects.create( + device_type=self.dt, name="F9", rear_port_template=rear, + description="patch A", + ) + r = self.client.post("/api/devices/", { + "name": "panel-1", "device_type_id": str(self.dt.id), + }, format="json") + self.assertEqual(r.status_code, 201, r.content) + dev_id = r.json()["id"] + dev = Device.objects.get(pk=dev_id) + self.assertEqual(dev.interfaces.get(name="uplink").description, "to spine") + self.assertEqual(dev.rear_ports.get(name="R9").description, "trunk A") + self.assertEqual(dev.front_ports.get(name="F9").description, "patch A") + + # Writable on the concrete components, and read back on the wire. + for path, obj in ( + ("interfaces", dev.interfaces.get(name="uplink")), + ("rear-ports", dev.rear_ports.get(name="R9")), + ("front-ports", dev.front_ports.get(name="F9")), + ): + upd = self.client.patch( + f"/api/{path}/{obj.id}/", {"description": "spare"}, + format="json", + ) + self.assertEqual(upd.status_code, 200, upd.content) + self.assertEqual(upd.json()["description"], "spare") + def test_device_create_materializes_components(self): r = self.client.post("/api/devices/", { "name": "access-sw-1", "device_type_id": str(self.dt.id), diff --git a/api/tests_tier2_parity.py b/api/tests_tier2_parity.py index e28c335f..f3564ee3 100644 --- a/api/tests_tier2_parity.py +++ b/api/tests_tier2_parity.py @@ -1,4 +1,4 @@ -"""Tier-2 NetBox-parity tests: virtual chassis, L2VPN, VM-interface L2/L3, +"""Tier-2 migration-parity tests: virtual chassis, L2VPN, VM-interface L2/L3, tenant/contact group nesting, and config-template resolution.""" from __future__ import annotations diff --git a/api/tests_walls.py b/api/tests_walls.py new file mode 100644 index 00000000..6e7d60d8 --- /dev/null +++ b/api/tests_walls.py @@ -0,0 +1,129 @@ +"""Floor-plan walls: CRUD, isolation, lattice snapping shared with trays, +opening validation, and the scene payload. v1 walls are documentation +geometry — nothing here touches routing, by design.""" +from django.contrib.auth import get_user_model + +from rest_framework.test import APITestCase + +from core.models import Organization, Tenant + +from .models import FloorPlan, FloorPlanWall, Location, Site + + +class _Base(APITestCase): + def setUp(self): + org = Organization.objects.create(name="O", slug="o") + self.tenant = Tenant.objects.create(org=org, name="T", slug="t") + other_org = Organization.objects.create(name="OO", slug="oo") + self.other = Tenant.objects.create(org=other_org, name="X", slug="x") + U = get_user_model() + self.user = U.objects.create_superuser("wall", "w@x.io", "pw") + self.client.force_authenticate(self.user) + s = self.client.session + s["tenant_id"] = str(self.tenant.id) + s.save() + self.site = Site.objects.create(tenant=self.tenant, name="S1") + self.loc = Location.objects.create( + tenant=self.tenant, site=self.site, name="DC" + ) + self.plan = FloorPlan.objects.create( + tenant=self.tenant, location=self.loc, name="Hall", + grid_width=20, grid_height=12, cell_mm=600, ceiling_mm=3000, + ) + + def _mk(self, **over): + body = { + "floor_plan_id": str(self.plan.id), + "label": "North wall", + "points": [[0, 0], [10, 0]], + } + body.update(over) + return self.client.post("/api/floor-plan-walls/", body, format="json") + + +class WallCrudTests(_Base): + def test_create_snap_update_delete(self): + r = self._mk(points=[[0.24, 0], [9.76, 0.26]]) + self.assertEqual(r.status_code, 201, r.content) + body = r.json() + # Half-cell snap — the same lattice rule trays use. + self.assertEqual(body["points"], [[0, 0], [10, 0.5]]) + wall_id = body["id"] + + patched = self.client.patch( + f"/api/floor-plan-walls/{wall_id}/", + {"height_mm": 2400, "label": "North"}, + format="json", + ) + self.assertEqual(patched.status_code, 200, patched.content) + self.assertEqual(patched.json()["height_mm"], 2400) + + gone = self.client.delete(f"/api/floor-plan-walls/{wall_id}/") + self.assertEqual(gone.status_code, 204) + + def test_tray_points_still_snap_via_the_shared_helper(self): + # Regression: extracting validate_lattice_points must not change tray + # behaviour by a hair. + r = self.client.post( + "/api/floor-plan-trays/", + {"floor_plan_id": str(self.plan.id), "name": "T1", + "points": [[0.24, 0], [3.76, 0]]}, + format="json", + ) + self.assertEqual(r.status_code, 201, r.content) + self.assertEqual(r.json()["points"], [[0, 0], [4, 0]]) + + def test_openings_validated_against_segments(self): + # Segment 0 runs (0,0)→(10,0): length 10 cells. + ok = self._mk(openings=[ + {"seg": 0, "from": 2, "to": 3.5, "height_mm": 2100}, + {"seg": 0, "from": 6, "to": 7, "height_mm": None}, + ]) + self.assertEqual(ok.status_code, 201, ok.content) + + bad_seg = self._mk(label="w2", openings=[{"seg": 3, "from": 0, "to": 1}]) + self.assertEqual(bad_seg.status_code, 400) + + past_end = self._mk(label="w3", openings=[{"seg": 0, "from": 9, "to": 11}]) + self.assertEqual(past_end.status_code, 400) + + overlap = self._mk(label="w4", openings=[ + {"seg": 0, "from": 2, "to": 4}, + {"seg": 0, "from": 3, "to": 5}, + ]) + self.assertEqual(overlap.status_code, 400) + self.assertIn("overlap", str(overlap.content)) + + taller_than_wall = self._mk( + label="w5", height_mm=2000, + openings=[{"seg": 0, "from": 1, "to": 2, "height_mm": 2400}], + ) + self.assertEqual(taller_than_wall.status_code, 400) + + def test_cross_tenant_plan_rejected_and_invisible(self): + other_site = Site.objects.create(tenant=self.other, name="S2") + other_loc = Location.objects.create( + tenant=self.other, site=other_site, name="DC2" + ) + hidden_plan = FloorPlan.objects.create( + tenant=self.other, location=other_loc, name="Hidden" + ) + r = self._mk(floor_plan_id=str(hidden_plan.id)) + self.assertEqual(r.status_code, 400) + + FloorPlanWall.objects.create( + floor_plan=hidden_plan, points=[[0, 0], [2, 0]] + ) + listed = self.client.get("/api/floor-plan-walls/").json() + self.assertEqual(listed["count"], 0) + + def test_scene_includes_walls(self): + self._mk(openings=[{"seg": 0, "from": 4, "to": 5, "height_mm": None}]) + body = self.client.get( + f"/api/floor-plans/{self.plan.id}/scene/" + ).json() + self.assertEqual(len(body["walls"]), 1) + w = body["walls"][0] + self.assertEqual(w["points"], [[0, 0], [10, 0]]) + self.assertIsNone(w["height_mm"]) + self.assertEqual(w["openings"][0]["from"], 4) diff --git a/api/tests_zero_u.py b/api/tests_zero_u.py new file mode 100644 index 00000000..5fc8fe37 --- /dev/null +++ b/api/tests_zero_u.py @@ -0,0 +1,184 @@ +"""Zero-U side mounting (vertical PDU strips): the placement rules, the two +rack-rollup fixes the feature owns (0U gear no longer charged a unit; PDU +draw no longer double-counted), and the scene payload that lets the 3D room +draw the strip.""" + +from django.contrib.auth import get_user_model + +from rest_framework.test import APITestCase + +from core.models import Organization, Tenant + +from .models import ( + Device, + DeviceType, + FloorPlan, + FloorPlanTile, + FloorTileType, + Location, + PowerOutlet, + PowerPort, + Rack, + Site, +) + + +class ZeroUMountTests(APITestCase): + def setUp(self): + org = Organization.objects.create(name="Acme", slug="acme") + self.tenant = Tenant.objects.create(org=org, name="Acme", slug="acme") + self.site = Site.objects.create(tenant=self.tenant, name="dc1") + self.loc = Location.objects.create( + tenant=self.tenant, site=self.site, name="Hall A", slug="hall-a" + ) + self.rack = Rack.objects.create( + tenant=self.tenant, site=self.site, location=self.loc, + name="rack-01", u_height=42, + ) + self.dt_pdu = DeviceType.objects.create( + tenant=self.tenant, name="Vertical PDU", u_height=0 + ) + self.dt_1u = DeviceType.objects.create( + tenant=self.tenant, name="R650", u_height=1 + ) + user = get_user_model().objects.create_superuser("admin", "a@b.c", "pw") + self.client.force_login(user) + sess = self.client.session + sess["current_tenant_id"] = str(self.tenant.id) + sess.save() + + def _post(self, name, dt, **extra): + return self.client.post( + "/api/devices/", + {"name": name, "device_type_id": str(dt.id), + "rack_id": str(self.rack.id), **extra}, + format="json", + ) + + # ── Placement rules ────────────────────────────────────────────────── + + def test_mount_requires_a_rack(self): + r = self.client.post( + "/api/devices/", + {"name": "pdu", "device_type_id": str(self.dt_pdu.id), + "mount": "side_left"}, + format="json", + ) + self.assertEqual(r.status_code, 400) + self.assertIn("mount", r.json()) + + def test_mount_requires_a_zero_u_type(self): + r = self._post("srv", self.dt_1u, mount="side_left") + self.assertEqual(r.status_code, 400) + self.assertIn("mount", r.json()) + + def test_mount_excludes_u_position(self): + r = self._post("pdu", self.dt_pdu, mount="side_left", position=5) + self.assertEqual(r.status_code, 400) + self.assertIn("position", r.json()) + + def test_mount_keeps_face_as_the_channel(self): + # `face` is NOT exclusive with a mount: on a 0U strip it names which + # channel the thing bolts into, and the elevation draws it on that + # face only. (It used to be rejected, which is why every PDU showed + # up on both the front AND rear elevation.) + r = self._post("pdu", self.dt_pdu, mount="side_left", face="rear") + self.assertEqual(r.status_code, 201, r.content) + self.assertEqual(r.json()["face"], "rear") + + def test_mount_never_keeps_a_half_width_side(self): + # A 0U strip is not half-width gear, so a stray rack_side is + # normalised away rather than rejected — the same rule that already + # keeps stale sides off every full-width device. + r = self._post("pdu", self.dt_pdu, mount="side_left", rack_side="left") + self.assertEqual(r.status_code, 201, r.content) + self.assertEqual(r.json()["rack_side"], "") + + def test_span_longer_than_the_rack_rejected(self): + r = self._post( + "pdu", self.dt_pdu, mount="side_left", mount_span_u=50 + ) + self.assertEqual(r.status_code, 400) + self.assertIn("mount_span_u", r.json()) + + def test_offset_without_mount_rejected(self): + r = self._post("pdu", self.dt_pdu, mount_offset_mm=150) + self.assertEqual(r.status_code, 400) + self.assertIn("mount", r.json()) + + def test_valid_mount_roundtrips(self): + r = self._post( + "pdu-a", self.dt_pdu, + mount="side_left", mount_offset_mm=150, mount_span_u=40, + ) + self.assertEqual(r.status_code, 201, r.content) + body = r.json() + self.assertEqual(body["mount"], "side_left") + self.assertEqual(body["mount_offset_mm"], 150) + self.assertEqual(body["mount_span_u"], 40) + self.assertIsNone(body["position"]) + + # ── The two rollup fixes ───────────────────────────────────────────── + + def test_zero_u_gear_occupies_no_units(self): + # A positioned 0U appliance AND a mounted strip: neither counts. + # (The old `or 1` charged the positioned one a full unit.) + self.assertEqual( + self._post("appl", self.dt_pdu, position=5).status_code, 201 + ) + self.assertEqual( + self._post("pdu", self.dt_pdu, mount="side_right").status_code, + 201, + ) + self.assertEqual( + self._post("srv", self.dt_1u, position=10).status_code, 201 + ) + r = self.client.get(f"/api/racks/{self.rack.id}/") + self.assertEqual(r.json()["used_units"], 1) # just the 1U server + + def test_rack_power_skips_distributors(self): + # The PDU's inlet restates its children's draw — counting both + # doubled the rack. Only the leaf device's draw may count. + pdu = Device.objects.create( + tenant=self.tenant, site=self.site, name="pdu", + device_type=self.dt_pdu, rack=self.rack, mount="side_left", + ) + PowerPort.objects.create( + device=pdu, name="inlet", allocated_draw=500, maximum_draw=1000 + ) + PowerOutlet.objects.create(device=pdu, name="out1") + srv = Device.objects.create( + tenant=self.tenant, site=self.site, name="srv", + device_type=self.dt_1u, rack=self.rack, position=10, + ) + PowerPort.objects.create( + device=srv, name="psu1", allocated_draw=500, maximum_draw=1000 + ) + p = self.client.get(f"/api/racks/{self.rack.id}/").json()["power"] + self.assertEqual(p["allocated_w"], 500) + self.assertEqual(p["maximum_w"], 1000) + + # ── Scene payload ──────────────────────────────────────────────────── + + def test_scene_carries_mounted_strips(self): + plan = FloorPlan.objects.create( + tenant=self.tenant, location=self.loc, name="Hall A" + ) + tt = FloorTileType.objects.create( + tenant=self.tenant, name="Rack", slug="rack" + ) + FloorPlanTile.objects.create( + floor_plan=plan, tile_type=tt, x=1, y=1, rack=self.rack + ) + Device.objects.create( + tenant=self.tenant, site=self.site, name="pdu", + device_type=self.dt_pdu, rack=self.rack, + mount="side_right", mount_offset_mm=100, mount_span_u=38, + ) + body = self.client.get(f"/api/floor-plans/{plan.id}/scene/").json() + tile = next(t for t in body["tiles"] if t["rack"]) + dev = next(d for d in tile["rack"]["devices"] if d["name"] == "pdu") + self.assertIsNone(dev["position"]) + self.assertEqual(dev["mount"], "side_right") + self.assertEqual(dev["mount_offset_mm"], 100) + self.assertEqual(dev["mount_span_u"], 38) diff --git a/api/viewsets.py b/api/viewsets.py index 5438fe1b..8e193577 100644 --- a/api/viewsets.py +++ b/api/viewsets.py @@ -6,6 +6,7 @@ from __future__ import annotations from django.db import transaction +from django.db.models.functions import Collate from django.db.models import Count, Q from django.utils.text import slugify from drf_spectacular.utils import extend_schema, extend_schema_view @@ -29,7 +30,8 @@ Contact, ContactAssignment, ContactGroup, ContactRole, Device, DeviceType, FHRPGroup, FHRPGroupAssignment, FiberSettings, - FloorPlan, FloorPlanTile, FloorPlanTray, FloorTileType, SiteMarker, + FloorPlan, FloorPlanRaisedFloorArea, FloorPlanTile, FloorPlanTray, + FloorPlanWall, FloorTileType, SiteMarker, FrontPort, FrontPortTemplate, InterfaceTemplate, DeviceTypeService, IPAddress, IPRange, IPRole, Status, Interface, MACAddress, Manufacturer, @@ -40,7 +42,7 @@ PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Prefix, Provider, ProviderNetwork, RearPort, RearPortTemplate, - DeviceRole, Platform, PlatformGroup, Rack, RackRole, RIR, RouteTarget, Service, ServiceTemplate, Site, VirtualMachine, VMInterface, VLAN, VLANGroup, VRF, Zone, + DeviceRole, Platform, PlatformGroup, Rack, RackRole, RackType, RackTypeAccessory, RIR, RouteTarget, Service, ServiceTemplate, Site, VirtualMachine, VMInterface, VLAN, VLANGroup, VRF, Zone, WirelessLAN, WirelessLANGroup, Tunnel, TunnelGroup, TunnelTermination, IPSecProfile, L2VPN, L2VPNTermination, VirtualChassis, @@ -54,7 +56,9 @@ CableSerializer, FiberSettingsSerializer, FloorPlanMiniSerializer, + FloorPlanRaisedFloorAreaSerializer, FloorPlanTraySerializer, + FloorPlanWallSerializer, FloorPlanSerializer, FloorPlanTileSerializer, SiteMarkerSerializer, @@ -126,6 +130,9 @@ RackSerializer, RackRoleSerializer, RackRoleMiniSerializer, + RackTypeSerializer, + RackTypeMiniSerializer, + RackTypeAccessorySerializer, DeviceRoleSerializer, PlatformGroupMiniSerializer, PlatformGroupSerializer, @@ -228,6 +235,12 @@ def _apply_custom_field_scope(request, qs, model_slug: str): from .views import _build_space_map, _get_active_tenant, _next_available_ips, _subnet_details + +# Human/natural name ordering ("disk2" before "disk10") — backed by the +# `natural_sort` ICU collation (migration 0099). Used wherever a list orders +# by a user-visible name. +NATURAL_NAME = Collate("name", "natural_sort") + class StandardPagination(PageNumberPagination): # The SPA loads the full result set and paginates/filters client-side (the # DataTable pager uses the user's page_size preference), so return everything @@ -763,7 +776,7 @@ def clone(self, request, pk=None): class ImageAttachmentMixin: - """Adds NetBox-style image attachments to any tenant-scoped detail viewset. + """Adds image attachments to any tenant-scoped detail viewset. Mix it into a viewset and its objects gain an ``images`` nested endpoint: @@ -1273,7 +1286,7 @@ class VRFViewSet(CatalogLocalityMixin, CloneableMixin, TenantScopedViewSet): VRF.objects .prefetch_related("import_targets", "export_targets", "tags") .all() - .order_by("name") + .order_by(NATURAL_NAME) ) serializer_class = VRFSerializer pagination_class = StandardPagination @@ -1324,7 +1337,7 @@ class RouteTargetViewSet(CatalogLocalityMixin, TenantScopedViewSet): RouteTarget.objects .prefetch_related("importing_vrfs", "exporting_vrfs", "tags") .all() - .order_by("name") + .order_by(NATURAL_NAME) ) serializer_class = RouteTargetSerializer pagination_class = StandardPagination @@ -1358,7 +1371,7 @@ def bulk_delete(self, request): class SiteViewSet(ImageAttachmentMixin, TenantScopedViewSet): - queryset = Site.objects.prefetch_related("tags", "vrfs").all().order_by("name") + queryset = Site.objects.prefetch_related("tags", "vrfs").all().order_by(NATURAL_NAME) serializer_class = SiteSerializer pagination_class = StandardPagination rbac_action_map = {"bulk_delete": "delete"} @@ -1519,7 +1532,7 @@ class TagViewSet(CatalogLocalityMixin, TenantScopedViewSet): permission_classes = [permissions.IsAuthenticated] pagination_class = _PickerPagination - queryset = Tag.objects.all().order_by("name") + queryset = Tag.objects.all().order_by(NATURAL_NAME) serializer_class = TagManageSerializer def get_serializer_class(self): @@ -1533,7 +1546,7 @@ def get_queryset(self): return Tag.objects.none() qs = ( Tag.objects.filter(Q(tenant=tenant) | Q(tenant__isnull=True)) - .order_by("name") + .order_by(NATURAL_NAME) ) if self.request: search = self.request.query_params.get("search", "").strip() @@ -1726,7 +1739,7 @@ class TenantGroupViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] pagination_class = StandardPagination - queryset = TenantGroup.objects.select_related("parent").order_by("name") + queryset = TenantGroup.objects.select_related("parent").order_by(NATURAL_NAME) serializer_class = TenantGroupSerializer def get_queryset(self): @@ -1764,7 +1777,7 @@ class TenantViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] pagination_class = StandardPagination - queryset = Tenant.objects.all().order_by("name") + queryset = Tenant.objects.all().order_by(NATURAL_NAME) serializer_class = TenantSerializer rbac_action_map = {"bulk_delete": "delete", "bulk_update": "change"} @@ -2015,7 +2028,7 @@ class ZoneViewSet(_IpCatalogViewSet): class ManufacturerViewSet(CatalogLocalityMixin, TenantScopedViewSet): - queryset = Manufacturer.objects.all().order_by("name") + queryset = Manufacturer.objects.all().order_by(NATURAL_NAME) serializer_class = ManufacturerSerializer pagination_class = StandardPagination @@ -2077,7 +2090,7 @@ def _check_unique_name(model, serializer, tenant, noun): class DeviceTypeViewSet(CatalogLocalityMixin, CloneableMixin, TenantScopedViewSet): queryset = ( - DeviceType.objects.select_related("manufacturer", "platform").prefetch_related("tags").all().order_by("name") + DeviceType.objects.select_related("manufacturer", "platform").prefetch_related("tags").all().order_by(NATURAL_NAME) ) serializer_class = DeviceTypeSerializer pagination_class = StandardPagination @@ -2091,18 +2104,172 @@ class DeviceTypeViewSet(CatalogLocalityMixin, CloneableMixin, TenantScopedViewSe "end_of_support", "lifecycle_url", ) + # Importing a bundle creates a device type, so it demands `add`; replacing + # one that already exists is a change and checked separately. Bulk delete + # must demand `delete` — the @action default is `change`, which would let a + # read/write-but-not-delete editor empty the catalog. Reimporting images + # rewrites existing rows' image fields — `change`, pinned explicitly so the + # row restriction below scopes the batch the same way. + rbac_action_map = { + "import_bundle": "add", + "bulk_delete": "delete", + "reimport_images": "change", + } + + @action(detail=True, methods=["get"], url_path="library-export") + def library_export(self, request, pk=None): + """This device type as a portable bundle — templates, faceplate, + photo-port markers, inventory templates and bound SNMP sensors. + + The point of the device library: the work of teaching Danbyte a piece of + hardware is model knowledge, identical for everyone who owns the box, so + it should move as a file instead of being redone. Carries no credentials + (see ``api/device_library.py``). + """ + from .device_library import export_bundle + + return Response(export_bundle(self.get_object())) + + @action(detail=False, methods=["post"], url_path="import-bundle") + def import_bundle(self, request): + """Create or update a device type from a bundle. + + ``?dry_run=1`` reports what would happen and writes nothing — importing + a stranger's file should never be a blind action. ``?replace=1`` is + required to touch a type that already exists here, and additionally + demands `change`. + """ + from auth_api import rbac + + from .device_library import BundleError, import_bundle as run_import + + tenant = self._tenant_or_403() + flag = lambda k: str( # noqa: E731 - tiny local reader + request.query_params.get(k, "") + ).lower() in ("1", "true", "yes") + replace, dry_run = flag("replace"), flag("dry_run") + if replace and not ( + request.user.is_superuser + or rbac.has_action(request.user, tenant, "devicetype", "change") + ): + raise PermissionDenied( + "Replacing an existing device type needs change access; import " + "without replace to add only what's new." + ) + try: + return Response( + run_import( + request.data, tenant, replace=replace, dry_run=dry_run, + owning_site=self._import_owning_site(request, tenant), + ) + ) + except BundleError as exc: + raise ValidationError({"detail": str(exc)}) from exc + + def _import_owning_site(self, request, tenant): + """Under enhanced site separation, a site-scoped importer's new device + types (and any manufacturers minted along the way) are LOCAL to their + site — the raw import path skips the post-save guard, so resolve the + one editable site here (or fail if their scope spans several).""" + from core.effective_settings import separation_enabled + + if request.user.is_superuser or not separation_enabled(tenant): + return None + from auth_api import rbac + + editable = rbac.editable_sites(request.user, tenant) + if not isinstance(editable, set): + return None + if len(editable) != 1: + raise PermissionDenied( + "Site-scoped import needs exactly one editable site — yours " + "spans several, so imported types have no home." + ) + return Site.objects.filter( + tenant=tenant, pk=next(iter(editable)) + ).first() + + def _run_dict(self, run): + return { + "id": str(run.id), + "kind": run.kind, + "source_url": run.source_url, + "status": run.status, + "progress": run.progress or {}, + "failures": run.failures or [], + "options": run.options or {}, + "error": run.error, + "created_at": run.created_at.isoformat(), + "finished_at": run.finished_at.isoformat() + if run.finished_at else None, + } + + @action(detail=False, methods=["post"], url_path="import-folder") + def import_folder(self, request): + """Start a BACKGROUND import of a whole devicetype-library folder — a + manufacturer, or the entire device-types dir (thousands of files). + + Body: {"url": "", "stack_positions": bool}. + Returns the run so the client can poll ``import-runs//``. The + synchronous ``import-yaml`` handles small pastes; this handles bulk.""" + from .devicetype_import import is_github_dir + from .devicetype_import_tasks import enqueue_devicetype_import + + tenant = self._tenant_or_403() + url = str((request.data or {}).get("url") or "").strip() + if not is_github_dir(url): + return Response( + {"detail": "Provide a GitHub folder (/tree/) URL."}, + status=drf_status.HTTP_400_BAD_REQUEST, + ) + owning_site = self._import_owning_site(request, tenant) + run = enqueue_devicetype_import( + tenant, url, + stack=bool((request.data or {}).get("stack_positions")), + owning_site=owning_site, user=request.user, + ) + return Response( + self._run_dict(run), status=drf_status.HTTP_201_CREATED + ) + + @action( + detail=False, methods=["get"], + url_path=r"import-runs/(?P[0-9a-f-]+)", + ) + def import_run(self, request, run_id=None): + """Poll one background import run (tenant-scoped).""" + from .models import DeviceTypeImportRun + + tenant = self._tenant_or_403() + run = DeviceTypeImportRun.objects.filter( + id=run_id, tenant=tenant + ).first() + if run is None: + return Response(status=drf_status.HTTP_404_NOT_FOUND) + return Response(self._run_dict(run)) + @action(detail=False, methods=["post"], url_path="import-yaml") def import_yaml(self, request): """Import device types from NetBox devicetype-library YAML. Body: {"items": ["", …], "stack_positions": bool}. - Each item is either a raw YAML document or a URL to one (github.com - blob links are converted to raw automatically). Returns one report - per item; content problems never abort the batch. + Each item is a raw YAML document, a URL to one (github.com blob links + convert to raw automatically), or a github.com ``/tree/`` **folder** + URL — expanded to every .yaml under it (one manufacturer, or the whole + device-types dir, capped for this synchronous path). Returns one report + per file; content problems never abort the batch. """ from core.ssrf import SSRFError, safe_get - from .devicetype_import import import_yaml_auto, to_raw_url + from .devicetype_import import ( + expand_github_dir, import_yaml_auto, is_github_dir, to_raw_url, + ) + + # A GitHub /tree/ directory URL fetched as-is returns HTML, not YAML. + # This is a synchronous request, so it can only fetch so many files + # before the proxy times out — a whole-library import (thousands) needs + # the background path, not this one. + SYNC_FILE_CAP = 200 tenant = self._tenant_or_403() body = request.data or {} @@ -2114,25 +2281,44 @@ def import_yaml(self, request): ) stack = bool(body.get("stack_positions")) - # Enhanced site separation: a site-scoped importer's new device types - # (and any manufacturers minted along the way) are LOCAL to their - # site — this raw path skips the post-save guard, so force it here. - from core.effective_settings import separation_enabled - - owning_site = None - if not request.user.is_superuser and separation_enabled(tenant): - from auth_api import rbac - - editable = rbac.editable_sites(request.user, tenant) - if isinstance(editable, set): - if len(editable) != 1: - raise PermissionDenied( - "Site-scoped import needs exactly one editable site — " - "yours spans several, so imported types have no home." + # Expand any directory (tree) URLs into their individual YAML files + # up front, so "paste a folder link" and "a whole vendor" just work. + expanded: list[str] = [] + for item in items: + text = str(item or "").strip() + if is_github_dir(text): + try: + files = expand_github_dir(text, safe_get) + except SSRFError as exc: + return Response( + {"detail": f"Refused: {exc}"}, + status=drf_status.HTTP_400_BAD_REQUEST, + ) + except Exception as exc: # noqa: BLE001 + return Response( + {"detail": f"Couldn't list that folder: {exc}"}, + status=drf_status.HTTP_400_BAD_REQUEST, + ) + if not files: + return Response( + {"detail": "That folder has no .yaml device types."}, + status=drf_status.HTTP_400_BAD_REQUEST, ) - owning_site = Site.objects.filter( - tenant=tenant, pk=next(iter(editable)) - ).first() + expanded.extend(files) + else: + expanded.append(text) + if len(expanded) > SYNC_FILE_CAP: + return Response( + {"detail": ( + f"That expands to {len(expanded)} files — over the " + f"{SYNC_FILE_CAP} this import handles at once. Pick a " + "narrower folder (e.g. one manufacturer)." + )}, + status=drf_status.HTTP_400_BAD_REQUEST, + ) + items = expanded + + owning_site = self._import_owning_site(request, tenant) results = [] for item in items: @@ -2147,6 +2333,16 @@ def import_yaml(self, request): resp = safe_get(url, timeout=10) resp.raise_for_status() text = resp.text + stripped = text.lstrip() + if stripped[:1] == "<" or stripped[:9].lower() == ""}`` — + blank uses Danbyte's device-library fork. ``?dry_run=1`` (bundle-import + convention; a body flag works too) classifies without downloading: + ``matched`` / ``no_match`` / ``skipped_has_images``. Default apply is + fill-gaps-only — a face is written only when its field is empty or the + file is missing from storage; ``?overwrite=1`` replaces intact images + too. Catalogs over the sync cap run in the background instead (202 + + ``{"run": …}``, poll ``import-runs//``). + + The batch is the ``change``-restricted queryset (tenant + row + constraints, same discipline as ``bulk_delete``); airgapped + deployments get a clean 409 before any outbound attempt.""" + from .devicetype_import import ( + DEFAULT_REIMPORT_REPO, + REIMPORT_SYNC_CAP, + airgap_refusal, + elevation_image_base, + reimport_images_for_type, + repo_image_inventory, + summarize_reimport, + ) + from .devicetype_import_tasks import enqueue_devicetype_image_reimport + + tenant = self._tenant_or_403() + refusal = airgap_refusal() + if refusal: + return Response( + {"detail": refusal}, status=drf_status.HTTP_409_CONFLICT + ) + + body = request.data or {} + + def flag(k): + v = request.query_params.get(k) + if v is None: + v = body.get(k) + if isinstance(v, bool): + return v + return str(v or "").lower() in ("1", "true", "yes") + + dry_run, overwrite = flag("dry_run"), flag("overwrite") + repo = str(body.get("repo") or "").strip() or DEFAULT_REIMPORT_REPO + try: + image_base = elevation_image_base(repo) + except ValueError as exc: + return Response( + {"detail": str(exc)}, status=drf_status.HTTP_400_BAD_REQUEST + ) + + qs = ( + self.get_queryset() + .select_related("manufacturer") + .order_by("name") + ) + if qs.count() > REIMPORT_SYNC_CAP: + run = enqueue_devicetype_image_reimport( + tenant, image_base, overwrite=overwrite, dry_run=dry_run, + user=request.user, + ) + return Response( + {"run": self._run_dict(run)}, + status=drf_status.HTTP_202_ACCEPTED, + ) + + # One repo listing (two requests) up front; None falls back to probes. + inventory = repo_image_inventory(image_base) + results = [ + reimport_images_for_type( + dt, image_base, overwrite=overwrite, apply=not dry_run, + inventory=inventory, + ) + for dt in qs + ] + return Response({ + "dry_run": dry_run, + "overwrite": overwrite, + "repo": image_base, + "results": results, + "totals": summarize_reimport(results), + }) + def get_serializer_class(self): if self.action == "list" and self.request and self.request.query_params.get("picker") == "1": return DeviceTypeMiniSerializer @@ -2197,6 +2482,43 @@ def perform_update(self, serializer): _check_unique_name(DeviceType, serializer, self._tenant_or_403(), "device type") serializer.save() + @action(detail=False, methods=["post"], url_path="bulk-delete") + def bulk_delete(self, request): + """POST {ids: [...]} → ``{"deleted": n}``. + + The submitted ids are never trusted: the selection is re-derived from + ``get_queryset()``, which is tenant-filtered and then row-restricted for + the *delete* action (``rbac_action_map`` above). An id from another + tenant — or, with enhanced site separation on, a global entry or one + local to a site outside the caller's grant — simply falls out of the + set rather than being deleted. + + ``n`` counts DEVICE TYPES, not the cascade. A type drags its component + templates (interfaces, ports, bays…) with it, so the total returned by + ``qs.delete()`` would report a single 48-port switch type as "49 + deleted". Devices are NOT deleted — ``Device.device_type`` is + SET_NULL, so they keep running and lose their type reference; the UI + warns about that before it calls this. + """ + ids = request.data.get("ids") or [] + if not isinstance(ids, list) or not ids: + raise ValidationError( + {"ids": "Provide a non-empty list of device type IDs."} + ) + if len(ids) > 1000: + raise ValidationError({"ids": "At most 1000 ids per call."}) + with transaction.atomic(): + rows = list(self.get_queryset().filter(pk__in=ids)) + # No log_bulk_delete() here, deliberately: that helper exists for + # deletes Django can "fast delete" (no signals). DeviceType is in + # AUDITED_MODELS *and* cascades, so the collector always fires + # post_delete — one richer entry per row (it carries the + # pre_change field snapshot) plus entries for the templates that + # go with it. Adding the explicit call would log every deletion + # TWICE. Covered by tests_catalog_scope. + DeviceType.objects.filter(pk__in=[r.pk for r in rows]).delete() + return Response({"deleted": len(rows)}, status=drf_status.HTTP_200_OK) + @action(detail=True, methods=["post"], url_path="images", parser_classes=[MultiPartParser, FormParser]) def images(self, request, pk=None): @@ -2248,7 +2570,7 @@ def _region_and_descendant_ids(region_id): class DeviceViewSet(CloneableMixin, ImageAttachmentMixin, TenantScopedViewSet): queryset = ( Device.objects.select_related("device_type", "device_type__platform", "site", "primary_ip") - .prefetch_related("tags").all().order_by("name") + .prefetch_related("tags").all().order_by(NATURAL_NAME) ) serializer_class = DeviceSerializer pagination_class = StandardPagination @@ -2265,6 +2587,169 @@ def config_context(self, request, pk=None): return Response(render_config_context(self.get_object())) + # Photo-port marker kind (hyphenated, as saved in DeviceType.image_ports) → + # (device component relation, CableTermination kind). Drives face-ports. + # Inventory items (disk bays…) and module bays (line-card slots) are + # placeable but not cable-able, hence the None termination kind: a part + # answers "what health", a bay answers "occupied or free". + _FACE_PORT_KINDS = { + "interface": ("interfaces", "interface"), + "console-port": ("console_ports", "console_port"), + "console-server-port": ("console_server_ports", "console_server_port"), + "power-port": ("power_ports", "power_port"), + "power-outlet": ("power_outlets", "power_outlet"), + "front-port": ("front_ports", "front_port"), + "rear-port": ("rear_ports", "rear_port"), + "aux-port": ("aux_ports", "aux_port"), + "inventory-item": ("inventory_items", None), + "module-bay": ("module_bays", None), + } + + # Observed-vs-intent difference → the one-line label a marker wears. Keeps + # the phrasing in one place so 2D hovercards and the 3D HUD agree. + @staticmethod + def _face_drift_label(item: dict) -> str: + kind = item.get("kind") + if kind == "part_status": + return f"SNMP says {item.get('observed')}" + if kind == "interface_mismatch": + return f"{item.get('field')}: SNMP says {item.get('observed')}" + if kind == "ip_missing": + return f"SNMP reports {item.get('ip')}, not recorded" + if kind == "interface_stale": + return "not reported by SNMP" + return "differs from SNMP" + + def _face_drift(self, device) -> dict[str, str]: + """Drift labels for this device's components, keyed by component id. + + Imported inside the method: ``monitoring`` imports ``api``, so a + module-level import would close the cycle. + """ + from monitoring.snmp_drift import compute_device_drift + + out: dict[str, str] = {} + for item in compute_device_drift(device, device.tenant): + cid = item.get("part_id") or item.get("interface_id") + # First difference wins — the marker only has room for one line, and + # its job is "look here", not "here is the full report". + if cid and cid not in out: + out[cid] = self._face_drift_label(item) + return out + + @action(detail=True, methods=["get"], url_path="face-ports") + def face_ports(self, request, pk=None): + """Resolve this device's photo-port markers (from its device type's + ``image_ports``) to the device's REAL components: the port id, its + cable-termination kind, whether it's already cabled, and whether SNMP + sees it differently than the record does. The 3D room view needs this to + turn a clicked marker into a termination it can cable — the marker + itself only carries a template name — and to flag drift without a + second request per device in the rack.""" + from .models import render_component_name + + device = self.get_object() + dt = device.device_type + image_ports = (dt.image_ports if dt else None) or {} + pos = device.vc_position + drift = self._face_drift(device) + + # Load each component relation we actually need exactly once, keyed by + # rendered name, with terminations prefetched for the cabled check. + name_maps: dict[str, dict[str, object]] = {} + + def name_map(relation, cabled: bool): + if relation not in name_maps: + comps = getattr(device, relation) + # Only cable-able kinds have terminations; inventory items + # carry a status instead, and a module bay's occupancy is the + # reverse Module relation (there is no field on the bay). + if cabled: + comps = comps.prefetch_related("terminations") + elif relation == "module_bays": + comps = comps.select_related("module__module_type") + else: + comps = comps.select_related("status") + name_maps[relation] = {c.name: c for c in comps} + return name_maps[relation] + + def resolve(markers): + out = [] + for m in markers if isinstance(markers, list) else []: + raw = m.get("name", "") if isinstance(m, dict) else "" + kind = m.get("kind", "interface") if isinstance(m, dict) else "" + name = render_component_name(raw, pos) + entry = { + "marker": raw, "name": name, "kind": None, "id": None, + "connected": False, "cable_id": None, + # Enough for the shared port-state colouring (portState): + # interfaces carry enabled/speed/type; others default on. + "enabled": True, "speed": "", "type": "", + # Hardware markers (inventory items): lifecycle status. + "status": None, + # Module-bay markers: the installed module, or null for an + # empty slot. Occupancy is the whole point of drawing a bay + # on the photo, so it rides along rather than costing the + # client a request per bay. + "module": None, + # What SNMP saw differently, or null when they agree. The + # status/speed above stay the SOURCE OF TRUTH either way — + # drift is drawn beside intent, never over it. + "drift": None, + } + mapping = self._FACE_PORT_KINDS.get(kind) + if mapping: + relation, term_kind = mapping + comp = name_map(relation, cabled=term_kind is not None).get(name) + if comp is not None: + entry["drift"] = drift.get(str(comp.id)) + if comp is not None and kind == "module-bay": + # Module bay — reads occupied/empty, never cable-able. + # The reverse OneToOne raises (an AttributeError + # subclass) when the bay is free, so getattr → None. + mod = getattr(comp, "module", None) + entry.update({ + "id": str(comp.id), + "module": { + "id": str(mod.id), + "module_type": { + "id": str(mod.module_type_id), + "name": mod.module_type.name, + }, + "serial_number": mod.serial_number, + } if mod else None, + }) + elif comp is not None and term_kind is None: + # Inventory item — status-coloured, never cable-able. + s = comp.status + entry.update({ + "id": str(comp.id), + "status": {"id": str(s.id), "name": s.name, "color": s.color} + if s else None, + }) + elif comp is not None: + term = next(iter(comp.terminations.all()), None) + # Only interfaces carry a network-speed string; other + # kinds may have an int `speed` (power draw) — ignore it. + speed = getattr(comp, "speed", "") + ctype = getattr(comp, "type", "") + entry.update({ + "kind": term_kind, + "id": str(comp.id), + "connected": term is not None, + "cable_id": str(term.cable_id) if term else None, + "enabled": bool(getattr(comp, "enabled", True)), + "speed": speed if isinstance(speed, str) else "", + "type": ctype if isinstance(ctype, str) else "", + }) + out.append(entry) + return out + + return Response({ + "front": resolve(image_ports.get("front")), + "rear": resolve(image_ports.get("rear")), + }) + @action(detail=True, methods=["get"]) def render(self, request, pk=None): """Render an export template for this device → intended config text. @@ -2440,9 +2925,9 @@ def perform_create(self, serializer): tenant = self._tenant_or_403() _check_unique_name(Device, serializer, tenant, "device") serializer.save(tenant=tenant) - # Stamp the device's components out of its type's templates (NetBox - # semantics: a C9300-48P type materialises its 48 interfaces + console - # + PSU inlets on every new device of the type). + # Stamp the device's components out of its type's templates (a + # C9300-48P type materialises its 48 interfaces + console + PSU + # inlets on every new device of the type). materialize_device_components(serializer.instance) def perform_update(self, serializer): @@ -2538,7 +3023,7 @@ def interfaces(self, request, pk=None): "tags", "ip_addresses", "children", "lag_members", "tunnel_terminations__tunnel", ) - .order_by("name") + .order_by(NATURAL_NAME) ) qs = rbac.restrict_queryset( qs, request.user, device.tenant, "interface", "view" @@ -2572,8 +3057,7 @@ def paths(self, request, pk=None): class InterfaceViewSet(ComponentBulkMixin, TenantScopedViewSet): """Interfaces have no direct tenant FK — scope via device.tenant.""" - # Interface has no description column (yet) — don't offer one. - bulk_str_fields = ("type", "mode", "speed", "duplex") + bulk_str_fields = ("type", "mode", "speed", "duplex", "description") bulk_bool_fields = ("enabled", "mgmt_only") bulk_int_fields = ("mtu",) bulk_fk_fields = {"vlan_id": VLAN, "vrf_id": VRF} @@ -2589,7 +3073,7 @@ class InterfaceViewSet(ComponentBulkMixin, TenantScopedViewSet): "lag_members", "tagged_vlans", "mac_addresses", "tunnel_terminations__tunnel", ) - .order_by("device__name", "name") + .order_by("device__name", NATURAL_NAME) ) serializer_class = InterfaceSerializer pagination_class = StandardPagination @@ -2872,6 +3356,168 @@ def strand(self, request, pk=None): return Response({"detail": "strand out of range"}, status=400) return Response(cable_strand_path(cable, n)) + @action(detail=True, methods=["post"], url_path="auto-route") + def auto_route(self, request, pk=None): + """Compute the best tray route for this cable on a floor plan and + persist it: the plan's trays on the winning path replace the cable's + current trays *on that plan* (other plans' assignments are kept), and + the estimated length fills ``length`` when blank (``overwrite: true`` + replaces a recorded one). Body: ``{"floor_plan": id, "overwrite"?}``.""" + from .models import FloorPlan + from .pathfinding import ( + estimate_length_m, rack_drop_mm, route_through_trays, + underfloor_plenum_mm, + ) + + cable = self.get_object() + tenant = _get_active_tenant(self.request) + plan = FloorPlan.objects.filter( + id=(request.data or {}).get("floor_plan"), tenant=tenant + ).first() + if plan is None: + return Response({"detail": "Unknown floor plan."}, status=400) + + # A/B endpoint devices from the cable's own terminations. + ends: dict = {"A": None, "B": None} + for term in cable.terminations.select_related(): + dev_id = getattr(term.point, "device_id", None) + if dev_id and ends.get(term.end) is None: + ends[term.end] = dev_id + if not ends["A"] or not ends["B"]: + return Response( + {"detail": "Both cable ends must terminate on a device."}, + status=400, + ) + resolved, err = _resolve_route_endpoints(plan, { + "from": {"kind": "device", "id": str(ends["A"])}, + "to": {"kind": "device", "id": str(ends["B"])}, + }) + if err: + return Response({"detail": err}, status=400) + a, b, rack_a, rack_b = resolved + + trays = list(plan.trays.all()) + result = route_through_trays(a, b, [t.points for t in trays]) + if not result.reachable: + return Response({ + "reachable": False, + "detail": "No tray path connects the two ends on this plan.", + }) + used = [trays[i] for i in result.tray_indexes] + + # Plenum-aware drops: an underfloor run dives as deep as the raised + # floor beneath it, not a constant. + area_rects = [ + (a.x, a.y, a.width, a.height, a.plenum_mm) + for a in plan.raised_floor_areas.all() + ] + + def _drop(rack, tray): + if tray is None: + return 0.0 + plenum = underfloor_plenum_mm(area_rects, tray.points) + return rack_drop_mm( + rack.u_height if rack is not None else None, + tray.level, tray.elevation_mm, plan.ceiling_mm, plenum, + ) + + drop_a = _drop(rack_a, used[0]) if used else 0.0 + drop_b = _drop(rack_b, used[-1]) if used else 0.0 + length_m = estimate_length_m(result.run_cells, plan.cell_mm, drop_a, drop_b) + + overwrite = bool((request.data or {}).get("overwrite")) + with transaction.atomic(): + # Replace only THIS plan's tray assignments. + cable.trays.remove(*cable.trays.filter(floor_plan=plan)) + cable.trays.add(*used) + length_set = False + if cable.length is None or overwrite: + cable.length = length_m + cable.length_unit = "m" + cable.save(update_fields=["length", "length_unit"]) + length_set = True + return Response({ + "reachable": True, + "points": [[round(x, 3), round(y, 3)] for x, y in result.points], + "tray_ids": [str(t.id) for t in used], + "tray_names": [t.name for t in used], + "length_m": length_m, + "length_set": length_set, + }) + + @action(detail=True, methods=["get", "put"], url_path="routing") + def routing(self, request, pk=None): + """Read or set how this cable is routed on one floor plan. + + GET ``?floor_plan=`` → ``{mode, trays: [{id, name, level, + elevation_mm}], available: [...]}`` — ``mode`` is ``"trays"`` when the + cable is assigned any tray on that plan, else ``"point-to-point"``. + + PUT ``{floor_plan, tray_ids: [...]}`` replaces THIS plan's assignment + in the given order (other plans' assignments are untouched). An empty + list is point-to-point — a legitimate answer, not a no-op. + + The auto-route action picks the trays for you; this is the manual + twin, so an operator can see what a run follows and name the exact + ducts it should take instead. + """ + from auth_api import rbac + + from .models import FloorPlan + + cable = self.get_object() + tenant = _get_active_tenant(self.request) + plan_id = ( + request.query_params.get("floor_plan") + if request.method == "GET" + else (request.data or {}).get("floor_plan") + ) + plan = FloorPlan.objects.filter(id=plan_id, tenant=tenant).first() + if plan is None: + return Response({"detail": "Unknown floor plan."}, status=400) + + def shape(trays): + return [ + { + "id": str(t.id), + "name": t.name, + "level": t.level, + "elevation_mm": t.elevation_mm, + } + for t in trays + ] + + if request.method == "PUT": + if not rbac.can_act_on( + request.user, tenant, "cable", "change", cable + ): + raise PermissionDenied("You may not re-route this cable.") + ids = (request.data or {}).get("tray_ids") or [] + if not isinstance(ids, list): + return Response({"tray_ids": "Expected a list."}, status=400) + # Only trays on THIS plan (which is already tenant-scoped) may be + # named — an id from another plan or tenant is simply not found. + by_id = {str(t.id): t for t in plan.trays.all()} + unknown = [str(i) for i in ids if str(i) not in by_id] + if unknown: + return Response( + {"tray_ids": f"Not a tray on this plan: {unknown[0]}"}, + status=400, + ) + chosen = [by_id[str(i)] for i in ids] + with transaction.atomic(): + cable.trays.remove(*cable.trays.filter(floor_plan=plan)) + if chosen: + cable.trays.add(*chosen) + else: + chosen = [t for t in cable.trays.all() if t.floor_plan_id == plan.id] + + return Response({ + "mode": "trays" if chosen else "point-to-point", + "trays": shape(chosen), + "available": shape(plan.trays.all()), + }) + @action(detail=True, methods=["get"], url_path="floor-plan") def floor_plan(self, request, pk=None): """The floor plan where this cable can be traced — a plan whose trays @@ -2997,7 +3643,7 @@ class RearPortViewSet(_DevicePortViewSet): queryset = ( RearPort.objects.select_related("device") .prefetch_related("tags", "terminations__cable", "front_ports") - .order_by("device__name", "name") + .order_by("device__name", NATURAL_NAME) ) serializer_class = RearPortSerializer bulk_int_fields = ("positions",) @@ -3007,7 +3653,7 @@ class FrontPortViewSet(_DevicePortViewSet): queryset = ( FrontPort.objects.select_related("device", "rear_port") .prefetch_related("tags", "terminations__cable") - .order_by("device__name", "name") + .order_by("device__name", NATURAL_NAME) ) serializer_class = FrontPortSerializer @@ -3016,7 +3662,7 @@ class ConsolePortViewSet(_DevicePortViewSet): queryset = ( ConsolePort.objects.select_related("device") .prefetch_related("tags", "terminations__cable") - .order_by("device__name", "name") + .order_by("device__name", NATURAL_NAME) ) serializer_class = ConsolePortSerializer @@ -3025,7 +3671,7 @@ class AuxPortViewSet(_DevicePortViewSet): queryset = ( AuxPort.objects.select_related("device") .prefetch_related("tags") # not cable-terminable — no terminations - .order_by("device__name", "name") + .order_by("device__name", NATURAL_NAME) ) serializer_class = AuxPortSerializer @@ -3034,7 +3680,7 @@ class ConsoleServerPortViewSet(_DevicePortViewSet): queryset = ( ConsoleServerPort.objects.select_related("device") .prefetch_related("tags", "terminations__cable") - .order_by("device__name", "name") + .order_by("device__name", NATURAL_NAME) ) serializer_class = ConsoleServerPortSerializer bulk_int_fields = ("speed",) @@ -3044,7 +3690,7 @@ class PowerPortViewSet(_DevicePortViewSet): queryset = ( PowerPort.objects.select_related("device") .prefetch_related("tags", "terminations__cable", "outlets") - .order_by("device__name", "name") + .order_by("device__name", NATURAL_NAME) ) serializer_class = PowerPortSerializer @@ -3053,7 +3699,7 @@ class PowerOutletViewSet(_DevicePortViewSet): queryset = ( PowerOutlet.objects.select_related("device", "power_port") .prefetch_related("tags", "terminations__cable") - .order_by("device__name", "name") + .order_by("device__name", NATURAL_NAME) ) serializer_class = PowerOutletSerializer bulk_str_fields = ("type", "description", "feed_leg") @@ -3125,7 +3771,7 @@ def perform_update(self, serializer): class InterfaceTemplateViewSet(_ComponentTemplateViewSet): - queryset = InterfaceTemplate.objects.select_related("device_type").order_by("name") + queryset = InterfaceTemplate.objects.select_related("device_type").order_by(NATURAL_NAME) serializer_class = InterfaceTemplateSerializer bulk_str_fields = ("type", "description") bulk_bool_fields = ("enabled", "mgmt_only") @@ -3135,24 +3781,24 @@ class DeviceTypeServiceViewSet(_ComponentTemplateViewSet): """Service templates on a device type — materialise onto new devices as Services (see ``materialize_device_components``).""" - queryset = DeviceTypeService.objects.select_related("device_type").order_by("name") + queryset = DeviceTypeService.objects.select_related("device_type").order_by(NATURAL_NAME) serializer_class = DeviceTypeServiceSerializer class ConsolePortTemplateViewSet(_ComponentTemplateViewSet): - queryset = ConsolePortTemplate.objects.select_related("device_type").order_by("name") + queryset = ConsolePortTemplate.objects.select_related("device_type").order_by(NATURAL_NAME) serializer_class = ConsolePortTemplateSerializer class AuxPortTemplateViewSet(_ComponentTemplateViewSet): - queryset = AuxPortTemplate.objects.select_related("device_type").order_by("name") + queryset = AuxPortTemplate.objects.select_related("device_type").order_by(NATURAL_NAME) serializer_class = AuxPortTemplateSerializer class InventoryItemTemplateViewSet(_ComponentTemplateViewSet): queryset = ( InventoryItemTemplate.objects - .select_related("device_type", "manufacturer").order_by("name") + .select_related("device_type", "manufacturer").order_by(NATURAL_NAME) ) serializer_class = InventoryItemTemplateSerializer @@ -3162,15 +3808,22 @@ class InventoryItemViewSet(_DevicePortViewSet): InventoryItem.objects .select_related("device", "manufacturer", "parent") .prefetch_related("tags") - .order_by("device__name", "name") + .order_by("device__name", NATURAL_NAME) ) serializer_class = InventoryItemSerializer - # InventoryItem has no `type` column — its own allowlist. - bulk_str_fields = ("description", "part_id", "serial_number", "asset_tag") + # InventoryItem has no `type` column — its own allowlist. kind/media are + # choice-backed CharFields (validated against the model choices); status + # is the tenant's Status catalog; capacity is raw bytes. + bulk_str_fields = ( + "description", "part_id", "serial_number", "asset_tag", + "kind", "media", "speed", + ) + bulk_int_fields = ("capacity_bytes",) + bulk_fk_fields = {"status_id": Status} class DeviceBayTemplateViewSet(_ComponentTemplateViewSet): - queryset = DeviceBayTemplate.objects.select_related("device_type").order_by("name") + queryset = DeviceBayTemplate.objects.select_related("device_type").order_by(NATURAL_NAME) serializer_class = DeviceBayTemplateSerializer @@ -3178,7 +3831,7 @@ class DeviceBayViewSet(_DevicePortViewSet): queryset = ( DeviceBay.objects.select_related("device", "installed_device") .prefetch_related("tags") - .order_by("device__name", "name") + .order_by("device__name", NATURAL_NAME) ) serializer_class = DeviceBaySerializer @@ -3186,12 +3839,12 @@ class DeviceBayViewSet(_DevicePortViewSet): class ModuleBayTemplateViewSet(_ComponentTemplateViewSet): queryset = ModuleBayTemplate.objects.select_related( "device_type", "default_module_type" - ).order_by("name") + ).order_by(NATURAL_NAME) serializer_class = ModuleBayTemplateSerializer class TopologyViewViewSet(TenantScopedViewSet): - queryset = TopologyView.objects.all().order_by("name") + queryset = TopologyView.objects.all().order_by(NATURAL_NAME) serializer_class = TopologyViewSerializer pagination_class = StandardPagination @@ -3202,7 +3855,7 @@ def perform_create(self, serializer): class ModuleTypeViewSet(TenantScopedViewSet): queryset = ( ModuleType.objects.select_related("manufacturer") - .prefetch_related("tags").order_by("name") + .prefetch_related("tags").order_by(NATURAL_NAME) ) serializer_class = ModuleTypeSerializer pagination_class = StandardPagination @@ -3233,7 +3886,7 @@ class ModuleInterfaceTemplateViewSet(TenantScopedViewSet): """Interface templates on a MODULE type — scope via module_type.tenant; filter with ?module_type=.""" - queryset = ModuleInterfaceTemplate.objects.select_related("module_type").order_by("name") + queryset = ModuleInterfaceTemplate.objects.select_related("module_type").order_by(NATURAL_NAME) serializer_class = ModuleInterfaceTemplateSerializer pagination_class = StandardPagination tenant_field = None @@ -3272,7 +3925,7 @@ class ModuleBayViewSet(_DevicePortViewSet): queryset = ( ModuleBay.objects.select_related("device") .prefetch_related("tags", "module__module_type") - .order_by("device__name", "name") + .order_by("device__name", NATURAL_NAME) ) serializer_class = ModuleBaySerializer @@ -3326,32 +3979,32 @@ def perform_destroy(self, instance): class ConsoleServerPortTemplateViewSet(_ComponentTemplateViewSet): - queryset = ConsoleServerPortTemplate.objects.select_related("device_type").order_by("name") + queryset = ConsoleServerPortTemplate.objects.select_related("device_type").order_by(NATURAL_NAME) serializer_class = ConsoleServerPortTemplateSerializer class PowerPortTemplateViewSet(_ComponentTemplateViewSet): - queryset = PowerPortTemplate.objects.select_related("device_type").order_by("name") + queryset = PowerPortTemplate.objects.select_related("device_type").order_by(NATURAL_NAME) serializer_class = PowerPortTemplateSerializer class PowerOutletTemplateViewSet(_ComponentTemplateViewSet): queryset = ( PowerOutletTemplate.objects - .select_related("device_type", "power_port_template").order_by("name") + .select_related("device_type", "power_port_template").order_by(NATURAL_NAME) ) serializer_class = PowerOutletTemplateSerializer class RearPortTemplateViewSet(_ComponentTemplateViewSet): - queryset = RearPortTemplate.objects.select_related("device_type").order_by("name") + queryset = RearPortTemplate.objects.select_related("device_type").order_by(NATURAL_NAME) serializer_class = RearPortTemplateSerializer class FrontPortTemplateViewSet(_ComponentTemplateViewSet): queryset = ( FrontPortTemplate.objects - .select_related("device_type", "rear_port_template").order_by("name") + .select_related("device_type", "rear_port_template").order_by(NATURAL_NAME) ) serializer_class = FrontPortTemplateSerializer @@ -3374,7 +4027,7 @@ def get_queryset(self): ) return qs.annotate( cluster_count_annotated=Count(self.count_rel) - ).order_by("name") + ).order_by(NATURAL_NAME) def _slug(self, serializer, tenant): data = serializer.validated_data @@ -3411,7 +4064,7 @@ def destroy(self, request, *args, **kwargs): class ClusterTypeViewSet(_SlugCatalogViewSet): - queryset = ClusterType.objects.all().order_by("name") + queryset = ClusterType.objects.all().order_by(NATURAL_NAME) serializer_class = ClusterTypeSerializer model = ClusterType count_rel = "clusters" @@ -3424,7 +4077,7 @@ def get_serializer_class(self): class ClusterGroupViewSet(_SlugCatalogViewSet): - queryset = ClusterGroup.objects.all().order_by("name") + queryset = ClusterGroup.objects.all().order_by(NATURAL_NAME) serializer_class = ClusterGroupSerializer model = ClusterGroup count_rel = "clusters" @@ -3437,7 +4090,7 @@ def get_serializer_class(self): class ClusterViewSet(TenantScopedViewSet): - queryset = Cluster.objects.all().order_by("name") + queryset = Cluster.objects.all().order_by(NATURAL_NAME) serializer_class = ClusterSerializer pagination_class = StandardPagination @@ -3484,7 +4137,7 @@ def destroy(self, request, *args, **kwargs): class VirtualMachineViewSet(CloneableMixin, TenantScopedViewSet): - queryset = VirtualMachine.objects.all().order_by("name") + queryset = VirtualMachine.objects.all().order_by(NATURAL_NAME) serializer_class = VirtualMachineSerializer pagination_class = StandardPagination # Name + primary IP are identity; carry placement + sizing. @@ -3529,7 +4182,7 @@ def get_queryset(self): class VMInterfaceViewSet(ComponentBulkMixin, TenantScopedViewSet): - queryset = VMInterface.objects.all().order_by("name") + queryset = VMInterface.objects.all().order_by(NATURAL_NAME) serializer_class = VMInterfaceSerializer pagination_class = StandardPagination # Tenant is reached through the VM (VMInterface has no direct tenant FK). @@ -3560,7 +4213,7 @@ def get_queryset(self): # ─── Racks ─────────────────────────────────────────────────────────────────── class RackRoleViewSet(_SlugCatalogViewSet): - queryset = RackRole.objects.all().order_by("name") + queryset = RackRole.objects.all().order_by(NATURAL_NAME) serializer_class = RackRoleSerializer model = RackRole count_rel = "racks" @@ -3572,7 +4225,7 @@ def get_queryset(self): if s: qs = qs.filter(name__icontains=s) | qs.filter(description__icontains=s) from django.db.models import Count as _C - return qs.annotate(rack_count_annotated=_C("racks")).order_by("name") + return qs.annotate(rack_count_annotated=_C("racks")).order_by(NATURAL_NAME) def get_serializer_class(self): if self.action == "list" and self.request and \ @@ -3591,6 +4244,84 @@ def destroy(self, request, *args, **kwargs): return TenantScopedViewSet.destroy(self, request, *args, **kwargs) +class RackTypeViewSet(TenantScopedViewSet): + """Rack model catalog — manufacturer/model profiles whose dims pre-fill + the rack form and whose accessories can stamp 0U strips onto new racks.""" + + queryset = RackType.objects.select_related("manufacturer").prefetch_related( + "tags", "accessories__device_type__manufacturer" + ).order_by(NATURAL_NAME) + serializer_class = RackTypeSerializer + pagination_class = StandardPagination + + def get_queryset(self): + qs = TenantScopedViewSet.get_queryset(self) + if self.request: + s = self.request.query_params.get("search", "").strip() + if s: + qs = qs.filter(name__icontains=s) \ + | qs.filter(manufacturer__name__icontains=s) + m = self.request.query_params.get("manufacturer") + if m: + qs = qs.filter(manufacturer_id=m) + from django.db.models import Count as _C + return qs.annotate(rack_count_annotated=_C("racks", distinct=True)) \ + .order_by(NATURAL_NAME) + + def get_serializer_class(self): + if self.action == "list" and self.request and \ + self.request.query_params.get("picker") == "1": + return RackTypeMiniSerializer + return RackTypeSerializer + + def perform_create(self, serializer): + tenant = self._tenant_or_403() + _check_unique_name(RackType, serializer, tenant, "rack type") + serializer.save(tenant=tenant) + + def perform_update(self, serializer): + _check_unique_name(RackType, serializer, self._tenant_or_403(), "rack type") + serializer.save() + + def destroy(self, request, *args, **kwargs): + obj = self.get_object() + n = obj.racks.count() + if n: + return Response( + {"detail": f"{n} rack{'s' if n != 1 else ''} use this type."}, + status=drf_status.HTTP_409_CONFLICT, + ) + return TenantScopedViewSet.destroy(self, request, *args, **kwargs) + + +class RackTypeAccessoryViewSet(TenantScopedViewSet): + """Accessory strips on a rack type — scoped through the type's tenant, + like floor-plan trays through their plan.""" + + queryset = RackTypeAccessory.objects.select_related( + "rack_type", "device_type__manufacturer" + ).order_by("order", "label") + serializer_class = RackTypeAccessorySerializer + pagination_class = StandardPagination + tenant_field = None + + def get_queryset(self): + tenant = _get_active_tenant(self.request) + if tenant is None: + return self.queryset.none() + qs = self.queryset.filter(rack_type__tenant=tenant) + if self.request: + rt = self.request.query_params.get("rack_type") + if rt: + qs = qs.filter(rack_type_id=rt) + return restrict_for_view(self, qs) + + def perform_create(self, serializer): + if serializer.validated_data.get("rack_type") is None: + raise ValidationError({"rack_type_id": "This field is required."}) + serializer.save() + + class RackViewSet(ImageAttachmentMixin, TenantScopedViewSet): queryset = Rack.objects.all().order_by("site__name", "name") serializer_class = RackSerializer @@ -3606,7 +4337,7 @@ def get_serializer_class(self): def get_queryset(self): qs = ( super().get_queryset() - .select_related("site", "role", "location") + .select_related("site", "role", "location", "rack_type__manufacturer") .prefetch_related( "tags", "devices__device_type", "devices__power_ports", "power_feeds", @@ -3625,8 +4356,59 @@ def get_queryset(self): role = self.request.query_params.get("role") if role: qs = qs.filter(role_id=role) + rack_type = self.request.query_params.get("rack_type") + if rack_type: + qs = qs.filter(rack_type_id=rack_type) return qs + @action(detail=True, methods=["post"], url_path="sync-from-type") + def sync_from_type(self, request, pk=None): + """Re-align this rack with its rack type — the rack twin of the + device action. + + ``apply=false`` (default) → dry-run: the dimension drift and the + accessories this rack is missing, so the UI can preview. + ``apply=true`` → copy the dims and stamp the missing strips. + ``dims`` / ``accessories`` (both default true) narrow what applies. + + Never deletes: an "extra" strip in the diff is somebody's real, + cabled PDU. Adding a strip creates a device, so that half needs + device-add scope at the rack's site. + """ + from auth_api import rbac + + from .models import diff_rack_from_type, sync_rack_from_type + from .views import _get_active_tenant + + rack = self.get_object() + tenant = _get_active_tenant(request) + if not rbac.can_act_on(request.user, tenant, "rack", "change", rack): + raise PermissionDenied("rack.change required.") + if rack.rack_type_id is None: + return Response( + {"detail": "This rack has no rack type to sync from."}, + status=drf_status.HTTP_400_BAD_REQUEST, + ) + + diff = diff_rack_from_type(rack) + if not bool(request.data.get("apply")): + return Response({"applied": False, "diff": diff}) + + dims = bool(request.data.get("dims", True)) + accessories = bool(request.data.get("accessories", True)) + if accessories and diff.get("accessories", {}).get("add"): + # Stamping writes devices — same gate as the create-time stamp. + scope = rbac.site_scope(request.user, tenant, "device", "add") + if scope is not None and not ( + rack.site_id is not None and rack.site_id in scope + ): + raise PermissionDenied( + "Adding accessories requires permission to add devices " + "at this rack's site." + ) + result = sync_rack_from_type(rack, dims=dims, accessories=accessories) + return Response({"applied": True, "diff": diff, "result": result}) + def destroy(self, request, *args, **kwargs): obj = self.get_object() n = obj.devices.count() @@ -3641,7 +4423,7 @@ def destroy(self, request, *args, **kwargs): # ─── Device roles + platforms ──────────────────────────────────────────────── class DeviceRoleViewSet(TenantScopedViewSet): - queryset = DeviceRole.objects.all().order_by("name") + queryset = DeviceRole.objects.all().order_by(NATURAL_NAME) serializer_class = DeviceRoleSerializer pagination_class = StandardPagination @@ -3694,7 +4476,7 @@ def destroy(self, request, *args, **kwargs): class PlatformGroupViewSet(TenantScopedViewSet): """Groupings of platforms (Windows, Linux, network NOS, …) — self-nesting.""" - queryset = PlatformGroup.objects.all().order_by("name") + queryset = PlatformGroup.objects.all().order_by(NATURAL_NAME) serializer_class = PlatformGroupSerializer pagination_class = StandardPagination @@ -3712,7 +4494,7 @@ def get_queryset(self): qs = qs.filter(name__icontains=s) | qs.filter(description__icontains=s) return qs.annotate( platform_count_annotated=Count("platforms") - ).order_by("name") + ).order_by(NATURAL_NAME) def _slug(self, serializer, tenant): data = serializer.validated_data @@ -3747,7 +4529,7 @@ def destroy(self, request, *args, **kwargs): class PlatformViewSet(DeviceRoleViewSet): - queryset = Platform.objects.all().order_by("name") + queryset = Platform.objects.all().order_by(NATURAL_NAME) serializer_class = PlatformSerializer def get_serializer_class(self): @@ -3771,7 +4553,7 @@ def get_queryset(self): lc = self.request.query_params.get("lifecycle") if lc: qs = _apply_lifecycle_filter(qs, lc) - return qs.order_by("name") + return qs.order_by(NATURAL_NAME) def _slug(self, serializer, tenant): data = serializer.validated_data @@ -3797,7 +4579,7 @@ def destroy(self, request, *args, **kwargs): # ─── Services ──────────────────────────────────────────────────────────────── class ServiceViewSet(TenantScopedViewSet): - queryset = Service.objects.all().order_by("name") + queryset = Service.objects.all().order_by(NATURAL_NAME) serializer_class = ServiceSerializer pagination_class = StandardPagination @@ -3855,7 +4637,7 @@ def monitor(self, request, pk=None): # ─── Service templates (reusable service definitions) ──────────────────────── class ServiceTemplateViewSet(TenantScopedViewSet): - queryset = ServiceTemplate.objects.all().order_by("name") + queryset = ServiceTemplate.objects.all().order_by(NATURAL_NAME) serializer_class = ServiceTemplateSerializer pagination_class = StandardPagination @@ -3978,7 +4760,7 @@ def available(self, request, pk=None): # ─── RIRs + Aggregates ─────────────────────────────────────────────────────── class RIRViewSet(TenantScopedViewSet): - queryset = RIR.objects.all().order_by("name") + queryset = RIR.objects.all().order_by(NATURAL_NAME) serializer_class = RIRSerializer pagination_class = StandardPagination @@ -3996,7 +4778,7 @@ def get_queryset(self): s = self.request.query_params.get("search", "").strip() if s: qs = qs.filter(name__icontains=s) | qs.filter(description__icontains=s) - return qs.order_by("name") + return qs.order_by(NATURAL_NAME) def _slug(self, serializer, tenant): data = serializer.validated_data @@ -4088,7 +4870,7 @@ def get_queryset(self): # ─── VLAN groups ───────────────────────────────────────────────────────────── class VLANGroupViewSet(TenantScopedViewSet): - queryset = VLANGroup.objects.all().order_by("name") + queryset = VLANGroup.objects.all().order_by(NATURAL_NAME) serializer_class = VLANGroupSerializer pagination_class = StandardPagination @@ -4112,7 +4894,7 @@ def get_queryset(self): site = self.request.query_params.get("site") if site: qs = qs.filter(site_id=site) - return qs.order_by("name") + return qs.order_by(NATURAL_NAME) def _slug(self, serializer, tenant): data = serializer.validated_data @@ -4221,7 +5003,7 @@ def get_queryset(self): s = self.request.query_params.get("search", "").strip() if s: qs = qs.filter(name__icontains=s) | qs.filter(description__icontains=s) - return qs.order_by("name") + return qs.order_by(NATURAL_NAME) def _slug(self, serializer, tenant): data = serializer.validated_data @@ -4245,7 +5027,7 @@ def perform_update(self, serializer): class ContactGroupViewSet(_ContactCatalogViewSet): - queryset = ContactGroup.objects.all().order_by("name") + queryset = ContactGroup.objects.all().order_by(NATURAL_NAME) serializer_class = ContactGroupSerializer picker_serializer = ContactGroupMiniSerializer model = ContactGroup @@ -4268,7 +5050,7 @@ def destroy(self, request, *args, **kwargs): class ContactRoleViewSet(_ContactCatalogViewSet): - queryset = ContactRole.objects.all().order_by("name") + queryset = ContactRole.objects.all().order_by(NATURAL_NAME) serializer_class = ContactRoleSerializer picker_serializer = ContactRoleMiniSerializer model = ContactRole @@ -4286,7 +5068,7 @@ def destroy(self, request, *args, **kwargs): class ContactViewSet(TenantScopedViewSet): - queryset = Contact.objects.all().order_by("name") + queryset = Contact.objects.all().order_by(NATURAL_NAME) serializer_class = ContactSerializer pagination_class = StandardPagination @@ -4380,7 +5162,7 @@ def perform_update(self, serializer): # ─── Circuits ──────────────────────────────────────────────────────────────── class ProviderViewSet(TenantScopedViewSet): - queryset = Provider.objects.all().order_by("name") + queryset = Provider.objects.all().order_by(NATURAL_NAME) serializer_class = ProviderSerializer pagination_class = StandardPagination @@ -4402,7 +5184,7 @@ def get_queryset(self): | qs.filter(account__icontains=s) | qs.filter(noc_email__icontains=s) ) - return qs.order_by("name") + return qs.order_by(NATURAL_NAME) def destroy(self, request, *args, **kwargs): obj = self.get_object() @@ -4417,7 +5199,7 @@ def destroy(self, request, *args, **kwargs): class CircuitTypeViewSet(TenantScopedViewSet): - queryset = CircuitType.objects.all().order_by("name") + queryset = CircuitType.objects.all().order_by(NATURAL_NAME) serializer_class = CircuitTypeSerializer pagination_class = StandardPagination @@ -4435,7 +5217,7 @@ def get_queryset(self): s = self.request.query_params.get("search", "").strip() if s: qs = qs.filter(name__icontains=s) | qs.filter(description__icontains=s) - return qs.order_by("name") + return qs.order_by(NATURAL_NAME) def destroy(self, request, *args, **kwargs): obj = self.get_object() @@ -4481,7 +5263,7 @@ def get_queryset(self): class ProviderNetworkViewSet(TenantScopedViewSet): queryset = ( ProviderNetwork.objects.select_related("provider") - .prefetch_related("tags").order_by("name") + .prefetch_related("tags").order_by(NATURAL_NAME) ) serializer_class = ProviderNetworkSerializer pagination_class = StandardPagination @@ -4553,7 +5335,7 @@ def perform_update(self, serializer): # ─── Power ─────────────────────────────────────────────────────────────────── class PowerPanelViewSet(TenantScopedViewSet): - queryset = PowerPanel.objects.all().order_by("name") + queryset = PowerPanel.objects.all().order_by(NATURAL_NAME) serializer_class = PowerPanelSerializer pagination_class = StandardPagination @@ -4574,7 +5356,7 @@ def get_queryset(self): site = self.request.query_params.get("site") if site: qs = qs.filter(site_id=site) - return qs.order_by("name") + return qs.order_by(NATURAL_NAME) def destroy(self, request, *args, **kwargs): obj = self.get_object() @@ -4589,7 +5371,7 @@ def destroy(self, request, *args, **kwargs): class PowerFeedViewSet(TenantScopedViewSet): - queryset = PowerFeed.objects.all().order_by("name") + queryset = PowerFeed.objects.all().order_by(NATURAL_NAME) serializer_class = PowerFeedSerializer pagination_class = StandardPagination @@ -4617,7 +5399,7 @@ def get_queryset(self): # ─── Wireless ──────────────────────────────────────────────────────────────── class WirelessLANGroupViewSet(TenantScopedViewSet): - queryset = WirelessLANGroup.objects.all().order_by("name") + queryset = WirelessLANGroup.objects.all().order_by(NATURAL_NAME) serializer_class = WirelessLANGroupSerializer pagination_class = StandardPagination @@ -4635,7 +5417,7 @@ def get_queryset(self): s = self.request.query_params.get("search", "").strip() if s: qs = qs.filter(name__icontains=s) | qs.filter(description__icontains=s) - return qs.order_by("name") + return qs.order_by(NATURAL_NAME) def destroy(self, request, *args, **kwargs): obj = self.get_object() @@ -4678,7 +5460,7 @@ def get_queryset(self): # ─── VPN ───────────────────────────────────────────────────────────────────── class TunnelGroupViewSet(TenantScopedViewSet): - queryset = TunnelGroup.objects.all().order_by("name") + queryset = TunnelGroup.objects.all().order_by(NATURAL_NAME) serializer_class = TunnelGroupSerializer pagination_class = StandardPagination @@ -4696,7 +5478,7 @@ def get_queryset(self): s = self.request.query_params.get("search", "").strip() if s: qs = qs.filter(name__icontains=s) | qs.filter(description__icontains=s) - return qs.order_by("name") + return qs.order_by(NATURAL_NAME) def destroy(self, request, *args, **kwargs): obj = self.get_object() @@ -4711,7 +5493,7 @@ def destroy(self, request, *args, **kwargs): class IPSecProfileViewSet(TenantScopedViewSet): - queryset = IPSecProfile.objects.all().order_by("name") + queryset = IPSecProfile.objects.all().order_by(NATURAL_NAME) serializer_class = IPSecProfileSerializer pagination_class = StandardPagination @@ -4729,7 +5511,7 @@ def get_queryset(self): s = self.request.query_params.get("search", "").strip() if s: qs = qs.filter(name__icontains=s) | qs.filter(description__icontains=s) - return qs.order_by("name") + return qs.order_by(NATURAL_NAME) def destroy(self, request, *args, **kwargs): obj = self.get_object() @@ -4744,7 +5526,7 @@ def destroy(self, request, *args, **kwargs): class TunnelViewSet(TenantScopedViewSet): - queryset = Tunnel.objects.all().order_by("name") + queryset = Tunnel.objects.all().order_by(NATURAL_NAME) serializer_class = TunnelSerializer pagination_class = StandardPagination @@ -4836,7 +5618,7 @@ class L2VPNViewSet(TenantScopedViewSet): "terminations__vlan", "terminations__interface__device", "terminations__vm_interface__vm", ) - .order_by("name") + .order_by(NATURAL_NAME) ) serializer_class = L2VPNSerializer pagination_class = StandardPagination @@ -4909,7 +5691,7 @@ class VirtualChassisViewSet(TenantScopedViewSet): VirtualChassis.objects .select_related("master", "master__primary_ip", "master__oob_ip") .prefetch_related("members__status", "tags") - .order_by("name") + .order_by(NATURAL_NAME) ) serializer_class = VirtualChassisSerializer pagination_class = StandardPagination @@ -4933,7 +5715,7 @@ def perform_destroy(self, instance): # ─── Regions & Locations ───────────────────────────────────────────────────── class RegionViewSet(TenantScopedViewSet): - queryset = Region.objects.all().order_by("name") + queryset = Region.objects.all().order_by(NATURAL_NAME) serializer_class = RegionSerializer pagination_class = StandardPagination @@ -4952,7 +5734,7 @@ def get_queryset(self): parent = self.request.query_params.get("parent") if parent: qs = qs.filter(parent_id=parent) - return qs.order_by("name") + return qs.order_by(NATURAL_NAME) def destroy(self, request, *args, **kwargs): obj = self.get_object() @@ -5019,7 +5801,7 @@ def get_queryset(self): # ─── Export templates ──────────────────────────────────────────────────────── class ExportTemplateViewSet(TenantScopedViewSet): - queryset = ExportTemplate.objects.all().order_by("name") + queryset = ExportTemplate.objects.all().order_by(NATURAL_NAME) serializer_class = ExportTemplateSerializer pagination_class = StandardPagination @@ -5076,7 +5858,7 @@ def render(self, request, pk=None): class FloorTileTypeViewSet(TenantScopedViewSet): """The user-created floor-tile palette. Ships empty — zero built-ins.""" - queryset = FloorTileType.objects.all().order_by("name") + queryset = FloorTileType.objects.all().order_by(NATURAL_NAME) serializer_class = FloorTileTypeSerializer pagination_class = StandardPagination @@ -5125,11 +5907,54 @@ def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) +def _resolve_route_endpoints(plan, body): + """Resolve a route request's two endpoints to tile-centre coordinates. + + Each endpoint is ``{"kind": "device"|"rack", "id": …}``; a device resolves + to its own tile, else its rack's tile — the same fallback ``cable_paths`` + uses. Returns ``((a, b, rack_a, rack_b), None)`` on success — the racks (or + None) feed the vertical-drop estimate — or ``(None, error_message)``.""" + from .models import Device + + tiles = list(plan.tiles.select_related("rack")) + + def centre(t): + return (t.x + t.width / 2, t.y + t.height / 2) + + def resolve(spec): + if not isinstance(spec, dict) or spec.get("kind") not in ("device", "rack"): + return None, None, "Each endpoint needs kind device|rack and id." + oid = str(spec.get("id") or "") + if spec["kind"] == "rack": + t = next((t for t in tiles if str(t.rack_id) == oid), None) + return (centre(t), t.rack, None) if t else ( + None, None, "That rack isn't placed on this plan.") + t = next((t for t in tiles if str(t.device_id) == oid), None) + if t: + return centre(t), None, None + dev = Device.objects.filter( + id=oid, tenant=plan.tenant + ).only("rack_id").first() + if dev is None: + return None, None, "Unknown device." + t = next((t for t in tiles if t.rack_id == dev.rack_id), None) + return (centre(t), t.rack, None) if t else ( + None, None, "That device (or its rack) isn't placed on this plan.") + + a, rack_a, err_a = resolve(body.get("from")) + if err_a: + return None, err_a + b, rack_b, err_b = resolve(body.get("to")) + if err_b: + return None, err_b + return (a, b, rack_a, rack_b), None + + class FloorPlanViewSet(TenantScopedViewSet): queryset = ( FloorPlan.objects.select_related("location", "location__site") .prefetch_related("tags") - .order_by("name") + .order_by(NATURAL_NAME) ) serializer_class = FloorPlanSerializer pagination_class = StandardPagination @@ -5229,6 +6054,227 @@ def device_check(device_id): } return Response({"as_of": timezone.now().isoformat(), "tiles": out}) + # `route` is a POST (it carries a body) but computes only — view, not change. + rbac_action_map = {"route": "view"} + + @action(detail=True, methods=["post"], url_path="route") + def route(self, request, pk=None): + """Preview the best tray route between two placed endpoints. + + Body: ``{"from": {"kind": "device"|"rack", "id": …}, "to": {…}}``. + Pure computation — nothing is persisted; the cable ``auto-route`` + action is the writing twin. Returns the polyline (cell units), the + trays it rides, and the estimated physical length (run + vertical + drops + slack).""" + from .pathfinding import ( + estimate_length_m, rack_drop_mm, route_through_trays, + underfloor_plenum_mm, + ) + + plan = self.get_object() + ends, err = _resolve_route_endpoints(plan, request.data or {}) + if err: + return Response({"detail": err}, status=400) + a, b, rack_a, rack_b = ends + + trays = list(plan.trays.all()) + result = route_through_trays(a, b, [t.points for t in trays]) + used = [trays[i] for i in result.tray_indexes] + + # Plenum-aware drops: an underfloor run dives as deep as the raised + # floor beneath it, not a constant. + area_rects = [ + (a.x, a.y, a.width, a.height, a.plenum_mm) + for a in plan.raised_floor_areas.all() + ] + + def _drop(rack, tray): + if tray is None: + return 0.0 + plenum = underfloor_plenum_mm(area_rects, tray.points) + return rack_drop_mm( + rack.u_height if rack is not None else None, + tray.level, tray.elevation_mm, plan.ceiling_mm, plenum, + ) + + drop_a = _drop(rack_a, used[0] if used else None) + drop_b = _drop(rack_b, used[-1] if used else None) + length_m = estimate_length_m( + result.run_cells, plan.cell_mm, drop_a, drop_b + ) + return Response({ + "reachable": result.reachable, + "points": [[round(x, 3), round(y, 3)] for x, y in result.points], + "tray_ids": [str(t.id) for t in used], + "tray_names": [t.name for t in used], + "length_m": length_m, + "run_m": round(result.run_cells * plan.cell_mm / 1000, 1), + "drops_mm": [round(drop_a), round(drop_b)], + }) + + @action(detail=True, methods=["get"], url_path="scene") + def scene(self, request, pk=None): + """Everything the 3D room view needs, in one fetch: the plan's physical + dimensions, every tile (racks carrying their racked devices' geometry + + face images), and the trays at their elevations. Static structure only — + live status keeps coming from the sibling ``state`` action, so the 3D + view polls exactly what the 2D canvas polls.""" + from django.utils import timezone + + plan = self.get_object() + tiles_qs = plan.tiles.select_related( + "tile_type", "role_type", "rack", "device__role", + "device__device_type", + ) + rack_ids = {t.rack_id for t in tiles_qs if t.rack_id} + racks = { + r.id: r + for r in Rack.objects.filter(id__in=rack_ids).prefetch_related( + "devices__device_type", "devices__role", + "devices__status", "devices__primary_ip", + ) + } + + def img(f): + return request.build_absolute_uri(f.url) if f else None + + def device_geo(d): + dt = d.device_type + return { + "id": str(d.id), + "name": d.name, + "position": d.position, + "face": d.face or "", + "rack_side": d.rack_side or "", + # Zero-U side mounting — position is None for these; the 3D + # room draws them as vertical strips on the named rail. + "mount": d.mount or "", + "mount_offset_mm": d.mount_offset_mm, + "mount_span_u": d.mount_span_u, + "u_height": dt.u_height if dt else 1, + "rack_width": (dt.rack_width if dt else "full") or "full", + "is_full_depth": dt.is_full_depth if dt else True, + # Effective airflow (device override, else type default) so the + # 3D room can draw intake/exhaust glyphs. "" = unknown/passive. + "airflow": d.effective_airflow, + "role_color": d.role.color if d.role_id else "", + "role_name": d.role.name if d.role_id else "", + "device_type": dt.name if dt else "", + "status": {"name": d.status.name, "color": d.status.color} + if d.status_id else None, + "primary_ip": d.primary_ip.ip_address + if d.primary_ip_id else None, + "serial_number": d.serial_number or "", + "front_image": img(dt.front_image if dt else None), + "rear_image": img(dt.rear_image if dt else None), + "has_faceplate": bool(dt and dt.faceplate), + # Photo-anchored port markers (per device type; denormalized + # here like front_image so the 3D face can overlay them). + "image_ports": (dt.image_ports if dt else None) or None, + } + + def rack_geo(r): + return { + "id": str(r.id), + "name": r.name, + "u_height": r.u_height, + "starting_unit": r.starting_unit, + "desc_units": r.desc_units, + "width": r.width, + "outer_width_mm": r.outer_width_mm, + "outer_depth_mm": r.outer_depth_mm, + "devices": [ + device_geo(d) + for d in r.devices.all() + # Positioned gear AND side-mounted 0U strips — a mounted + # PDU has no U position but very much exists in the room. + if d.position is not None or d.mount + ], + } + + tiles = [ + { + "id": str(t.id), + "x": t.x, "y": t.y, + "w": t.width, "h": t.height, + "orientation": t.orientation, + "status": t.status, + "label": t.label or "", + "kind": "rack" if t.rack_id else + "device" if t.device_id else "other", + # The type's name, so the 3D room can label unlinked tiles + # ("build in advance": planned massing before objects exist). + "type_name": ( + t.tile_type.name if t.tile_type_id + else t.role_type.name if t.role_type_id else "" + ), + "color": ( + (t.tile_type.color if t.tile_type_id else "") + or (t.role_type.color if t.role_type_id else "") + or t.color or "" + ), + "is_zone": bool(t.tile_type_id and t.tile_type.is_zone), + # Perforated zone types render as grate floor in 3D — the + # cold-aisle supply-tile read. + "perforated": bool( + t.tile_type_id and t.tile_type.perforated + ), + "rack": rack_geo(racks[t.rack_id]) + if t.rack_id and t.rack_id in racks else None, + } + for t in tiles_qs + ] + trays = [ + { + "id": str(tr.id), + "name": tr.name, + "kind": tr.kind, + "color": tr.color, + "level": tr.level, + "elevation_mm": tr.elevation_mm, + "points": tr.points, + "cable_count": tr.cables.count(), + } + for tr in plan.trays.prefetch_related("cables") + ] + raised_floors = [ + { + "id": str(a.id), + "x": a.x, "y": a.y, "w": a.width, "h": a.height, + "plenum_mm": a.plenum_mm, + "label": a.label, "color": a.color, + } + for a in plan.raised_floor_areas.all() + ] + walls = [ + { + "id": str(w.id), + "label": w.label, + "points": w.points, + "height_mm": w.height_mm, + "color": w.color, + "openings": w.openings, + } + for w in plan.walls.all() + ] + return Response({ + "plan": { + "id": str(plan.id), + "name": plan.name, + "grid_width": plan.grid_width, + "grid_height": plan.grid_height, + "cell_mm": plan.cell_mm, + "ceiling_mm": plan.ceiling_mm, + "background_image": img(plan.background_image), + "background_opacity": plan.background_opacity, + }, + "tiles": tiles, + "trays": trays, + "raised_floors": raised_floors, + "walls": walls, + "as_of": timezone.now().isoformat(), + }) + @action(detail=True, methods=["get"], url_path="cable-paths") def cable_paths(self, request, pk=None): """Resolve each cable on this plan to its two endpoint tiles — a @@ -5278,7 +6324,9 @@ def cable_paths(self, request, pk=None): dev_id = getattr(point, "device_id", None) if dev_id is not None: wanted_devices.add(dev_id) - terms.append((term.end, dev_id)) + # Port name rides along so the 3D room can anchor the run to + # the exact photo-port quad on the device face. + terms.append((term.end, dev_id, getattr(point, "name", ""))) term_cache[cable.id] = terms device_rack = dict( Device.objects.filter(id__in=wanted_devices).values_list( @@ -5297,11 +6345,15 @@ def tile_for(dev_id): result = [] for cable in cables: a_tiles, b_tiles = [], [] - for end, dev_id in term_cache[cable.id]: + a_points, b_points = [], [] + for end, dev_id, port in term_cache[cable.id]: tile_id = tile_for(dev_id) if tile_id is None: continue (a_tiles if end == "A" else b_tiles).append(tile_id) + (a_points if end == "A" else b_points).append( + {"device": str(dev_id), "port": port} + ) result.append( { "id": str(cable.id), @@ -5310,6 +6362,8 @@ def tile_for(dev_id): "type": cable.type, "a_tiles": list(dict.fromkeys(a_tiles)), "b_tiles": list(dict.fromkeys(b_tiles)), + "a_points": a_points, + "b_points": b_points, "tray_ids": [ str(tr.id) for tr in cable.trays.all() @@ -5427,29 +6481,67 @@ def perform_create(self, serializer): serializer.save() -class CableRouteViewSet(TenantScopedViewSet): - """Geographic duct/aerial/trench runs on the site map.""" +class FloorPlanTrayViewSet(TenantScopedViewSet): + """Tray/conduit runs — scoped through their plan's tenant, like tiles.""" - queryset = CableRoute.objects.prefetch_related("cables").order_by("name") - serializer_class = CableRouteSerializer + queryset = FloorPlanTray.objects.select_related("floor_plan").prefetch_related( + "cables" + ).order_by(NATURAL_NAME) + serializer_class = FloorPlanTraySerializer pagination_class = StandardPagination + tenant_field = None def get_queryset(self): - qs = super().get_queryset() + tenant = _get_active_tenant(self.request) + if tenant is None: + return self.queryset.none() + qs = self.queryset.filter(floor_plan__tenant=tenant) if self.request: - cable = self.request.query_params.get("cable") - if cable: - qs = qs.filter(cables__id=cable) - return qs + fp = self.request.query_params.get("floor_plan") + if fp: + qs = qs.filter(floor_plan_id=fp) + return restrict_for_view(self, qs) + def perform_create(self, serializer): + if serializer.validated_data.get("floor_plan") is None: + raise ValidationError({"floor_plan_id": "This field is required."}) + serializer.save() -class FloorPlanTrayViewSet(TenantScopedViewSet): - """Tray/conduit runs — scoped through their plan's tenant, like tiles.""" - queryset = FloorPlanTray.objects.select_related("floor_plan").prefetch_related( - "cables" - ).order_by("name") - serializer_class = FloorPlanTraySerializer +class FloorPlanWallViewSet(TenantScopedViewSet): + """Wall polylines — scoped through their plan's tenant, like trays. + Render-only geometry in v1: drawn in 2D, extruded in 3D, and deliberately + NOT part of the cable-routing graph.""" + + queryset = FloorPlanWall.objects.select_related("floor_plan") + serializer_class = FloorPlanWallSerializer + pagination_class = StandardPagination + tenant_field = None + + def get_queryset(self): + tenant = _get_active_tenant(self.request) + if tenant is None: + return self.queryset.none() + qs = self.queryset.filter(floor_plan__tenant=tenant) + if self.request: + fp = self.request.query_params.get("floor_plan") + if fp: + qs = qs.filter(floor_plan_id=fp) + return restrict_for_view(self, qs) + + def perform_create(self, serializer): + if serializer.validated_data.get("floor_plan") is None: + raise ValidationError({"floor_plan_id": "This field is required."}) + serializer.save() + + +class FloorPlanRaisedFloorAreaViewSet(TenantScopedViewSet): + """Raised-floor rectangles — scoped through their plan's tenant, like + trays. The plenum depth they carry feeds underfloor tray elevation in the + 3D room and the vertical-drop term in route-length estimation.""" + + queryset = FloorPlanRaisedFloorArea.objects.select_related("floor_plan") + serializer_class = FloorPlanRaisedFloorAreaSerializer pagination_class = StandardPagination tenant_field = None @@ -5473,7 +6565,7 @@ def perform_create(self, serializer): class CableRouteViewSet(TenantScopedViewSet): """Geographic duct/aerial/trench runs on the site map.""" - queryset = CableRoute.objects.prefetch_related("cables").order_by("name") + queryset = CableRoute.objects.prefetch_related("cables").order_by(NATURAL_NAME) serializer_class = CableRouteSerializer pagination_class = StandardPagination diff --git a/audit/apps.py b/audit/apps.py index 12210a54..c0d997f7 100644 --- a/audit/apps.py +++ b/audit/apps.py @@ -81,6 +81,8 @@ "api.VirtualMachine", "api.VMInterface", "api.RackRole", + "api.RackType", + "api.RackTypeAccessory", "api.Rack", "api.DeviceRole", "api.PlatformGroup", @@ -93,6 +95,8 @@ "api.FloorPlanTile", "api.SiteMarker", "api.FloorPlanTray", + "api.FloorPlanRaisedFloorArea", + "api.FloorPlanWall", "api.CableRoute", # Customisation + monitoring config (not high-volume engine state). "customization.CustomField", @@ -105,6 +109,8 @@ "monitoring.MonitoringEngine", "monitoring.MonitoringEngineBinding", "monitoring.OutpostRelease", + "monitoring.SnmpSensor", + "monitoring.RedfishEndpoint", # Org-level objects. "core.Tenant", "core.TenantGroup", diff --git a/audit/models.py b/audit/models.py index f79a5924..0706896e 100644 --- a/audit/models.py +++ b/audit/models.py @@ -57,7 +57,7 @@ class ChangeLogEntry(models.Model): # {field: {"old": ..., "new": ...}} for updates; "{}" for create/delete. changes = models.JSONField(default=dict, blank=True) - # Full field snapshots (NetBox-style): the whole row before the write + # Full field snapshots: the whole row before the write # (update/delete) and after it (create/update). Null when not applicable — # a create has no pre state, a delete no post state. pre_change = models.JSONField(null=True, blank=True) diff --git a/audit/tests_snapshots.py b/audit/tests_snapshots.py index 0c8005ec..63538046 100644 --- a/audit/tests_snapshots.py +++ b/audit/tests_snapshots.py @@ -1,6 +1,6 @@ """Change-log pre/post snapshots + the detail endpoint that serves them. -The snapshots power the NetBox-style changelog detail page (Difference + +The snapshots power the changelog detail page (Difference + Pre-/Post-Change Data panels): create stores the post state, update stores both, delete stores the pre state. """ diff --git a/auth_api/object_types.py b/auth_api/object_types.py index b9cb560f..dce2f16f 100644 --- a/auth_api/object_types.py +++ b/auth_api/object_types.py @@ -78,6 +78,8 @@ ("api.Manufacturer", "Manufacturers", "DCIM"), ("api.Rack", "Racks", "DCIM"), ("api.RackRole", "Rack roles", "DCIM"), + ("api.RackType", "Rack types", "DCIM"), + ("api.RackTypeAccessory", "Rack type accessories", "DCIM"), ("api.Interface", "Interfaces", "DCIM"), ("api.MACAddress", "MAC addresses", "DCIM"), ("api.FrontPort", "Front ports", "DCIM"), @@ -99,6 +101,8 @@ ("api.FloorPlan", "Floor plans", "DCIM"), ("api.FloorPlanTile", "Floor-plan tiles", "DCIM"), ("api.FloorPlanTray", "Floor-plan cable trays", "DCIM"), + ("api.FloorPlanRaisedFloorArea", "Floor-plan raised floors", "DCIM"), + ("api.FloorPlanWall", "Floor-plan walls", "DCIM"), ("api.SiteMarker", "Site-map markers", "DCIM"), ("api.TopologyView", "Topology views", "DCIM"), ("api.AuxPort", "Aux ports", "DCIM"), @@ -124,6 +128,8 @@ # SNMP profiles are credentials — unregistered they'd fall back to "any # tenant member may write", which is exactly wrong for secrets. ("monitoring.SnmpProfile", "SNMP profiles", "Monitoring"), + ("monitoring.SnmpSensor", "SNMP sensors", "Monitoring"), + ("monitoring.RedfishEndpoint", "BMC (Redfish) endpoints", "Monitoring"), ("monitoring.NotificationChannel", "Notification channels", "Monitoring"), ("monitoring.AlertRule", "Alert rules", "Monitoring"), ("monitoring.Silence", "Silences", "Monitoring"), diff --git a/auth_api/site_paths.py b/auth_api/site_paths.py index 2453d794..6022a42b 100644 --- a/auth_api/site_paths.py +++ b/auth_api/site_paths.py @@ -43,6 +43,8 @@ "floorplan": "location__site", "floorplantile": "floor_plan__location__site", "floorplantray": "floor_plan__location__site", + "floorplanraisedfloorarea": "floor_plan__location__site", + "floorplanwall": "floor_plan__location__site", # A site's own scope is itself. "site": "id", # Per-site settings rows — a change grant scoped to sites=[X] makes its diff --git a/customization/models.py b/customization/models.py index 53fb05cf..4ce71fde 100644 --- a/customization/models.py +++ b/customization/models.py @@ -46,7 +46,7 @@ class CustomFieldGroup(TimestampedModel): """A named bucket that related custom fields can belong to, so forms and detail pages render them under a heading instead of one flat list. - Smarter than a free-text group name (NetBox's approach): a real object means + Smarter than a free-text group name: a real object means renaming/reordering happens in one place, typos can't split a group, and the group can carry a description + a collapse default. Tenant-scoped like every other customization object. diff --git a/danbyte/plugin_loader.py b/danbyte/plugin_loader.py index 5886bde6..4dbc2b75 100644 --- a/danbyte/plugin_loader.py +++ b/danbyte/plugin_loader.py @@ -55,7 +55,7 @@ class LoadResult: def _find_config_class(module_name: str): """Locate a plugin's ``DanbytePluginConfig`` subclass. - Prefers ``.config`` (NetBox-style), else scans ``.apps`` for the + Prefers ``.config``, else scans ``.apps`` for the single ``DanbytePluginConfig`` subclass. Importing ``plugins.base`` here is safe — it only defines a class deriving from ``django.apps.AppConfig``. """ diff --git a/danbyte/settings.py b/danbyte/settings.py index e37729df..436cda05 100644 --- a/danbyte/settings.py +++ b/danbyte/settings.py @@ -77,12 +77,12 @@ ] # ─── Plugins ───────────────────────────────────────────────────────────────── -# NetBox-style trusted plugins: a comma-separated list of importable plugin -# packages, applied on restart. Each is discovered + version-gated at import +# Trusted plugins (package + restart): a comma-separated list of importable +# plugin packages, applied on restart. Each is discovered + version-gated at import # time here (before Django builds the app registry) and appended to # INSTALLED_APPS; a broken/incompatible one is skipped and reported via # /api/plugins/ rather than aborting boot. PLUGINS_CONFIG holds per-plugin -# settings overrides (keyed by plugin slug), NetBox-style. +# settings overrides (keyed by plugin slug). PLUGINS = [p for p in os.getenv("PLUGINS", "").split(",") if p.strip()] PLUGINS_CONFIG: dict = {} @@ -203,23 +203,27 @@ } } +# RQ queue DB — separate from 0 lets a second instance on the same Redis run +# its own worker pool without stealing the primary's jobs (a dev clone sets +# RQ_REDIS_DB to a spare index). +_RQ_DB = int(os.getenv("RQ_REDIS_DB", "0")) RQ_QUEUES = { "default": { "HOST": os.getenv("REDIS_HOST", "localhost"), "PORT": int(os.getenv("REDIS_PORT", "6379")), - "DB": 0, + "DB": _RQ_DB, "DEFAULT_TIMEOUT": "1h", }, "high": { "HOST": os.getenv("REDIS_HOST", "localhost"), "PORT": int(os.getenv("REDIS_PORT", "6379")), - "DB": 0, + "DB": _RQ_DB, "DEFAULT_TIMEOUT": "1h", }, "low": { "HOST": os.getenv("REDIS_HOST", "localhost"), "PORT": int(os.getenv("REDIS_PORT", "6379")), - "DB": 0, + "DB": _RQ_DB, "DEFAULT_TIMEOUT": "24h", }, } diff --git a/danbyte_checks/snmp_facts.py b/danbyte_checks/snmp_facts.py index 860f5162..fd6b6212 100644 --- a/danbyte_checks/snmp_facts.py +++ b/danbyte_checks/snmp_facts.py @@ -379,9 +379,15 @@ def parse_fdb(fdb_port: dict, base_port_ifindex: dict) -> list[dict]: return out -async def _walk_column(mod, engine, auth, transport, base: str) -> dict: +async def _walk_column( + mod, engine, auth, transport, base: str, limit: int | None = None +) -> dict: """Walk one column → ``{oid_tail_after_base: prettyValue}``. Tolerant: a - missing/blocked column yields ``{}`` rather than failing the whole fetch.""" + missing/blocked column yields ``{}`` rather than failing the whole fetch. + + ``limit`` stops after that many bindings — for exploring a whole table base + interactively, where the subtree can be far larger than any one column. + """ result: dict = {} try: walk = mod.bulk_walk_cmd( @@ -395,6 +401,8 @@ async def _walk_column(mod, engine, auth, transport, base: str) -> dict: tail = str(oid)[len(base) + 1:] if tail: result[tail] = value.prettyPrint() + if limit is not None and len(result) >= limit: + break except Exception: # noqa: BLE001 return result return result @@ -471,3 +479,143 @@ def fetch_snmp(target, version, params, secret_params, timeout_ms) -> dict: except SnmpFactsError as exc: out["error"] = str(exc)[:500] return out + + +# ─── Arbitrary OID fetch (user-defined sensors) ───────────────────────────── + +async def fetch_oid( + target: str, version: str, params: dict, secret_params: dict, + oid: str, walk: bool, timeout_ms: int = 4000, limit: int | None = None, +) -> dict: + """Read one user-defined OID → ``{index: prettyValue, ...}``. + + WALK mode returns one entry per table row (key = the OID tail after + ``oid``); scalar (GET) mode returns ``{"0": value}``. Raises + ``SnmpFactsError`` on a config/engine failure so the caller records the + error; an empty dict means the agent simply had nothing there. + """ + try: + import pysnmp.hlapi.v3arch.asyncio as mod + except Exception as e: # noqa: BLE001 + raise SnmpFactsError(f"pysnmp unavailable: {e}") + + port = int(params.get("port", 161)) + timeout_s = max(timeout_ms / 1000, 0.2) + try: + auth = _auth_data(version, params, secret_params, mod) + transport = await mod.UdpTransportTarget.create( + (target, port), timeout=timeout_s, retries=0 + ) + engine = mod.SnmpEngine() + if walk: + return await _walk_column( + mod, engine, auth, transport, oid.strip("."), limit + ) + error_indication, error_status, _, var_binds = await mod.get_cmd( + engine, auth, transport, mod.ContextData(), + mod.ObjectType(mod.ObjectIdentity(oid)), + ) + except Exception as e: # noqa: BLE001 + raise SnmpFactsError(f"snmp error: {e}") + if error_indication: + raise SnmpFactsError(str(error_indication)) + if error_status: + raise SnmpFactsError(error_status.prettyPrint()) + return {"0": v.prettyPrint() for _, v in var_binds} + + +async def list_oid_children( + target: str, version: str, params: dict, secret_params: dict, + base: str, timeout_ms: int = 4000, limit: int = 64, +) -> list[dict]: + """List the direct children of ``base`` → ``[{sub, oid, sample}, ...]``. + + One level, not a subtree. A plain walk can't browse the tree: OIDs come back + in lexicographic order, so walking a high base like ``1.3.6.1.4.1`` spends + its entire budget inside the first vendor it meets and never reveals that + the others exist. + + So each child is found with a single GETNEXT, then its whole subtree is + skipped by probing ``base.child.4294967295`` — greater than anything within + that child (max sub-identifier), yet still less than the next sibling, so no + sibling is stepped over. That's one round trip per child instead of one per + value. + """ + try: + import pysnmp.hlapi.v3arch.asyncio as mod + except Exception as e: # noqa: BLE001 + raise SnmpFactsError(f"pysnmp unavailable: {e}") + + base = base.strip(".") + prefix = f"{base}." + port = int(params.get("port", 161)) + timeout_s = max(timeout_ms / 1000, 0.2) + out: list[dict] = [] + try: + auth = _auth_data(version, params, secret_params, mod) + transport = await mod.UdpTransportTarget.create( + (target, port), timeout=timeout_s, retries=0 + ) + engine = mod.SnmpEngine() + probe = base + while len(out) < limit: + error_indication, error_status, _, var_binds = await mod.next_cmd( + engine, auth, transport, mod.ContextData(), + mod.ObjectType(mod.ObjectIdentity(probe)), + lexicographicMode=True, + ) + if error_indication or error_status: + # Nothing collected yet → the agent never answered, which is a + # very different thing from an empty subtree and must not be + # reported as "nothing there". Small BMCs do time out under + # consecutive browses. Once we have children, keep the partial + # listing: it's still navigable. + if not out: + raise SnmpFactsError( + str(error_indication or error_status.prettyPrint()) + ) + break + if not var_binds: + break + oid, value = var_binds[0] + found = str(oid) + if not found.startswith(prefix): + break # walked out of the subtree — done + sub = found[len(prefix):].split(".")[0] + out.append({ + "sub": sub, + "oid": f"{base}.{sub}", + # Where the first value under this child actually lives, which + # is what tells a table entry (one level down) from a branch. + "first_oid": found, + "sample": value.prettyPrint(), + }) + probe = f"{base}.{sub}.4294967295" + except SnmpFactsError: + raise + except Exception as e: # noqa: BLE001 + raise SnmpFactsError(f"snmp error: {e}") + return out + + +def list_oid_children_sync( + target, version, params, secret_params, base, timeout_ms=4000, limit=64 +) -> list[dict]: + """Synchronous wrapper for browsing the tree from a DRF view.""" + return asyncio.run( + list_oid_children( + target, version, params, secret_params, base, timeout_ms, limit + ) + ) + + +def fetch_oid_sync( + target, version, params, secret_params, oid, walk, timeout_ms=4000, + limit: int | None = None, +) -> dict: + """Synchronous wrapper for on-demand sensor polling from a DRF view.""" + return asyncio.run( + fetch_oid( + target, version, params, secret_params, oid, walk, timeout_ms, limit + ) + ) diff --git a/docs/architecture/plugins.md b/docs/architecture/plugins.md index 2466c7d8..b1aab804 100644 --- a/docs/architecture/plugins.md +++ b/docs/architecture/plugins.md @@ -5,7 +5,7 @@ icon: lucide/puzzle # Plugins Danbyte has a first-class plugin system: a **trusted, in-process** extension -model in the NetBox tradition. A plugin is an ordinary Python package that an +model. A plugin is an ordinary Python package that an operator installs and lists in the `PLUGINS` setting; on the next restart it is discovered, version-checked, and wired into RBAC, custom fields, tags, import/export, audit, monitoring, automation, and the UI — with **no core diff --git a/docs/architecture/service-monitoring.md b/docs/architecture/service-monitoring.md index b532b437..778ecc87 100644 --- a/docs/architecture/service-monitoring.md +++ b/docs/architecture/service-monitoring.md @@ -76,6 +76,10 @@ that were waiting for one. - **Device type → Components → Services.** Define the services a device of this type exposes and tick **Monitor** to have every new device auto-watched. This is the fleet-wide control plane. +- **Service detail page.** **Monitor** resolves the target IP and starts + watching, **Edit** opens the same form the Services tab uses (there is no + separate edit route — the dialog *is* the editor), and **Delete** removes the + service and cascades its checks. - The old `POST /api/services/{id}/monitor/` action still exists (sets `monitored=True` + reconciles) for backward compatibility. diff --git a/docs/dcim/cabling.md b/docs/dcim/cabling.md index cd414f92..c4bc1913 100644 --- a/docs/dcim/cabling.md +++ b/docs/dcim/cabling.md @@ -52,6 +52,11 @@ every device and panel the run passes through as linked chips, with the pass-through ports shown `front ⇄ rear` and each cable segment labelled (the current cable highlighted). Breakout fan-outs fall back to the Trace tab. +The port cells in a chip are click targets: an interface opens its own page, +while a front / rear / console / power port opens its device's +[Components → Hardware](devices.md#the-device-page) sub-tab +(`?tab=components&sub=hardware`), where those ports live. + ## Connection shapes You're not limited to one-to-one patches: @@ -70,6 +75,8 @@ connection **passes through** to the rear and continues on whatever's cabled there — so a link can cross several panels and Danbyte still follows it. Manage a panel's front/rear ports from its device page, alongside its interfaces. +Each port takes a **description** — the room the trunk runs to, the label on the +sticker — shown as a column in the front/rear port tables and editable in bulk. ## Tracing a connection @@ -94,3 +101,22 @@ You don't have to start from the Cables page: any **uncabled interface** offers a **Connect cable** button — on the interfaces table (row action) and in the interface detail header. It opens the cable form with that port already on the A side; pick the B side and save. + +The same affordance follows every other cable-able port, permissions allowing +(you need cable-add rights; the server enforces them regardless): + +- **Power tab** — uncabled **power ports** and **power outlets** carry the same + ghosted connect button as interface rows, landing on the cable form with the + inlet or outlet pre-seeded as side A. +- **Photo faceplate** — on a device whose type has + [photo ports](device-catalog.md#photo-ports), a **free** power / console / + console-server / aux / front / rear marker is a button: click it and the + cable maker opens right there, titled from that port, with it already on the + A side. Cabled markers keep their hover card. +- **3D room** — clicking a free port marker on a device's face offers the same + connect flow (pick the far end in 3D, or open the cable maker) — see + [floor plans](../features/floor-plans.md#the-3d-room-view). + +However you arrive, the pre-seeded port shows as a **named chip** +(`device:port`) on the form, never a raw id — a **power feed** chip reads +`panel:feed`, since feeds terminate on power panels rather than devices. diff --git a/docs/dcim/device-catalog.md b/docs/dcim/device-catalog.md index f0f80f98..711407a8 100644 --- a/docs/dcim/device-catalog.md +++ b/docs/dcim/device-catalog.md @@ -65,9 +65,16 @@ component templates use the same taxonomy — so they import 1:1. Click - paste **GitHub links** to `.yaml` files in the library (one per line — regular `blob` links work, they're converted automatically), +- paste a **folder link** — a `/tree/` URL such as + `…/device-types/Cisco` — to import every device type in it (one + manufacturer at a time; the importer lists the folder over the GitHub API + and pulls each file), - paste the **YAML itself**, or - **upload** the `.yaml` files. +The whole `device-types` folder is thousands of files — too many for one +synchronous import, so pull it a manufacturer (or a few) at a time. + Manufacturers are created as needed. Everything Danbyte models — interfaces, console/console-server ports, power ports/outlets, front/rear ports, **module bays**, **device bays** (+ subdevice role, exclude-from-utilisation), @@ -85,6 +92,62 @@ to the [`{position}` token](virtual-chassis.md#position-aware-interface-names) (`1/…` → `{position}/…`, Juniper-style `0/…` → `{position:0}/…`) so one imported type serves every member of a stack. +### Filtering a long catalog {#filtering} + +Import a vendor folder or two and the catalog runs to hundreds of models, so the +Device types list carries a full filter rail. The search box still matches name +and model; the rail narrows by: + +| Facet | Picks out | +|---|---| +| **Manufacturer** | one vendor's models | +| **U** | a height range — 1U top-of-rack gear, or everything ≥ 4U | +| **Images** | whether the type has a [rack-face photo](#rack-face-images) at all | +| **Faceplate** | how its devices draw their panel: **Photo ports**, **Custom** or **Auto** | +| **Usage** | **In use** vs **Unused** — catalog entries no device is built from | +| **Lifecycle** | [vendor lifecycle state](../features/lifecycle.md) — what's end-of-life | +| **Scope** | site-local vs tenant-wide entries, where the deployment scopes catalogs per site | +| **Tags** | any tag; the chips in the Tags column toggle the same filter | + +Facets stack (**Cisco** + **Unused** + **End of life** is the prune list), and +the count chip beside the title always reports what survived them. A facet that +can't split the rows you have — every type global, nothing laid out yet — hides +itself rather than take up rail space. + +Two columns carry what the rail filters on. **Images** shows which faces exist +(**Front**, **Rear**, or `—`), and **Faceplate** reads *Photo ports* when +[markers are placed on a photo](#photo-ports), *Custom* when a +[faceplate layout](#faceplate-builder) is saved, and a muted *Auto* when the +panel is drawn automatically — so **Images: Yes** plus **Faceplate: Auto** is +exactly the queue of types you have a photo for but haven't marked up yet. Hide +either column from the **Columns** menu if you don't work with panels; the +filters stay. + +### Deleting types in bulk {#bulk-delete} + +Tick the checkbox on any row and a bar appears at the bottom of the list with +the selection count, **Export** (CSV / Excel / JSON of just those rows) and +**Delete**. The selection is drawn from the rows the rail is currently showing, +so the usual prune — **Cisco** + **Unused** + **End of life**, select all, +delete — clears a vendor's dead models in one pass. The bar only appears if you +hold `delete` on device types. + +The confirm names up to five of the types and, crucially, **sums the devices +attached to the whole selection**: *"12 devices use these types — they'll keep +working but lose their type reference."* Deleting a type never deletes its +devices; `Device.device_type` is nulled, so those devices keep running, +untyped, until you point them at another type. What *does* go with the type is +its own [component templates](#component-templates), faceplate and photo-port +markers. + +Deletion runs through `POST /api/device-types/bulk-delete/` (`{ids}`) and +returns `{"deleted": n}` — a count of **types**, not of the templates that +cascaded with them. The submitted ids are re-checked server-side against your +tenant and, where the deployment scopes catalogs per site, your site scope: an +id you can see but not write (a tenant-wide entry, or one local to another +site) is skipped rather than deleted, so `n` can be smaller than the number you +selected. Every removal lands in the change log. + ### Rack-face images On a device type's detail page you can upload a **front image** and a **rear @@ -94,6 +157,52 @@ looks like the real thing. Use the **Front / Rear** toggle on the rack to switch faces. The same images also render read-only on each **device's** Overview tab, so you can see the hardware without opening the type. +### Recovering lost images {#reimport-images} + +Images live in the media folder; the device types live in the database. Lose +the media folder — disk corruption, a restore that skipped `media/`, a botched +migration between hosts — and every type still *lists* an image it no longer +has. **Reimport images** on the Device types page (needs `change` on device +types) rebuilds exactly that: it matches your **existing** types against a +devicetype-library-layout repository and re-downloads their elevation images. +Nothing is created, renamed, or otherwise modified — only the two image fields +are written, and every write lands in the change log. + +Point it at a repository in whichever form you have handy — plain +`owner/name`, a `github.com` URL (optionally `/tree/`), or a full https +base such as an internal mirror. The default is Danbyte's +[device-library](https://github.com/danbyte-net/device-library) fork, which +keeps the upstream layout: images at +`elevation-images//.front|rear.png`. Matching reuses the +import's own naming: the slug embedded in a surviving image filename first +(it's still in the database even when the file is gone), then +vendor-prefixed slugs derived from the type's name, part number and model. + +**Dry run** classifies without writing: **matched** (the repo has images for +it), **no match**, or **has images** (both faces present *and their files +actually exist on disk*). Apply is **fill-gaps-only** by default — a face is +written only when its field is empty *or* the field is set but the file is +missing from storage. That second case is the whole point: after media loss +the database still says "has image", and Danbyte treats it as a gap rather +than trusting the stale reference. Tick **overwrite** to replace intact +images too, e.g. after switching to a repo with better photos. A repo that's +unreachable mid-run marks the affected faces `fetch failed` and carries on — +one bad fetch never aborts the batch. + +Small catalogs answer synchronously with a per-type report; anything over +~50 types runs in the background with the same pollable progress as the +[folder import](#importing-from-the-netbox-devicetype-library). The API is +`POST /api/device-types/reimport-images/` (`{"repo": …}`, flags `?dry_run=1` +/ `?overwrite=1`), which either returns the report or `202` + a run to poll +at `import-runs//`. + +**Airgapped deployments** (update checks disabled) get a clean refusal +instead of a hanging timeout — no outbound request is attempted. Recovery +there is the offline route: restore the media folder from a backup, or +re-upload images per type; [bundles](#bundles) stay the offline carrier for +*definitions*, but they deliberately reference images rather than embed +them, so they can't restore the files themselves. + ### Jumping to the devices The **Devices** count on a device type's detail page is a link: it opens the @@ -102,7 +211,48 @@ seeded from the URL), so you land on exactly those devices — the same foreign-key linkage used throughout Danbyte to keep related objects one click apart. -### Component templates +### Share a device type as a bundle {#bundles} + +Teaching Danbyte a piece of hardware is real work: stamp the component +templates, draw the [faceplate](#faceplate-builder), place the photo-port +markers on the rear image, find the vendor OID that reports drive health. All of +it is knowledge about the **model** — identical for everyone who owns that box. + +A **bundle** is that work in one file. On a device type, **Export bundle** +downloads everything that makes the model work: + +- every component template (interfaces, console, power, panel ports, bays) +- the faceplate layout and the photo-port markers +- inventory-item templates (the disk bays a chassis ships with) +- the [custom SNMP sensors](../features/snmp-discovery.md#sensors) bound to it + +**Import bundle** on the device-type list reads one back. It **previews first** — +importing a file from elsewhere should never be blind — showing what would be +created, and only then offering to apply it. + +Three rules make a bundle safe to accept from anyone: + +- **No credentials.** Sensors poll with *your* deployment's own + [SNMP profile](../features/snmp-discovery.md#snmp-profiles); a bundle + references nothing secret. +- **Imported sensors are observe-only.** Whatever the file says, they arrive as + `drift` — they surface differences for review and can never overwrite a status + you set. Switch one to automatic yourself if you want that. +- **Nothing is overwritten silently.** A device type you already have is skipped + unless you tick *Update the device type if it already exists* (which needs + change access, not just add). + +Ids never travel — manufacturers, an outlet's inlet, a front port's rear port all +move as **names** and are re-resolved locally. Anything that can't be resolved is +reported, never dropped in silence. Photo-port coordinates are normalized 0–1, so +they line up at any resolution of the same photo; if the bundle was built against +an image you don't have, the import says so — upload it on the device type and +the markers land correctly. + +API: `GET /api/device-types/{id}/library-export/` and +`POST /api/device-types/import-bundle/?dry_run=1&replace=1`. + +## Component templates A device type owns **component templates** — the ports the hardware ships with: interfaces, console port(s), power inlets, PDU outlets, and patch-panel @@ -114,10 +264,14 @@ hand-typing ports per device. Manage them on the device type's **Components** tab, which splits the component kinds — Interfaces, Console ports, Console server ports, Power ports, Power outlets, Rear ports, Front ports, **Aux ports**, and **Services** — into -sub-tabs with counts. **Aux ports** are the catch-all for connectors the other -kinds don't cover: USB (A/B/C/mini/micro), video outputs (HDMI, VGA, DVI, -DisplayPort), SD/microSD slots, RJ11, audio jacks, and grounding lugs — so a -device type can model *everything* on its panel. Template names support +sub-tabs with counts. The open sub-tab is part of the URL +(`?tab=components&sub=power-port`), so you can link someone straight at one +kind, and reload or back/forward without losing your place. + +**Aux ports** are the catch-all for connectors the other kinds don't cover: USB +(A/B/C/mini/micro), video outputs (HDMI, VGA, DVI, DisplayPort), SD/microSD +slots, RJ11, audio jacks, and grounding lugs — so a device type can model +*everything* on its panel. Template names support two shorthands: a **`[1-24]` range** creates one template per port in a single add, and a **`{position}` token** resolves to the device's stack member number when components are stamped (and renames ports when a device changes stack @@ -210,6 +364,53 @@ Templates renamed or deleted after a layout was saved render as dashed **ghost** cages, and the tab counts them so you can tidy up. Interfaces the layout doesn't place are appended automatically — nothing silently disappears. +### Photo ports (anchoring ports on a real image) {#photo-ports} + +When a device type has a front and/or rear **[image](#rack-face-images)**, a +**Photo ports** tab appears. Instead of the schematic cage layout, you place +port markers **directly on the photo** — drag an interface (or console / power +/ panel port) template from the palette onto the image, then position it +precisely: drag it, grab the corner handle to resize, nudge with the **arrow +keys** (Shift = coarser), or type exact **X / Y / W / H** percentages. A +**fine-grid snap** keeps rows aligned. Coordinates are stored normalized +(0–1), so they scale to any render size. + +Once a type has an image **and** at least one placed marker, its devices show +the **photo faceplate** in place of the schematic one — each marker matched to +the device's real interface by name (so it carries the same state colour, live +SNMP dot, hover card and link), and the markers also render **on the device's +face in the [3D room view](../features/floor-plans.md#the-3d-room-view)**. +Types without photo ports keep using the schematic faceplate builder above. + +The palette also offers the type's **[inventory-item](#inventory-items) +templates** under *Hardware* — place disk bays, PSUs and other parts on the +photo the same way. Hardware markers resolve to the device's real parts by +name and are coloured by the **part's status** (a *Failed* disk reads red on +the faceplate and in 3D); hovering shows the part's media, capacity, speed, +status and serial. Hardware markers are informational — they never join the +cable-connect flow. + +**[Module bay](#module-types) templates** are placeable too, under *Module bays +(line cards)* — mark where a chassis's card slots physically are. Because a +slot is a broad rectangle rather than a connector-sized sliver, a dropped bay +marker starts at **20 % × 45 %** instead of the port default; resize it from +there like any other marker. + +A bay marker answers one question — **is this slot free?** — so it is drawn as +occupancy, not speed and not health: a bay with a module seated in it is +**filled**, an empty one is the same faint outline an idle port wears. Hovering +(2D) or clicking (3D) names the installed module type and its serial, or reads +**Empty**. On a device *type* there is no device yet, so every bay draws as +empty — that is the honest answer, not an error. The key under the panel lists +only the occupancies actually on screen, and only when the panel carries bay +markers at all. Bays are informational here too; install and remove modules on +the device's **Hardware** tab. + +Note the split with the schematic [faceplate builder](#faceplate-builder), +which stays port-only: there a bay is a **group placeholder** whose installed +module's own faceplate gets composed in, while the photo builder marks the +slot's real position on the artwork. + ## Device bays (chassis nesting) A **parent** chassis (blade enclosure, FEX parent) declares **device bays** @@ -227,12 +428,52 @@ used-units number. ## Inventory items **Inventory items** are serial-tracked physical parts that aren't cabled -components — PSUs, fans, CPUs, discrete SFPs. Templates on the device type -(Components → Inventory) stamp onto new devices; on the device page's -**Hardware** tab you can add/edit parts with manufacturer, part ID, serial -and asset tag, and nest them one level (a fan tray containing fans). Roles -are just [tags](../features/tags-and-custom-fields.md) — no pre-filled role -catalog, per the zero-data rule. +components — disks, CPUs, RAM, PSUs, fans, discrete SFPs. Templates on the +device type (Components → Inventory) stamp onto new devices; on the device +page's **Hardware** tab you can add/edit parts with manufacturer, part ID, +serial and asset tag, and nest them one level (a fan tray containing fans). +Roles are just [tags](../features/tags-and-custom-fields.md) — no pre-filled +role catalog, per the zero-data rule. + +Each part also carries its **hardware identity and health**: + +- **Kind** — what the part is: Disk, CPU, RAM, PSU, Fan, GPU, Controller, + Transceiver, or Other (the default for pre-existing parts). +- **Media** (disks) — NVMe, SSD (SATA/SAS), HDD, or Tape. +- **Capacity** with a unit picker (KB → PB; stored in bytes, so it's + backwards- and future-proof). +- **Speed** — a dropdown of the common industry values for the part in front of + you: spindle rates for an HDD (5400/7200/10K/15K RPM), the bus for flash + (SATA 6Gb/s, SAS 12/24Gb/s, PCIe 3.0–5.0 x4), memory grades for RAM + (DDR4-3200, DDR5-5600), LTO generations for tape. The field stays free text, + so any vendor's wording still fits — the list is there to keep eight disks + from being recorded eight different ways. +- **Status** — the part's lifecycle/health from the shared + [status catalog](../features/catalogs-and-settings.md): every tenant gets + **Active / Planned / Failed / Spare** for inventory items (extensible like + any other status). Marking a disk *Failed* colours it red wherever the part + is shown. + +Templates carry kind/media/capacity/speed too, so a device type modelled with +eight `Bay {position}` NVMe templates stamps ready-described disks onto every +new device. + +### Bulk-editing parts + +On the device's **Hardware** tab, tick the checkbox on one or more parts (the +header checkbox selects all) — a bulk bar floats up: + +- **Edit** opens a *keep/set* dialog: anything left on **Keep current** is + untouched; set **Status** (mark eight disks *Failed* in one go), **Kind**, + **Media**, **Capacity** (value + KB…PB unit), **Speed**, **Part ID**, + **Description**, or add/remove **tags** — applied to every selected part. +- **Rename** does find/replace across the selected names (regex optional), + with a live preview. +- **Clone** duplicates the selected parts under new names. +- **Delete** removes them after a confirmation. + +The same bulk bar (and the same keyboard-free flow) is used on the interface +and port tables, so one habit covers every component list. ## Module types diff --git a/docs/dcim/devices.md b/docs/dcim/devices.md index 8bed54ee..2db2caf9 100644 --- a/docs/dcim/devices.md +++ b/docs/dcim/devices.md @@ -25,7 +25,9 @@ detail page. !!! note "Built-in fields vs. custom fields" Danbyte ships a curated set of common attributes as built-in device - fields — including **comments**, **location**, **cluster**, **airflow**, + fields — including **comments**, **location**, **cluster**, **airflow** +(the device's own value overrides its type's default; the resolved value is +served as `effective_airflow` and drives the 3D room's airflow cones), and **latitude** / **longitude**. Comments, location, and the coordinates are on by default (coordinates put a device on the [site map](../features/site-map.md)); cluster and airflow are opt-in @@ -67,6 +69,13 @@ a switch stack, a **Stack** badge in the header (name, position, master) links to its [virtual chassis](virtual-chassis.md) — membership is set in the **Stack membership** section of the device's edit form. +The tab you're on is part of the URL (`?tab=components`), and so is the +**sub-tab** inside the Components tab (`?sub=power`). So +`/devices/?tab=components&sub=power` links a colleague straight at a +device's power ports, and a reload, browser back/forward, or a trip through +another tab and back all keep your place. An unknown value in either param +falls back to the default tab instead of showing an empty pane. + ### Overview tab The default tab lays the device's facts out in four cards: @@ -144,9 +153,8 @@ the same defaults. | Tab | What's there | | -------------- | ----------------------------------------------------------------------------------------------- | | **IPs** | Every IP address assigned to this device. | -| **Interfaces** | The device's ports — add, edit, and nest them, and attach IPs. See [Interfaces](interfaces.md). | +| **Components** | Four sub-tabs: **Interfaces** (add, edit, and nest ports and attach IPs — see [Interfaces](interfaces.md)), **Console**, **Power**, and **Hardware** (device bays for child devices, module bays for line cards, serial-tracked inventory items, and patch-panel front/rear ports). | | **Services** | Application services running on the device. | -| **Hardware** | Device bays (install child devices), module bays (install/remove line cards), inventory items (serial-tracked parts) and patch-panel front/rear ports. | | **Contacts** | People responsible for the device. | | **Config** | Configuration context and rendered config. | | **Journal** | Free-form notes and a running log you write. | @@ -197,6 +205,19 @@ drag-and-drop builder), the panel follows that instead — including console, power, and aux ports placed on it. Layouts with a **rear side** add a **Front / Rear** toggle above the panel. +On a **photo faceplate** (a type with +[photo ports](device-catalog.md#photo-ports)), the markers are work surfaces +too, permissions allowing. An **empty module bay** takes a click to seat a +module — the same install dialog as Components → Hardware, stamping the module +type's interfaces onto the device — and the bay marker flips to occupied on +save. The same works in the +[3D room](../features/floor-plans.md#the-3d-room-view): the port card offers +**Install module** on an empty bay and **Edit part** on a hardware marker (the +same part editor the 2D faceplate opens for disk bays and PSUs). A **free** +power / console / aux / front / rear marker connects a cable in place — see +[Cabling](cabling.md#connecting-from-a-port). Removing a module stays on the +Hardware tab. + Racked devices also show a **Rack** card — the whole rack drawn with this device highlighted, linking to the [rack page](racks.md). @@ -207,6 +228,76 @@ speed. The overlay is read-only decoration from the monitoring collector: observed facts are drawn *over* your intent, never written into it, so the source of truth stays yours. +### The panel's key + +The **speed ramp is always the full scale**, FE → 400G+, at a fixed width. It's a +scale, and a scale only means something if it reads identically on every page — +so it doesn't shrink to the speeds on the panel in front of you. (It briefly did. +With two speeds present, two segments split a fixed-width bar into two enormous +slabs, which looked like a different control rather than a shorter one.) + +The **hardware key** does adapt, because its entries are chips and a shorter list +is just a shorter list: a server whose photo panel is nothing but disk bays gets +`Active · Empty`, not the tenant's whole inventory-status catalog. + +A virtual chassis draws one key for the whole stack, unioning what its members +drew. In the [3D room](../features/floor-plans.md#the-3d-room-view) the key is +hidden entirely until something photo-anchored is in view. + +## Adding many components at once + +Any component you add to a device takes a **`[a-b]` range in its name** and +creates one component per number — the same shorthand as a device type's +[component templates](device-catalog.md#component-templates). Type +`Disk[1-5]` and you get Disk1 … Disk5; a live line under the Name field shows +the count and the first/last name before you submit. + +It works on every add dialog on the device page: + +| Tab | Components | +| ------------- | ---------------------------------------------------------------- | +| **Interfaces**| interfaces (**Add interface**) | +| **Console** | console ports, console server ports | +| **Power** | power ports (inlets), power outlets | +| **Hardware** | inventory parts, patch-panel front and rear ports | + +Everything else on the dialog — type, speed, description, an outlet's inlet and +feed leg, tags — is applied to every component in the range, so a PDU's +`Outlet[1-24]` all hang off the same inlet in one submit. Ranges apply to +**creating** only: editing a component renames that one row. + +Notes: + +- The ports are created **one at a time, in order**. Names must be unique per + device, so if one collides the error names the port that clashed and the ones + created before it stay created. +- A range spanning more than **128** components is left alone and treated as a + literal name — reach for **Bulk add** on the Interfaces tab instead, which + goes through a server-side endpoint, preserves zero-padding + (`Gi1/0/[01-48]`), and silently skips names the device already has. See + [Interfaces](interfaces.md#add-many-interfaces-at-once). +- **Front ports** advance a second field as they go. A front port claims its own + strand range on the rear port, and two of them may not share a strand — so the + range steps the **Start strand** along with the name. `Front[1-24]` against a + 24-strand rear port starting at strand 1 wires the whole trunk through in one + submit; with a 2-strand connector each port takes the next *pair*. Pick the + rear port and starting strand once and the rest follows. + +## Component descriptions + +Every component a device can carry — interfaces, console and console server +ports, power ports and outlets, front and rear ports, aux ports, module and +device bays, inventory parts — has a short free-text **description** for notes: +what's on the far end, why a port is reserved, a ticket reference. It's a single +line (255 characters). Fill it in on the component's add/edit dialog, read it +back as a column in the component's table, and retype it across a whole +selection from the bulk-edit bar. + +A component **template** on the device type has one too, and it's copied onto +every component stamped from it — so "reserved for out-of-band" written once on +the type reaches every device built from it. Editing the concrete component's +description afterwards doesn't touch the template. + ## Bulk editing components Every component table — interfaces, console ports, power ports/outlets, diff --git a/docs/dcim/interfaces.md b/docs/dcim/interfaces.md index 5b1d7b9b..05b94e05 100644 --- a/docs/dcim/interfaces.md +++ b/docs/dcim/interfaces.md @@ -34,6 +34,7 @@ From a device's **Interfaces** tab, click **Add interface**, then fill in: | **VLAN** | An optional VLAN association. | | **MAC address** | The port's hardware address. | | **Enabled** | Whether the port is administratively up. | +| **Description** | A short free-text note — what's on the far end, why the port is reserved, a ticket reference. | ### Interface type @@ -71,6 +72,13 @@ and watch the live preview: Names that already exist on the device are skipped, so re-running is safe. +The single **Add interface** form takes a `[a-b]` range too (`eth[0-3]`), which +is handier for a few ports since you get the full field set — type, MTU, PoE, +VLANs, VRF, LAG — applied to all of them. Bulk add is the one to use for a whole +switch face: it does the work server-side, keeps zero-padding, and skips +existing names. See +[Adding many components at once](devices.md#adding-many-components-at-once). + ## Edit many at once Tick the rows you want and a bar floats up from the bottom — **Edit** opens a @@ -89,8 +97,9 @@ type's component templates. ## What you see in the list On the device's **Interfaces** tab, each row shows the name, type, enabled state, -speed, VLAN, cable count, and any **IP addresses** attached to it. Sub-interfaces -are indented under their parent, and aggregate members show their LAG — see +speed, VLAN, cable count, any **IP addresses** attached to it, and the +description. Sub-interfaces are indented under their parent, and aggregate +members show their LAG — see [Virtual & aggregate interfaces](virtual-interfaces.md). ## Attaching IP addresses @@ -102,8 +111,8 @@ an address on the port without leaving the page. See ## The interface detail page Click an interface name to open its page. It shows the device, type, speed, MTU, -VLAN, MAC, any parent/LAG/bridge relationships, the IPs assigned to it, and a -cable trace. From here you can also add or assign IPs. +VLAN, MAC, description, any parent/LAG/bridge relationships, the IPs assigned to +it, and a cable trace. From here you can also add or assign IPs. ## VM interfaces diff --git a/docs/dcim/racks.md b/docs/dcim/racks.md index 239855d7..9e5808ad 100644 --- a/docs/dcim/racks.md +++ b/docs/dcim/racks.md @@ -13,6 +13,13 @@ familiar front/rear diagram showing what's mounted in each rack unit. 2. Name it and set its **height** in rack units (e.g. 42U) and **starting unit** (usually 1). 3. Optionally assign a **site**, a **rack role**, and tags. +4. Optionally record the cabinet's **outer width / depth (mm)** — the physical + footprint including the frame. These drive the 3D room view and scaled + drawings; left blank, plausible defaults are used (depth 1000 mm, width + derived from the rail width plus a 150 mm frame). + +Picking a [**rack type**](#rack-types) fills the height, width, outer +dimensions and weight budget from the cabinet model in one go. ### Rack roles @@ -20,6 +27,61 @@ A **rack role** classifies a rack's purpose (e.g. *network*, *compute*, *storage*) with a color, so racks group visually. Define them on the **Rack roles** page — like everything else, none ship by default. +### Rack types + +A **rack type** is a cabinet *model* — "APC NetShelter SX 42U 600mm" — with +the dimensions a cabinet of that model always has: rail width, height in U, +starting unit and numbering direction, outer width/depth (mm), and the load +rating. Define them on **DCIM → Rack types**; picking one on the rack form +**pre-fills all of those fields** (each stays editable — the rack remains the +source of truth, so a one-off odd cabinet just overrides a value). + +A rack type can also carry **accessories**: the factory-fitted 0U gear the +model ships with — typically a pair of vertical PDU strips. Each accessory +names a **0U device type**, a **label** (`PDU-A`), a **rail** (left/right), +a **channel** (front/rear), and the optional offset/span of a +[side mount](#zero-u-side-mounting-vertical-pdus). +When you create a rack with a type picked, tick **Create accessories** and +Danbyte stamps one side-mounted device per accessory, named +`{rack}-{label}` (deduped `-2`, `-3`… if taken), with the device type's +component templates materialised — so a stamped PDU arrives with its real +outlets, ready for power cabling. + +Stamping writes devices, so the checkbox requires permission to **add +devices at the rack's site** — without it the rack is refused wholly (no +half-created rack). The stamp is create-only: re-saving a rack never +duplicates its strips. Deleting a rack type never touches racks or devices +(and is refused with a conflict while racks still use it). + +#### Syncing a rack with its type + +A model changes after its racks are built — the cabinet gains a second PDU, +or its recorded depth was wrong. **Sync type** on a rack's page (the rack +twin of a device's *Sync from type*) compares the two and shows a preview +before touching anything: + +- **Dimensions to copy** — every dimension that drifted from the model, old + value and new. Drift is legitimate (you can edit a rack's dims after + picking a type), so this reports rather than nags. +- **Accessories to add** — strips the type defines that this rack hasn't + got, stamped exactly as they would be at creation. +- **Strips to bring in line** — a strip that *exists* but no longer agrees + with its accessory: the model's device type was swapped, the rail moved, + a channel was set. Applying re-points the existing device rather than + creating a second one. A changed **device type** adds the new type's + components and leaves the ones already there — pruning those is the + *device's* own Sync from type, which is the only place that knows what + the cabling depends on. +- **Not on the type** — stamped-looking strips the type no longer defines. + These are listed and **left alone**: a strip in a live rack is real, + probably cabled hardware, so syncing never deletes one. + +Apply needs **change** on the rack, and the accessory half additionally +needs device-add at its site. Syncing twice does nothing the second time. +`POST /api/racks/{id}/sync-from-type/` is the same operation +(`apply`, plus `dims` / `accessories` to narrow it); without `apply` it is +a dry run that returns the diff. + ## Mount a device in a rack On a device (or in the rack), set: @@ -47,6 +109,27 @@ they're on opposite sides; a full-width device still claims the whole U. The elevation draws the halves side by side, and a shared U counts once in the rack's used-units figure. +### Zero-U side mounting (vertical PDUs) + +A vertical PDU strip bolts to a rack **rail** instead of occupying units. +Give it a **0U device type**, then on the device pick **Side mount** — left +or right rail — plus an optional **offset from the base** (mm) and a +**span** in U (blank draws about three quarters of the rack). Side mounting +replaces U placement: no position and no half-width side. + +A side-mounted strip also picks a **channel** — front or rear — which is +the face it's reachable from. The elevation then draws it on **that +elevation only**, and the 3D room seats it at that depth in the cabinet. +Leave the channel blank and the strip shows on **both** elevations, which +is what strips mounted before this field existed do: we genuinely don't +know which channel they're in, so neither view claims otherwise. + +The elevation grows a slim **rail lane on each side** of the U grid listing +that rail's strips (click one to open it; **+** hangs a new one with the +rack and rail pre-picked), and the 3D room draws the strip on the cabinet's +flank. **0U gear never counts against used units** — including 0U types +parked at a U position, which previously (and wrongly) charged a full unit. + ## Rack elevations The rack's **Overview** draws paired elevations — **front and @@ -86,6 +169,13 @@ three-phase × √3), demand is the racked devices' power-port draws — allocated where you've recorded it, otherwise the nameplate sum (labelled as such). The rack page shows **demand / supply W** and turns red when over. +!!! note "Power numbers changed with the PDU fix" + Devices that **have power outlets** (PDUs — distributors) no longer + contribute their inlet draw to the rack's demand: a PDU's inlet + restates its children's draws, so counting both **double-counted** + every rack that recorded its PDU. If a rack's demand dropped after + upgrading, this fix is why — the new number is the honest one. + Racks can carry a **weight budget** (max weight + unit on the rack form — the floor or rack load rating). Every racked device's *type* weight sums against it, normalised to kg; the rack page shows **used / budget** and turns diff --git a/docs/design/visual-language.md b/docs/design/visual-language.md index 85c1a506..55870955 100644 --- a/docs/design/visual-language.md +++ b/docs/design/visual-language.md @@ -4,8 +4,9 @@ icon: lucide/palette # Visual language -The visual standard for Danbyte is defined in `/CLAUDE.md` at the project root -and the static mockups in `design/` are its source of truth. +The visual standard for Danbyte is defined in `/CLAUDE.md` at the project root. +The running React SPA in `frontend/` is its source of truth — read the shared +primitives before adding UI. ## In one breath @@ -21,7 +22,19 @@ a lot of data fast — typography and spacing serve that, not decoration. - Warning — `amber` - Danger — `red` - Neutral — `zinc` -- **Primary button**: high-contrast neutral (black in light mode, white in dark). **No** brand accent color. +- **Primary / accent**: `--primary` is Danbyte **blue** (`styles.css`), and the + chart ramp is built from it. Earlier drafts of this doc claimed a neutral + primary with no brand accent; the shipped token has been blue for a long time + and the whole product is built and screenshotted against it, so the doc was the + stale side and has been corrected here rather than the token. Blue is for + *primary action and selection only* — it is not decoration, and it never + carries meaning that belongs to a status colour. +- **One selection colour.** Anything meaning "this is the selected thing" uses + `--primary`. Canvas surfaces (3D, floor plan, site map, topology) can't read + CSS variables today and hard-code `#0ea5e9` in ~19 places, which is a + *different* blue — so selection currently reads as two colours depending on + which surface you're on. Until a `readCssVar()` bridge exists, keep new canvas + code on the shared constant rather than adding another literal. - **Links**: dotted underline, not blue. - **Mono font**: for every IP, CIDR, MAC, serial, ID, UUID, custom-field key. - **Tabular nums**: on every counter, percentage, timestamp. @@ -33,29 +46,187 @@ badge, tag, table, dropdown, etc). ## Where to look -- `design/index.html` — token gallery + links to the four mockup pages -- `design/prefixes.html` — canonical list-page mockup -- `design/devices.html` — older list mockup (pending redesign) -- `design/ip-detail.html` — detail page mockup -- `design/device-detail.html` — detail page with tabs + rack visualisation -- `design/tokens.css` — `.ck` checkbox, `.num`, table stripes, `
` resets -- `design/theme.js` — persistent dark/light toggle (`localStorage['danbyte-theme']`) +- `frontend/src/styles.css` — the active design tokens and global styling +- `frontend/src/components/ui/` — the shadcn primitives (button, badge, input, + checkbox, select, command, popover, dialog, …) +- `frontend/src/components/forms/` — the form-field layer built on those + primitives, re-exported from one barrel (`@/components/forms`) +- `frontend/src/components/` — shared and domain components (`DataTable`, + `ListPageShell`, `DetailShell`, `KvCard`, `StatusBadge`, `ObjectPicker`, …) +- `docs/architecture/shadcn-tokens.md` — the token/variable reference -## Component patterns extracted so far +## Never hand-roll a control -| Pattern | In template | +Every interactive control comes from the primitives above. A native +``, `` | +| Bare checkbox (table cell, list row) | `Checkbox` from `@/components/ui/checkbox` | `` | +| Dropdown of fixed options | `FormSelect`, or `Select` for an unlabelled one | `` with many `