Skip to content

Nautobot Floor Plan API Package

nautobot_floor_plan.api

REST API module for nautobot_floor_plan app.

serializers

API serializers for nautobot_floor_plan.

ConvertToFreeformResultSerializer

Bases: Serializer

Result payload for the convert_to_freeform action.

Source code in nautobot_floor_plan/api/serializers.py
class ConvertToFreeformResultSerializer(serializers.Serializer):  # pylint: disable=abstract-method
    """Result payload for the convert_to_freeform action."""

    placement_mode = serializers.CharField()
    tiles_seeded = serializers.IntegerField()
    tiles_skipped = serializers.IntegerField()
    tiles_total = serializers.IntegerField()

FloorPlanCustomAxisLabelSerializer

Bases: NautobotModelSerializer, TaggedModelSerializerMixin

FloorPlanCustomAxisLabel Serializer.

Source code in nautobot_floor_plan/api/serializers.py
class FloorPlanCustomAxisLabelSerializer(NautobotModelSerializer, TaggedModelSerializerMixin):
    """FloorPlanCustomAxisLabel Serializer."""

    class Meta:
        """Meta attributes."""

        model = models.FloorPlanCustomAxisLabel
        fields = "__all__"
Meta

Meta attributes.

Source code in nautobot_floor_plan/api/serializers.py
class Meta:
    """Meta attributes."""

    model = models.FloorPlanCustomAxisLabel
    fields = "__all__"

FloorPlanObjectTypeSerializer

Bases: NautobotModelSerializer, TaggedModelSerializerMixin

FloorPlanObjectType Serializer.

Source code in nautobot_floor_plan/api/serializers.py
class FloorPlanObjectTypeSerializer(NautobotModelSerializer, TaggedModelSerializerMixin):
    """FloorPlanObjectType Serializer."""

    class Meta:
        """Meta attributes."""

        model = models.FloorPlanObjectType
        fields = "__all__"
Meta

Meta attributes.

Source code in nautobot_floor_plan/api/serializers.py
class Meta:
    """Meta attributes."""

    model = models.FloorPlanObjectType
    fields = "__all__"

FloorPlanSerializer

Bases: NautobotModelSerializer, TaggedModelSerializerMixin

FloorPlan Serializer.

Source code in nautobot_floor_plan/api/serializers.py
class FloorPlanSerializer(NautobotModelSerializer, TaggedModelSerializerMixin):  # pylint: disable=too-many-ancestors
    """FloorPlan Serializer."""

    bg_x = serializers.FloatField(required=False, allow_null=True, validators=[validate_finite])
    bg_y = serializers.FloatField(required=False, allow_null=True, validators=[validate_finite])
    bg_width = serializers.FloatField(required=False, allow_null=True, validators=[validate_finite])
    bg_height = serializers.FloatField(required=False, allow_null=True, validators=[validate_finite])
    bg_rotation = serializers.FloatField(required=False, validators=[validate_finite])

    class Meta:
        """Meta attributes."""

        model = models.FloorPlan
        fields = "__all__"

    def update(self, instance, validated_data):
        """Persist a calibration/opacity-only change without re-running full plan validation.

        Dragging the blueprint PATCHes only ``bg_*``/opacity; routing that through ``full_clean`` could
        reject a pure reposition on unrelated state (e.g. placement_mode), so those writes are direct.
        """
        if validated_data and set(validated_data).issubset(CALIBRATION_FIELDS):
            for field, value in validated_data.items():
                setattr(instance, field, value)
            instance.save(update_fields=list(validated_data))
            return instance
        return super().update(instance, validated_data)
Meta

Meta attributes.

Source code in nautobot_floor_plan/api/serializers.py
class Meta:
    """Meta attributes."""

    model = models.FloorPlan
    fields = "__all__"
update(instance, validated_data)

Persist a calibration/opacity-only change without re-running full plan validation.

Dragging the blueprint PATCHes only bg_*/opacity; routing that through full_clean could reject a pure reposition on unrelated state (e.g. placement_mode), so those writes are direct.

Source code in nautobot_floor_plan/api/serializers.py
def update(self, instance, validated_data):
    """Persist a calibration/opacity-only change without re-running full plan validation.

    Dragging the blueprint PATCHes only ``bg_*``/opacity; routing that through ``full_clean`` could
    reject a pure reposition on unrelated state (e.g. placement_mode), so those writes are direct.
    """
    if validated_data and set(validated_data).issubset(CALIBRATION_FIELDS):
        for field, value in validated_data.items():
            setattr(instance, field, value)
        instance.save(update_fields=list(validated_data))
        return instance
    return super().update(instance, validated_data)

FloorPlanTilePlacementSerializer

Bases: Serializer

Input-only serializer that places any registered object type at a normalized position.

Deliberately a plain Serializer (not a ModelSerializer) so the generic API test harness never round-trips it and so the placement fields stay off the main tile serializer's read-only surface.

Source code in nautobot_floor_plan/api/serializers.py
class FloorPlanTilePlacementSerializer(serializers.Serializer):
    """Input-only serializer that places any registered object type at a normalized position.

    Deliberately a plain Serializer (not a ModelSerializer) so the generic API test harness never
    round-trips it and so the placement fields stay off the main tile serializer's read-only surface.
    """

    floor_plan = serializers.PrimaryKeyRelatedField(queryset=models.FloorPlan.objects.all())
    placed_content_type = serializers.PrimaryKeyRelatedField(queryset=ContentType.objects.all())
    placed_object_id = serializers.UUIDField()
    pos_x = serializers.FloatField(min_value=0, max_value=1, validators=[validate_finite])
    pos_y = serializers.FloatField(min_value=0, max_value=1, validators=[validate_finite])
    width = serializers.FloatField(required=False, allow_null=True, min_value=0, validators=[validate_finite])
    height = serializers.FloatField(required=False, allow_null=True, min_value=0, validators=[validate_finite])
    rotation = serializers.FloatField(required=False, default=0, validators=[validate_finite])
    status = serializers.PrimaryKeyRelatedField(queryset=Status.objects.all(), required=False, allow_null=True)

    def __init__(self, *args, **kwargs):
        """Scope the content type choices to registered placeable types."""
        super().__init__(*args, **kwargs)
        self.fields["placed_content_type"].queryset = registry.allowed_content_types()

    def validate(self, attrs):
        """Enforce permissions, registration, resolvable/matching location, and single placement."""
        request = self.context.get("request")
        user = getattr(request, "user", None)
        plan = attrs["floor_plan"]
        if user is not None and not models.FloorPlan.objects.restrict(user, "change").filter(pk=plan.pk).exists():
            raise serializers.ValidationError({"floor_plan": "You do not have permission to change this floor plan."})

        model_cls = attrs["placed_content_type"].model_class()
        if model_cls is None:
            raise serializers.ValidationError({"placed_content_type": "Unknown content type."})
        queryset = model_cls.objects.all()
        if user is not None and hasattr(queryset, "restrict"):
            queryset = queryset.restrict(user, "view")
        obj = queryset.filter(pk=attrs["placed_object_id"]).first()
        if obj is None:
            raise serializers.ValidationError(
                {"placed_object_id": "Object does not exist or you do not have permission to view it."}
            )
        if registry.resolve(obj) is None:
            raise serializers.ValidationError(
                {"placed_content_type": "This object type is not registered as placeable."}
            )
        location = registry.resolve_location(obj)
        if location is None:
            raise serializers.ValidationError({"placed_object_id": f"{obj} has no resolvable Location."})
        if location != plan.location:
            raise serializers.ValidationError({"placed_object_id": f"{obj} must belong to Location {plan.location}."})
        if models.FloorPlanTile.objects.for_object(obj).exists():
            raise serializers.ValidationError({"placed_object_id": f"{obj} is already placed on a floor plan."})
        attrs["_object"] = obj
        return attrs

    def create(self, validated_data):
        """Create a pure-freeform tile placing the object (null grid origins)."""
        tile = models.FloorPlanTile(
            floor_plan=validated_data["floor_plan"],
            placed_content_type=validated_data["placed_content_type"],
            placed_object_id=validated_data["placed_object_id"],
            pos_x=validated_data["pos_x"],
            pos_y=validated_data["pos_y"],
            width=validated_data.get("width"),
            height=validated_data.get("height"),
            rotation=validated_data.get("rotation") or 0,
            status=validated_data.get("status") or _default_tile_status(),
        )
        try:
            tile.validated_save()
        except DjangoValidationError as exc:
            raise serializers.ValidationError(getattr(exc, "message_dict", None) or exc.messages)
        except IntegrityError as exc:
            raise serializers.ValidationError(
                {"placed_object_id": "This object was just placed by another request."}
            ) from exc
        return tile
__init__(*args, **kwargs)

Scope the content type choices to registered placeable types.

Source code in nautobot_floor_plan/api/serializers.py
def __init__(self, *args, **kwargs):
    """Scope the content type choices to registered placeable types."""
    super().__init__(*args, **kwargs)
    self.fields["placed_content_type"].queryset = registry.allowed_content_types()
create(validated_data)

Create a pure-freeform tile placing the object (null grid origins).

Source code in nautobot_floor_plan/api/serializers.py
def create(self, validated_data):
    """Create a pure-freeform tile placing the object (null grid origins)."""
    tile = models.FloorPlanTile(
        floor_plan=validated_data["floor_plan"],
        placed_content_type=validated_data["placed_content_type"],
        placed_object_id=validated_data["placed_object_id"],
        pos_x=validated_data["pos_x"],
        pos_y=validated_data["pos_y"],
        width=validated_data.get("width"),
        height=validated_data.get("height"),
        rotation=validated_data.get("rotation") or 0,
        status=validated_data.get("status") or _default_tile_status(),
    )
    try:
        tile.validated_save()
    except DjangoValidationError as exc:
        raise serializers.ValidationError(getattr(exc, "message_dict", None) or exc.messages)
    except IntegrityError as exc:
        raise serializers.ValidationError(
            {"placed_object_id": "This object was just placed by another request."}
        ) from exc
    return tile
validate(attrs)

Enforce permissions, registration, resolvable/matching location, and single placement.

Source code in nautobot_floor_plan/api/serializers.py
def validate(self, attrs):
    """Enforce permissions, registration, resolvable/matching location, and single placement."""
    request = self.context.get("request")
    user = getattr(request, "user", None)
    plan = attrs["floor_plan"]
    if user is not None and not models.FloorPlan.objects.restrict(user, "change").filter(pk=plan.pk).exists():
        raise serializers.ValidationError({"floor_plan": "You do not have permission to change this floor plan."})

    model_cls = attrs["placed_content_type"].model_class()
    if model_cls is None:
        raise serializers.ValidationError({"placed_content_type": "Unknown content type."})
    queryset = model_cls.objects.all()
    if user is not None and hasattr(queryset, "restrict"):
        queryset = queryset.restrict(user, "view")
    obj = queryset.filter(pk=attrs["placed_object_id"]).first()
    if obj is None:
        raise serializers.ValidationError(
            {"placed_object_id": "Object does not exist or you do not have permission to view it."}
        )
    if registry.resolve(obj) is None:
        raise serializers.ValidationError(
            {"placed_content_type": "This object type is not registered as placeable."}
        )
    location = registry.resolve_location(obj)
    if location is None:
        raise serializers.ValidationError({"placed_object_id": f"{obj} has no resolvable Location."})
    if location != plan.location:
        raise serializers.ValidationError({"placed_object_id": f"{obj} must belong to Location {plan.location}."})
    if models.FloorPlanTile.objects.for_object(obj).exists():
        raise serializers.ValidationError({"placed_object_id": f"{obj} is already placed on a floor plan."})
    attrs["_object"] = obj
    return attrs

FloorPlanTileSerializer

Bases: NautobotModelSerializer, TaggedModelSerializerMixin

FloorPlanTile Serializer.

Source code in nautobot_floor_plan/api/serializers.py
class FloorPlanTileSerializer(NautobotModelSerializer, TaggedModelSerializerMixin):
    """FloorPlanTile Serializer."""

    pos_x = serializers.FloatField(required=False, allow_null=True, min_value=0, max_value=1, validators=[validate_finite])
    pos_y = serializers.FloatField(required=False, allow_null=True, min_value=0, max_value=1, validators=[validate_finite])
    width = serializers.FloatField(required=False, allow_null=True, min_value=0, validators=[validate_finite])
    height = serializers.FloatField(required=False, allow_null=True, min_value=0, validators=[validate_finite])
    rotation = serializers.FloatField(required=False, validators=[validate_finite])

    class Meta:
        """Meta attributes."""

        model = models.FloorPlanTile
        fields = "__all__"
        # Generic placement is mirrored from the typed FKs during the transition and exposed read-only;
        # permissioned direct writes land with the drag/place flow in a later wave.
        read_only_fields = ["placed_content_type", "placed_object_id", "placed_label"]

    def update(self, instance, validated_data):
        """Persist a geometry-only change without re-running full object validation.

        A drag/calibrate PATCH touches only position fields. Routing it through ``full_clean`` would
        re-validate unrelated object assignments against their current (possibly since-changed) state
        and reject a pure reposition, so geometry-only writes save the affected fields directly.
        """
        if validated_data and set(validated_data).issubset(TILE_GEOMETRY_FIELDS):
            for field, value in validated_data.items():
                setattr(instance, field, value)
            instance.save(update_fields=list(validated_data))
            return instance
        return super().update(instance, validated_data)
Meta

Meta attributes.

Source code in nautobot_floor_plan/api/serializers.py
class Meta:
    """Meta attributes."""

    model = models.FloorPlanTile
    fields = "__all__"
    # Generic placement is mirrored from the typed FKs during the transition and exposed read-only;
    # permissioned direct writes land with the drag/place flow in a later wave.
    read_only_fields = ["placed_content_type", "placed_object_id", "placed_label"]
update(instance, validated_data)

Persist a geometry-only change without re-running full object validation.

A drag/calibrate PATCH touches only position fields. Routing it through full_clean would re-validate unrelated object assignments against their current (possibly since-changed) state and reject a pure reposition, so geometry-only writes save the affected fields directly.

Source code in nautobot_floor_plan/api/serializers.py
def update(self, instance, validated_data):
    """Persist a geometry-only change without re-running full object validation.

    A drag/calibrate PATCH touches only position fields. Routing it through ``full_clean`` would
    re-validate unrelated object assignments against their current (possibly since-changed) state
    and reject a pure reposition, so geometry-only writes save the affected fields directly.
    """
    if validated_data and set(validated_data).issubset(TILE_GEOMETRY_FIELDS):
        for field, value in validated_data.items():
            setattr(instance, field, value)
        instance.save(update_fields=list(validated_data))
        return instance
    return super().update(instance, validated_data)

PlaceableTypeSerializer

Bases: Serializer

Read-only schema for a registered placeable type (drives the object picker).

Source code in nautobot_floor_plan/api/serializers.py
class PlaceableTypeSerializer(serializers.Serializer):  # pylint: disable=abstract-method
    """Read-only schema for a registered placeable type (drives the object picker)."""

    key = serializers.CharField()
    content_type = serializers.CharField()
    label = serializers.CharField()
    icon = serializers.CharField(allow_null=True)
    color = serializers.CharField(allow_null=True)
    legend_order = serializers.IntegerField()
    object_source = serializers.DictField()

validate_finite(value)

Reject NaN/Infinity, which slip past DRF min_value/max_value comparisons.

Source code in nautobot_floor_plan/api/serializers.py
def validate_finite(value):
    """Reject NaN/Infinity, which slip past DRF min_value/max_value comparisons."""
    if value is not None and not math.isfinite(value):
        raise serializers.ValidationError("Must be a finite number.")

urls

Django API urlpatterns declaration for nautobot_floor_plan app.

views

API views for nautobot_floor_plan.

FloorPlanObjectTypeViewSet

Bases: NautobotModelViewSet

FloorPlanObjectType viewset.

Source code in nautobot_floor_plan/api/views.py
class FloorPlanObjectTypeViewSet(NautobotModelViewSet):
    """FloorPlanObjectType viewset."""

    queryset = models.FloorPlanObjectType.objects.all()
    serializer_class = serializers.FloorPlanObjectTypeSerializer
    filterset_class = filters.FloorPlanObjectTypeFilterSet

FloorPlanTileViewSet

Bases: NautobotModelViewSet

FloorPlanTile viewset.

Source code in nautobot_floor_plan/api/views.py
class FloorPlanTileViewSet(NautobotModelViewSet):
    """FloorPlanTile viewset."""

    queryset = models.FloorPlanTile.objects.all()
    serializer_class = serializers.FloorPlanTileSerializer
    filterset_class = filters.FloorPlanTileFilterSet

    @extend_schema(
        request=serializers.FloorPlanTilePlacementSerializer,
        responses={201: serializers.FloorPlanTileSerializer},
    )
    @action(detail=False, methods=["post"])
    def place(self, request):
        """Place any registered object type on a floor plan at a normalized position."""
        from nautobot_floor_plan.placement.config import refresh_if_stale  # noqa: PLC0415

        refresh_if_stale()
        serializer = serializers.FloorPlanTilePlacementSerializer(data=request.data, context={"request": request})
        serializer.is_valid(raise_exception=True)
        tile = serializer.save()
        return Response(
            serializers.FloorPlanTileSerializer(tile, context={"request": request}).data,
            status=HTTP_201_CREATED,
        )
place(request)

Place any registered object type on a floor plan at a normalized position.

Source code in nautobot_floor_plan/api/views.py
@extend_schema(
    request=serializers.FloorPlanTilePlacementSerializer,
    responses={201: serializers.FloorPlanTileSerializer},
)
@action(detail=False, methods=["post"])
def place(self, request):
    """Place any registered object type on a floor plan at a normalized position."""
    from nautobot_floor_plan.placement.config import refresh_if_stale  # noqa: PLC0415

    refresh_if_stale()
    serializer = serializers.FloorPlanTilePlacementSerializer(data=request.data, context={"request": request})
    serializer.is_valid(raise_exception=True)
    tile = serializer.save()
    return Response(
        serializers.FloorPlanTileSerializer(tile, context={"request": request}).data,
        status=HTTP_201_CREATED,
    )

FloorPlanViewSet

Bases: NautobotModelViewSet

FloorPlan viewset.

Source code in nautobot_floor_plan/api/views.py
class FloorPlanViewSet(NautobotModelViewSet):  # pylint: disable=too-many-ancestors
    """FloorPlan viewset."""

    queryset = models.FloorPlan.objects.all()
    serializer_class = serializers.FloorPlanSerializer
    filterset_class = filters.FloorPlanFilterSet

    @extend_schema(exclude=True)
    @action(detail=True)
    @xframe_options_sameorigin
    def svg(self, request, *, pk):
        """SVG representation of a FloorPlan."""
        # Restrict to objects the caller may view (enforces object-level permissions, not just model).
        floor_plan = get_object_or_404(self.queryset.restrict(request.user, "view"), pk=pk)
        drawing = floor_plan.get_svg(user=request.user, base_url=request.build_absolute_uri("/"), request=request)
        return HttpResponse(drawing.tostring(), content_type="image/svg+xml; charset=utf-8")

    @extend_schema(request=None, responses={200: serializers.ConvertToFreeformResultSerializer})
    @action(detail=True, methods=["post"], url_path="convert-to-freeform")
    def convert_to_freeform(self, request, *, pk):
        """Seed freeform coordinates for this plan's grid tiles and (optionally) switch it to freeform.

        Idempotent: pass ``force=true`` to re-seed already-positioned tiles. ``set_mode=false`` seeds
        without changing the placement mode.
        """
        # Object-level "change" restriction; a caller constrained to other objects gets a 404 here.
        floor_plan = get_object_or_404(self.queryset.restrict(request.user, "change"), pk=pk)
        force = str(request.data.get("force", "")).lower() in ("true", "1", "yes", "on") or request.data.get(
            "force"
        ) is True
        set_mode = request.data.get("set_mode", True)
        grid_tiles = floor_plan.tiles.filter(x_origin__isnull=False).count()
        with transaction.atomic():
            modified = floor_plan.convert_to_freeform(force=force, save=True)
            if set_mode and floor_plan.placement_mode != PlacementModeChoices.FREEFORM:
                floor_plan.placement_mode = PlacementModeChoices.FREEFORM
                floor_plan.validated_save()
        return Response(
            {
                "placement_mode": floor_plan.placement_mode,
                "tiles_seeded": len(modified),
                "tiles_skipped": grid_tiles - len(modified),
                "tiles_total": floor_plan.tiles.count(),
            }
        )

    @extend_schema(responses={200: serializers.PlaceableTypeSerializer(many=True)})
    @action(detail=True, url_path="placeable-types")
    def placeable_types(self, request, *, pk):
        """List the object types that can be placed on this plan, scoped to its location."""
        from nautobot_floor_plan.placement.config import refresh_if_stale  # noqa: PLC0415

        refresh_if_stale()
        floor_plan = get_object_or_404(self.queryset.restrict(request.user, "view"), pk=pk)
        rows = [
            {
                "key": placement.key,
                "content_type": placement.key,
                "label": placement.label,
                "icon": placement.icon,
                "color": placement.color,
                "legend_order": placement.legend_order,
                "object_source": _object_source_for(placement, floor_plan.location),
            }
            for placement in registry.base_types()
        ]
        rows.sort(key=lambda row: (row["legend_order"], row["label"]))
        return Response(
            {"floor_plan": floor_plan.pk, "location": floor_plan.location.pk, "placeable_types": rows}
        )
convert_to_freeform(request, *, pk)

Seed freeform coordinates for this plan's grid tiles and (optionally) switch it to freeform.

Idempotent: pass force=true to re-seed already-positioned tiles. set_mode=false seeds without changing the placement mode.

Source code in nautobot_floor_plan/api/views.py
@extend_schema(request=None, responses={200: serializers.ConvertToFreeformResultSerializer})
@action(detail=True, methods=["post"], url_path="convert-to-freeform")
def convert_to_freeform(self, request, *, pk):
    """Seed freeform coordinates for this plan's grid tiles and (optionally) switch it to freeform.

    Idempotent: pass ``force=true`` to re-seed already-positioned tiles. ``set_mode=false`` seeds
    without changing the placement mode.
    """
    # Object-level "change" restriction; a caller constrained to other objects gets a 404 here.
    floor_plan = get_object_or_404(self.queryset.restrict(request.user, "change"), pk=pk)
    force = str(request.data.get("force", "")).lower() in ("true", "1", "yes", "on") or request.data.get(
        "force"
    ) is True
    set_mode = request.data.get("set_mode", True)
    grid_tiles = floor_plan.tiles.filter(x_origin__isnull=False).count()
    with transaction.atomic():
        modified = floor_plan.convert_to_freeform(force=force, save=True)
        if set_mode and floor_plan.placement_mode != PlacementModeChoices.FREEFORM:
            floor_plan.placement_mode = PlacementModeChoices.FREEFORM
            floor_plan.validated_save()
    return Response(
        {
            "placement_mode": floor_plan.placement_mode,
            "tiles_seeded": len(modified),
            "tiles_skipped": grid_tiles - len(modified),
            "tiles_total": floor_plan.tiles.count(),
        }
    )
placeable_types(request, *, pk)

List the object types that can be placed on this plan, scoped to its location.

Source code in nautobot_floor_plan/api/views.py
@extend_schema(responses={200: serializers.PlaceableTypeSerializer(many=True)})
@action(detail=True, url_path="placeable-types")
def placeable_types(self, request, *, pk):
    """List the object types that can be placed on this plan, scoped to its location."""
    from nautobot_floor_plan.placement.config import refresh_if_stale  # noqa: PLC0415

    refresh_if_stale()
    floor_plan = get_object_or_404(self.queryset.restrict(request.user, "view"), pk=pk)
    rows = [
        {
            "key": placement.key,
            "content_type": placement.key,
            "label": placement.label,
            "icon": placement.icon,
            "color": placement.color,
            "legend_order": placement.legend_order,
            "object_source": _object_source_for(placement, floor_plan.location),
        }
        for placement in registry.base_types()
    ]
    rows.sort(key=lambda row: (row["legend_order"], row["label"]))
    return Response(
        {"floor_plan": floor_plan.pk, "location": floor_plan.location.pk, "placeable_types": rows}
    )
svg(request, *, pk)

SVG representation of a FloorPlan.

Source code in nautobot_floor_plan/api/views.py
@extend_schema(exclude=True)
@action(detail=True)
@xframe_options_sameorigin
def svg(self, request, *, pk):
    """SVG representation of a FloorPlan."""
    # Restrict to objects the caller may view (enforces object-level permissions, not just model).
    floor_plan = get_object_or_404(self.queryset.restrict(request.user, "view"), pk=pk)
    drawing = floor_plan.get_svg(user=request.user, base_url=request.build_absolute_uri("/"), request=request)
    return HttpResponse(drawing.tostring(), content_type="image/svg+xml; charset=utf-8")