@extras_features(
"custom_fields",
# "custom_links", Not really needed since this doesn't have distinct views.
"custom_validators",
# "export_templates", Not really useful here
"graphql",
"relationships",
"statuses",
"webhooks",
)
# TBD: Remove after releasing pylint-nautobot v0.3.0
# pylint: disable-next=nb-string-field-blank-null
class FloorPlanTile(PrimaryModel):
"""Model representing a single rectangular "tile" within a FloorPlan, its status, and any Rack that it contains."""
status = StatusField(blank=False, null=False)
floor_plan = models.ForeignKey(to=FloorPlan, on_delete=models.CASCADE, related_name="tiles")
# TODO: for efficiency we could consider using something like GeoDjango, rather than inventing geometry from
# first principles, but since that requires changing settings.DATABASES and installing libraries, avoid it for now.
x_origin = models.PositiveSmallIntegerField(validators=[MinValueValidator(1)])
y_origin = models.PositiveSmallIntegerField(validators=[MinValueValidator(1)])
x_size = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1)],
default=1,
help_text="Number of tile spaces that this spans horizontally",
)
y_size = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1)],
default=1,
help_text="Number of tile spaces that this spans vertically",
)
rack = models.OneToOneField(
to="dcim.Rack",
on_delete=models.CASCADE,
blank=True,
null=True,
related_name="floor_plan_tile",
)
rack_group = models.ForeignKey(
to="dcim.RackGroup",
on_delete=models.CASCADE,
blank=True,
null=True,
related_name="rack_groups",
)
rack_orientation = models.CharField(
max_length=10,
choices=RackOrientationChoices,
blank=True,
help_text="Direction the rack's front is facing on the floor plan",
)
allocation_type = models.CharField(
choices=AllocationTypeChoices,
max_length=10,
blank=True,
help_text="Assigns a type of either Rack or RackGroup to a tile",
)
on_group_tile = models.BooleanField(
default=False, blank=True, help_text="Determines if a tile is placed on top of another tile"
)
class Meta:
"""Metaclass attributes."""
ordering = ["floor_plan", "y_origin", "x_origin"]
unique_together = ["floor_plan", "x_origin", "y_origin", "allocation_type"]
def allocation_type_assignment(self):
"""Assign the appropriate tile allocation type when saving in clean."""
# Assign Allocation type based off of Tile Assignemnt
if self.rack_group is not None or self.status is not None:
self.allocation_type = AllocationTypeChoices.RACKGROUP
if self.rack is not None:
self.allocation_type = AllocationTypeChoices.RACK
self.on_group_tile = False
@property
def bounds(self):
"""Get the tuple representing the set of grid spaces occupied by this FloorPlanTile.
This function also serves to return the allocation_type and rack_group of the underlying tile
to ensure non-like allocation_types can overlap and non-like rack_group are unable to overlap.
"""
return (
self.x_origin,
self.y_origin,
self.x_origin + self.x_size - 1,
self.y_origin + self.y_size - 1,
self.allocation_type,
self.rack_group,
)
def clean(self):
"""
Validate parameters above and beyond what the database can provide.
- Ensure that the bounds of this FloorPlanTile lie within the parent FloorPlan's bounds.
- Ensure that the Rack if any belongs to the correct Location.
- Ensure that this FloorPlanTile doesn't overlap with any other FloorPlanTile in this FloorPlan.
"""
super().clean()
FloorPlanTile.allocation_type_assignment(self)
def group_tile_bounds(rack, rack_group):
"""Validate the overlapping of group tiles."""
if rack is not None:
# Set the tile rack_group equal to the rack.rack_group if the rack is in a rack_group
if rack.rack_group is not None:
rack_group = rack.rack_group
self.rack_group = rack.rack_group
if x_max > ox_max or x_min < ox_min:
raise ValidationError(
{f"Rack {self.rack} must not extend beyond the boundary of the defined group tiles"}
)
if y_max > oy_max or y_min < oy_min:
raise ValidationError(
{f"Rack {self.rack} must not extend beyond the boundary of the defined group tiles"}
)
self.on_group_tile = True
if orack_group is not None:
if orack_group != rack_group or rack.rack_group != orack_group:
# Is tile assigned to a rack_group? Racks must be assigned to the same rack_group
raise ValidationError({"rack_group": f"Rack {self.rack} must belong to {orack_group}"})
if rack is None:
# RACKGROUP tiles can grow and shrink but not overlap other RACKGROUP tiles or Racks that are not assigned to the correct rackgroup
if x_max > ox_max or x_min < ox_min:
if oallocation_type == AllocationTypeChoices.RACK and orack_group != rack_group:
raise ValidationError("Tile overlaps a Rack that is not in the specified RackGroup")
if y_max > oy_max or y_min < oy_min:
if oallocation_type == AllocationTypeChoices.RACK and orack_group != rack_group:
raise ValidationError("Tile overlaps a Rack that is not in the specified RackGroup")
if allocation_type == oallocation_type:
raise ValidationError("Tile overlaps with another defined tile.")
# x <= 0, y <= 0 are covered by the base field definitions
if self.x_origin > self.floor_plan.x_size:
raise ValidationError({"x_origin": f"Too large for {self.floor_plan}"})
if self.y_origin > self.floor_plan.y_size:
raise ValidationError({"y_origin": f"Too large for {self.floor_plan}"})
if self.x_origin + self.x_size - 1 > self.floor_plan.x_size:
raise ValidationError({"x_size": f"Extends beyond the edge of {self.floor_plan}"})
if self.y_origin + self.y_size - 1 > self.floor_plan.y_size:
raise ValidationError({"y_size": f"Extends beyond the edge of {self.floor_plan}"})
if self.rack is not None:
if self.rack.location != self.floor_plan.location:
raise ValidationError(
{"rack": f"Must belong to Location {self.floor_plan.location}, not Location {self.rack.location}"}
)
# Check for overlapping tiles.
# TODO: this would be a lot more efficient using something like GeoDjango,
# but since this is only checked at write time it's acceptable for now.
x_min, y_min, x_max, y_max, allocation_type, rack_group = self.bounds
for other in FloorPlanTile.objects.filter(floor_plan=self.floor_plan).exclude(pk=self.pk):
ox_min, oy_min, ox_max, oy_max, oallocation_type, orack_group = other.bounds
# Is either bounds rectangle completely to the right of the other?
if x_min > ox_max or ox_min > x_max:
continue
# Is either bounds rectangle completely below the other?
if y_min > oy_max or oy_min > y_max:
continue
# Are tiles in the same rackgroup?
# If they are in the same rackgroup, do they overlap tiles?
if allocation_type != oallocation_type:
if self.rack is not None:
group_tile_bounds(self.rack, rack_group)
continue
if self.rack is None:
group_tile_bounds(self.rack, rack_group)
continue
# Else they must overlap
raise ValidationError("Tile overlaps with another defined tile.")
def __str__(self):
"""Stringify instance."""
return f"Tile {self.bounds} in {self.floor_plan}"