Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add usage of letters for rendering FP SVG. #79

Merged
merged 16 commits into from
Apr 11, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/79.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added letter grid numbering.
pszulczewski marked this conversation as resolved.
Show resolved Hide resolved
16 changes: 16 additions & 0 deletions docs/admin/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ Once installed, the app needs to be enabled in your Nautobot configuration. The
```python
# In your nautobot_config.py
PLUGINS = ["nautobot_floor_plan"]

# Optionally you can override default settings for config items to make grid numbering like a chessboard (as seen in this example)
pszulczewski marked this conversation as resolved.
Show resolved Hide resolved
PLUGINS_CONFIG = {
"nautobot_floor_plan": {
"grid_x_axis_labels": "letters",
}
}
```

Once the Nautobot configuration is updated, run the Post Upgrade command (`nautobot-server post_upgrade`) to run migrations and clear any cache:
Expand All @@ -53,3 +60,12 @@ sudo systemctl restart nautobot nautobot-worker nautobot-scheduler
```

If the App has been installed successfully, the Nautobot web UI should now show a new "Location Floor Plans" menu item under the "Organization" menu.

## App Configuration

The app behavior can be controlled with the following list of settings:

| Key | Example | Default | Description |
|--------------------|-----------|----------|------------------------------------------------------------------------------------------------------------------------------------------------|
| grid_x_axis_labels | "leters" | "numbers" | Label style for the floor plan gird. Can use numbers or letters in order. This setting will set the default selected value in the create form. |
| grid_y_axis_labels | "numbers" | "numbers" | Label style for the floor plan gird. Can use numbers or letters in order. This setting will set the default selected value in the create form. |
pszulczewski marked this conversation as resolved.
Show resolved Hide resolved
6 changes: 5 additions & 1 deletion nautobot_floor_plan/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from importlib import metadata

from nautobot.apps import NautobotAppConfig
from nautobot_floor_plan import choices

__version__ = metadata.version(__name__)

Expand All @@ -19,7 +20,10 @@ class FloorPlanConfig(NautobotAppConfig):
required_settings = []
min_version = "2.0.0"
max_version = "2.9999"
default_settings = {}
default_settings = {
"grid_x_axis_labels": choices.AxisLabelsChoices.NUMBERS,
"grid_y_axis_labels": choices.AxisLabelsChoices.NUMBERS,
}
caching_config = {}


Expand Down
12 changes: 12 additions & 0 deletions nautobot_floor_plan/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,15 @@ class RackOrientationChoices(ChoiceSet):
(LEFT, "left"),
(RIGHT, "right"),
)


class AxisLabelsChoices(ChoiceSet):
"""Choices for grid numbering style."""

NUMBERS = "numbers"
LETTERS = "letters"

CHOICES = (
(NUMBERS, "Numbers"),
(LETTERS, "Letters"),
)
64 changes: 63 additions & 1 deletion nautobot_floor_plan/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
# pylint: disable=nb-incorrect-base-class

"""Forms for nautobot_floor_plan."""
import re

from django import forms
from django.core.exceptions import ValidationError

from nautobot.dcim.models import Location, Rack
from nautobot.apps.forms import (
Expand All @@ -14,9 +17,11 @@
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
TagFilterField,
add_blank_choice,
)
from nautobot.apps.config import get_app_settings_or_config

from nautobot_floor_plan import models
from nautobot_floor_plan import models, choices, utils


class FloorPlanForm(NautobotModelForm):
Expand All @@ -34,9 +39,17 @@ class Meta:
"y_size",
"tile_width",
"tile_depth",
"x_axis_labels",
"y_axis_labels",
"tags",
]

def __init__(self, *args, **kwargs):
"""Overwrite the constructor to set initial values for select widget."""
super().__init__(*args, **kwargs)
self.initial["x_axis_labels"] = get_app_settings_or_config("nautobot_floor_plan", "grid_x_axis_labels")
self.initial["y_axis_labels"] = get_app_settings_or_config("nautobot_floor_plan", "grid_y_axis_labels")

joewesch marked this conversation as resolved.
Show resolved Hide resolved

class FloorPlanBulkEditForm(TagsBulkEditFormMixin, NautobotBulkEditForm):
"""FloorPlan bulk edit form."""
Expand All @@ -46,6 +59,8 @@ class FloorPlanBulkEditForm(TagsBulkEditFormMixin, NautobotBulkEditForm):
y_size = forms.IntegerField(min_value=1, required=False)
tile_width = forms.IntegerField(min_value=1, required=False)
tile_depth = forms.IntegerField(min_value=1, required=False)
x_axis_labels = forms.ChoiceField(choices=add_blank_choice(choices.AxisLabelsChoices), required=False)
y_axis_labels = forms.ChoiceField(choices=add_blank_choice(choices.AxisLabelsChoices), required=False)

class Meta:
"""Meta attributes."""
Expand All @@ -71,6 +86,8 @@ class FloorPlanTileForm(NautobotModelForm):
rack = DynamicModelChoiceField(
queryset=Rack.objects.all(), required=False, query_params={"nautobot_floor_plan_floor_plan": "$floor_plan"}
)
x_origin = forms.CharField()
y_origin = forms.CharField()

class Meta:
"""Meta attributes."""
Expand All @@ -87,3 +104,48 @@ class Meta:
"rack_orientation",
"tags",
]

def __init__(self, *args, **kwargs):
"""Overwrite the constructor to define grid numbering style."""
super().__init__(*args, **kwargs)
self.x_letters = False
self.y_letters = False

if fp_id := self.initial["floor_plan"] or self.data["floor_plan"]:
fp_obj = self.fields["floor_plan"].queryset.get(id=fp_id)
self.x_letters = fp_obj.x_axis_labels == choices.AxisLabelsChoices.LETTERS
self.y_letters = fp_obj.y_axis_labels == choices.AxisLabelsChoices.LETTERS

if self.instance.x_origin or self.instance.y_origin:
if self.x_letters:
self.initial["x_origin"] = utils.col_num_to_letter(self.instance.x_origin)
if self.y_letters:
self.initial["x_origin"] = utils.col_num_to_letter(self.instance.y_origin)
pszulczewski marked this conversation as resolved.
Show resolved Hide resolved

def letter_validator(self, value, axis):
"""Validate that origin uses combination of letters."""
if not re.search(r"[A-Z]+", value):
raise ValidationError(f"{axis} origin should use capital letters.")

def number_validator(self, value, axis):
"""Validate that origin uses combination of numbers."""
if not re.search(r"\d+", value):
raise ValidationError(f"{axis} origin should use numbers.")

def clean_x_origin(self):
"""Validate input and convert x_origin to an integer."""
x_origin = self.cleaned_data.get("x_origin")
if self.x_letters:
self.letter_validator(x_origin, "X")
return utils.column_letter_to_num(x_origin)
self.number_validator(x_origin, "X")
return int(x_origin)

def clean_y_origin(self):
"""Validate input and convert y_origin to an integer."""
y_origin = self.cleaned_data.get("y_origin")
if self.y_letters:
self.letter_validator(y_origin, "Y")
return utils.column_letter_to_num(y_origin)
self.number_validator(y_origin, "Y")
return int(y_origin)
22 changes: 22 additions & 0 deletions nautobot_floor_plan/migrations/0004_auto_20240321_1438.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 3.2.21 on 2024-03-21 14:38

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("nautobot_floor_plan", "0003_auto_20230908_1339"),
]

operations = [
migrations.AddField(
model_name="floorplan",
name="x_axis_labels",
field=models.CharField(default="numbers", max_length=10),
),
migrations.AddField(
model_name="floorplan",
name="y_axis_labels",
field=models.CharField(default="numbers", max_length=10),
),
]
14 changes: 13 additions & 1 deletion nautobot_floor_plan/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from nautobot.apps.models import PrimaryModel
from nautobot.apps.models import StatusField

from nautobot_floor_plan.choices import RackOrientationChoices
from nautobot_floor_plan.choices import RackOrientationChoices, AxisLabelsChoices
from nautobot_floor_plan.svg import FloorPlanSVG


Expand Down Expand Up @@ -53,6 +53,18 @@ class FloorPlan(PrimaryModel):
default=100,
help_text='Relative depth of each "tile" in the floor plan (cm, inches, etc.)',
)
x_axis_labels = models.CharField(
max_length=10,
choices=AxisLabelsChoices,
default=AxisLabelsChoices.NUMBERS,
help_text="Grid numbering of X axis (horizontal).",
pszulczewski marked this conversation as resolved.
Show resolved Hide resolved
)
y_axis_labels = models.CharField(
max_length=10,
choices=AxisLabelsChoices,
default=AxisLabelsChoices.NUMBERS,
help_text="Grid numbering of Y axis (vertical).",
pszulczewski marked this conversation as resolved.
Show resolved Hide resolved
)

class Meta:
"""Metaclass attributes."""
Expand Down
15 changes: 10 additions & 5 deletions nautobot_floor_plan/svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@

from nautobot.core.templatetags.helpers import fgcolor

from nautobot_floor_plan.choices import RackOrientationChoices
from nautobot_floor_plan.choices import RackOrientationChoices, AxisLabelsChoices
from nautobot_floor_plan.utils import col_num_to_letter


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -109,9 +110,10 @@ def _draw_grid(self, drawing):
)
# Axis labels
for x in range(1, self.floor_plan.x_size + 1):
label = col_num_to_letter(x) if self.floor_plan.x_axis_labels == AxisLabelsChoices.LETTERS else str(x)
drawing.add(
drawing.text(
str(x),
label,
insert=(
(x - 0.5) * self.GRID_SIZE_X + self.GRID_OFFSET,
self.BORDER_WIDTH + self.TEXT_LINE_HEIGHT / 2,
Expand All @@ -120,9 +122,10 @@ def _draw_grid(self, drawing):
)
)
for y in range(1, self.floor_plan.y_size + 1):
label = col_num_to_letter(y) if self.floor_plan.y_axis_labels == AxisLabelsChoices.LETTERS else str(y)
drawing.add(
drawing.text(
str(y),
label,
insert=(
self.BORDER_WIDTH + self.TEXT_LINE_HEIGHT / 2,
(y - 0.5) * self.GRID_SIZE_Y + self.GRID_OFFSET,
Expand All @@ -132,13 +135,15 @@ def _draw_grid(self, drawing):
)

# Links to populate tiles
y_letters = self.floor_plan.y_axis_labels == AxisLabelsChoices.LETTERS
x_letters = self.floor_plan.x_axis_labels == AxisLabelsChoices.LETTERS
for y in range(1, self.floor_plan.y_size + 1):
for x in range(1, self.floor_plan.x_size + 1):
query_params = urlencode(
{
"floor_plan": self.floor_plan.pk,
"x_origin": x,
"y_origin": y,
"x_origin": col_num_to_letter(x) if x_letters else x,
"y_origin": col_num_to_letter(y) if y_letters else y,
"return_url": self.return_url,
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@
<td>Tile Depth (Relative Units)</td>
<td>{{ object.tile_depth }}</td>
</tr>
<tr>
<td>X Axis Numbering</td>
pszulczewski marked this conversation as resolved.
Show resolved Hide resolved
<td>{{ object.x_axis_labels }}</td>
</tr>
<tr>
<td>Y Axis Numbering</td>
pszulczewski marked this conversation as resolved.
Show resolved Hide resolved
<td>{{ object.y_axis_labels }}</td>
</tr>
</table>
</div>
{% endblock content_left_page %}
Expand Down
1 change: 1 addition & 0 deletions nautobot_floor_plan/tests/test_api_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class FloorPlanAPIViewTest(APIViewTestCases.APIViewTestCase):
model = models.FloorPlan
bulk_update_data = {"x_size": 10, "y_size": 1}
brief_fields = ["display", "id", "url", "x_size", "y_size"]
choices_fields = ["x_axis_labels", "y_axis_labels"]

@classmethod
def setUpTestData(cls):
Expand Down
21 changes: 18 additions & 3 deletions nautobot_floor_plan/tests/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from nautobot.extras.models import Tag
from nautobot.core.testing import TestCase

from nautobot_floor_plan import forms, models
from nautobot_floor_plan import forms, models, choices
from nautobot_floor_plan.tests import fixtures


Expand All @@ -20,7 +20,15 @@ def setUp(self):
def test_valid_minimal_inputs(self):
"""Test creation with minimal input data."""
form = forms.FloorPlanForm(
data={"location": self.floors[0].pk, "x_size": 1, "y_size": 2, "tile_depth": 100, "tile_width": 200}
data={
"location": self.floors[0].pk,
"x_size": 1,
"y_size": 2,
"tile_depth": 100,
"tile_width": 200,
"x_axis_labels": choices.AxisLabelsChoices.NUMBERS,
"y_axis_labels": choices.AxisLabelsChoices.NUMBERS,
}
)
self.assertTrue(form.is_valid())
form.save()
Expand All @@ -41,6 +49,8 @@ def test_valid_extra_inputs(self):
"y_size": 2,
"tile_depth": 1,
"tile_width": 2,
"x_axis_labels": choices.AxisLabelsChoices.NUMBERS,
"y_axis_labels": choices.AxisLabelsChoices.NUMBERS,
"tags": [tag],
}
)
Expand All @@ -51,12 +61,17 @@ def test_valid_extra_inputs(self):
self.assertEqual(floor_plan.y_size, 2)
self.assertEqual(floor_plan.tile_width, 2)
self.assertEqual(floor_plan.tile_depth, 1)
self.assertEqual(floor_plan.x_axis_labels, choices.AxisLabelsChoices.NUMBERS)
self.assertEqual(floor_plan.y_axis_labels, choices.AxisLabelsChoices.NUMBERS)
self.assertEqual(list(floor_plan.tags.all()), [tag])

def test_invalid_required_fields(self):
"""Test form validation with missing required fields."""
form = forms.FloorPlanForm(data={})
self.assertFalse(form.is_valid())
self.assertEqual(["location", "tile_depth", "tile_width", "x_size", "y_size"], sorted(form.errors.keys()))
self.assertEqual(
["location", "tile_depth", "tile_width", "x_axis_labels", "x_size", "y_axis_labels", "y_size"],
sorted(form.errors.keys()),
)
for message in form.errors.values():
self.assertIn("This field is required.", message)
45 changes: 45 additions & 0 deletions nautobot_floor_plan/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Test floorplan utils."""

import unittest

from nautobot_floor_plan import utils


class TestUtils(unittest.TestCase):
"""Test class."""

def test_col_num_to_letter(self):
test_cases = [
(1, "A"),
(26, "Z"),
(27, "AA"),
(52, "AZ"),
(53, "BA"),
(78, "BZ"),
(79, "CA"),
(104, "CZ"),
(703, "AAA"),
(18278, "ZZZ"),
]

for num, expected in test_cases:
with self.subTest(num=num):
self.assertEqual(utils.col_num_to_letter(num), expected)

def test_column_letter_to_num(self):
test_cases = [
("A", 1),
("Z", 26),
("AA", 27),
("AZ", 52),
("BA", 53),
("BZ", 78),
("CA", 79),
("CZ", 104),
("AAA", 703),
("ZZZ", 18278),
]

for letter, expected in test_cases:
with self.subTest(letter=letter):
self.assertEqual(utils.column_letter_to_num(letter), expected)
Loading