diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61ccc70..0ed0815 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,8 @@ jobs: uses: "actions/checkout@v4" - name: "Setup environment" uses: "networktocode/gh-action-setup-poetry-environment@v6" + with: + poetry-version: "1.8.4" - name: "Linting: ruff format" run: "poetry run invoke ruff --action format" ruff-lint: @@ -36,6 +38,8 @@ jobs: uses: "actions/checkout@v4" - name: "Setup environment" uses: "networktocode/gh-action-setup-poetry-environment@v6" + with: + poetry-version: "1.8.4" - name: "Linting: ruff" run: "poetry run invoke ruff --action lint" check-docs-build: @@ -47,6 +51,8 @@ jobs: uses: "actions/checkout@v4" - name: "Setup environment" uses: "networktocode/gh-action-setup-poetry-environment@v6" + with: + poetry-version: "1.8.4" - name: "Check Docs Build" run: "poetry run invoke build-and-check-docs" poetry: @@ -58,6 +64,8 @@ jobs: uses: "actions/checkout@v4" - name: "Setup environment" uses: "networktocode/gh-action-setup-poetry-environment@v6" + with: + poetry-version: "1.8.4" - name: "Checking: poetry lock file" run: "poetry run invoke lock --check" yamllint: @@ -69,6 +77,8 @@ jobs: uses: "actions/checkout@v4" - name: "Setup environment" uses: "networktocode/gh-action-setup-poetry-environment@v6" + with: + poetry-version: "1.8.4" - name: "Linting: yamllint" run: "poetry run invoke yamllint" check-in-docker: @@ -91,6 +101,8 @@ jobs: uses: "actions/checkout@v4" - name: "Setup environment" uses: "networktocode/gh-action-setup-poetry-environment@v6" + with: + poetry-version: "1.8.4" - name: "Constrain Nautobot version and regenerate lock file" env: INVOKE_NAUTOBOT_FLOOR_PLAN_LOCAL: "true" @@ -146,6 +158,8 @@ jobs: uses: "actions/checkout@v4" - name: "Setup environment" uses: "networktocode/gh-action-setup-poetry-environment@v6" + with: + poetry-version: "1.8.4" - name: "Constrain Nautobot version and regenerate lock file" env: INVOKE_NAUTOBOT_FLOOR_PLAN_LOCAL: "true" @@ -187,6 +201,8 @@ jobs: fetch-depth: "0" - name: "Setup environment" uses: "networktocode/gh-action-setup-poetry-environment@v6" + with: + poetry-version: "1.8.4" - name: "Check for changelog entry" run: | git fetch --no-tags origin +refs/heads/${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }} diff --git a/changes/141.added b/changes/141.added new file mode 100644 index 0000000..cdf070c --- /dev/null +++ b/changes/141.added @@ -0,0 +1 @@ +Added boolean option to allow tiles to be moved or not once placed. \ No newline at end of file diff --git a/changes/8.added b/changes/8.added new file mode 100644 index 0000000..2bf6f7a --- /dev/null +++ b/changes/8.added @@ -0,0 +1 @@ +Added support for defining custom grid label ranges. \ No newline at end of file diff --git a/docs/admin/install.md b/docs/admin/install.md index 8d59bef..0574b73 100644 --- a/docs/admin/install.md +++ b/docs/admin/install.md @@ -1,21 +1,24 @@ # Installing the App in Nautobot -Here you will find detailed instructions on how to **install** and **configure** the App within your Nautobot environment. +This section provides detailed instructions on how to **install** and **configure** the app in your Nautobot environment. ## Prerequisites -- The app is compatible with Nautobot 2.0.0 and higher. -- Databases supported: PostgreSQL, MySQL +- Compatible with Nautobot **2.0.0 and higher**. +- Supported databases: **PostgreSQL** and **MySQL**. !!! note - Please check the [dedicated page](compatibility_matrix.md) for a full compatibility matrix and the deprecation policy. + For a full compatibility matrix and details about the deprecation policy, refer to the [Compatibility Matrix](compatibility_matrix.md). ## Install Guide !!! note - Apps can be installed from the [Python Package Index](https://pypi.org/) or locally. See the [Nautobot documentation](https://docs.nautobot.com/projects/core/en/stable/user-guide/administration/installation/app-install/) for more details. The pip package name for this app is [`nautobot-floor-plan`](https://pypi.org/project/nautobot-floor-plan/). + Apps can be installed from the [Python Package Index (PyPI)](https://pypi.org/) or locally. For more details, see the official [Nautobot App Installation Guide](https://docs.nautobot.com/projects/core/en/stable/user-guide/administration/installation/app-install/). + The pip package name for this app is [`nautobot-floor-plan`](https://pypi.org/project/nautobot-floor-plan/). -The app is available as a Python package via PyPI and can be installed with `pip`: +### Step 1: Install the App + +Install the app via PyPI using `pip`: ```shell pip install nautobot-floor-plan @@ -27,6 +30,8 @@ To ensure Nautobot Floor Plan is automatically re-installed during future upgrad echo nautobot-floor-plan >> local_requirements.txt ``` +### Step 2: Configure the App + Once installed, the app needs to be enabled in your Nautobot configuration. The following block of code below shows the additional configuration required to be added to your `nautobot_config.py` file: - Append `"nautobot_floor_plan"` to the `PLUGINS` list. @@ -62,30 +67,103 @@ Then restart the Nautobot services which may include: ```shell sudo systemctl restart nautobot nautobot-worker nautobot-scheduler ``` +# Nautobot Floor Plan App Configuration and Customization -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. +## Verifying Installation -## App Configuration +Once the app is successfully installed, the Nautobot web UI will display a new "Location Floor Plans" menu item under the **Organization** menu. -The app behavior can be controlled with the following list of settings: +## App Configuration Details -| Key | Example | Default | Description | -|--------------------|-----------|----------|------------------------------------------------------------------------------------------------------------------------------------------------| -| default_x_axis_labels | "letters" | "numbers" | Label style for the floor plan grid. Can use `numbers` or `letters` in order. This setting will set the default selected value in the create form. | -| default_y_axis_labels | "numbers" | "numbers" | Label style for the floor plan grid. Can use `numbers` or `letters` in order. This setting will set the default selected value in the create form. | -| default_statuses| "name": "Active", "color": "4caf50"| See Note Below | A list of name and color key value pairs for the FloorPlanTile model| +The app behavior can be customized with the following configuration settings: + +| Key | Example | Default | Description | +|------------------------|-------------------------------------------|-------------|------------------------------------------------------------------------------------------------------------------------------------------------| +| `default_x_axis_labels` | `"letters"` | `"numbers"` | Defines the label style for the X-axis of the floor plan grid. Options are `numbers` or `letters`. This sets the default value in the create form. | +| `default_y_axis_labels` | `"numbers"` | `"numbers"` | Defines the label style for the Y-axis of the floor plan grid. Options are `numbers` or `letters`. This sets the default value in the create form. | +| `default_statuses` | `{"name": "Active", "color": "4caf50"}` | See note below | A list of name and color key-value pairs for the **FloorPlanTile** model. | !!! note - Defaults for statuses are as follows: + Default statuses are configured as follows: ```python - "default_statuses": { - "FloorPlanTile": [ - {"name": "Active", "color": "4caf50"}, - {"name": "Reserved", "color": "00bcd4"}, - {"name": "Decommissioning", "color": "ffc107"}, - {"name": "Unavailable", "color": "111111"}, - {"name": "Planned", "color": "00bcd4"}, - ], - }, + "default_statuses": { + "FloorPlanTile": [ + {"name": "Active", "color": "4caf50"}, + {"name": "Reserved", "color": "00bcd4"}, + {"name": "Decommissioning", "color": "ffc107"}, + {"name": "Unavailable", "color": "111111"}, + {"name": "Planned", "color": "00bcd4"}, + ], + } ``` + +## Custom Labels + +The app supports custom label types, defined in `choices.py`: + +```python +class CustomAxisLabelsChoices(ChoiceSet): + """Choices for custom axis label types.""" + + ROMAN = "roman" + GREEK = "greek" + BINARY = "binary" + HEX = "hex" + NUMALPHA = "numalpha" + LETTERS = "letters" + ALPHANUMERIC = "alphanumeric" + NUMBERS = "numbers" + + CHOICES = ( + (ROMAN, "Roman (e.g., I, II, III)"), + (GREEK, "Greek (e.g., α, β, γ)"), + (BINARY, "Binary (e.g., 1, 10, 11)"), + (HEX, "Hexadecimal (e.g., 1, A, F)"), + (NUMALPHA, "numalpha (e.g., 02A)"), + (LETTERS, "Letters (e.g., A, B, C)"), + (ALPHANUMERIC, "Alphanumeric (e.g., A01, B02)"), + (NUMBERS, "Numbers (e.g., 1, 2, 3)"), + ) +``` + +### Adding New Custom Labels + +To define new custom label types: + +1. Add the new choice to the `CustomAxisLabelsChoices` class in `choices.py`. +2. Implement a corresponding converter class in `label_converters.py`. + +#### Label Converter Base Class + +All label converters inherit from the base `LabelConverter` class: + +```python +class LabelConverter: + """Base class for label conversion.""" + + def __init__(self): + """Initialize converter.""" + self.current_label = None + + def to_numeric(self, label: str) -> int: + """Convert label to numeric value.""" + raise NotImplementedError + + def from_numeric(self, number: int) -> str: + """Convert numeric value to label.""" + raise NotImplementedError +``` + +The to_numeric and from_numeric methods handle: + +- Converting database integer values to display labels on the Floor Plan grid. +- Converting labels back to the corresponding integer values for database storage. + +### Label Factory + +LabelConverterFactory located in label_converters.py is used to lookup the correct converter that will be used based off of the CustomAxisLabelsChoices class from choices.py to the proper converter class in label_converters.py. + +### Validation Logic + +Custom label validation is handled in custom_validators.py, ensuring that the labels meet the required format and rules before being applied to the Floor Plan. diff --git a/docs/admin/uninstall.md b/docs/admin/uninstall.md index 98296fc..1e46b1f 100644 --- a/docs/admin/uninstall.md +++ b/docs/admin/uninstall.md @@ -10,9 +10,6 @@ Prior to removing the app from the `nautobot_config.py`, run the following comma nautobot-server migrate nautobot_floor_plan zero ``` -!!! warning "Developer Note - Remove Me!" - Any other cleanup operations to ensure the database is clean after the app is removed. Is there anything else that needs cleaning up, such as CFs, relationships, etc. if they're no longer desired? - ## Remove App configuration Remove the configuration you added in `nautobot_config.py` from `PLUGINS` & `PLUGINS_CONFIG`. diff --git a/docs/images/add-floor-plan-form.png b/docs/images/add-floor-plan-form.png index 51a8ebd..1df9ff7 100644 Binary files a/docs/images/add-floor-plan-form.png and b/docs/images/add-floor-plan-form.png differ diff --git a/docs/images/add-tile-axis-default.png b/docs/images/add-tile-axis-default.png new file mode 100644 index 0000000..eb30e31 Binary files /dev/null and b/docs/images/add-tile-axis-default.png differ diff --git a/docs/images/add-tile-axis-numalpha.png b/docs/images/add-tile-axis-numalpha.png new file mode 100644 index 0000000..e676a3f Binary files /dev/null and b/docs/images/add-tile-axis-numalpha.png differ diff --git a/docs/images/add-tile-axis-roman.png b/docs/images/add-tile-axis-roman.png new file mode 100644 index 0000000..7a803cf Binary files /dev/null and b/docs/images/add-tile-axis-roman.png differ diff --git a/docs/images/custom-label-examples.png b/docs/images/custom-label-examples.png new file mode 100644 index 0000000..6516959 Binary files /dev/null and b/docs/images/custom-label-examples.png differ diff --git a/docs/user/app_getting_started.md b/docs/user/app_getting_started.md index 7ddb8cd..9b0d424 100644 --- a/docs/user/app_getting_started.md +++ b/docs/user/app_getting_started.md @@ -1,55 +1,189 @@ # Getting Started with the App -This document provides a step-by-step tutorial on how to get the App going and how to use it. +This document provides a step-by-step tutorial on how to get the app up and running, as well as instructions on how to use it effectively. ## Install the App -To install the App, please follow the instructions detailed in the [Installation Guide](../admin/install.md). +To install the app, please follow the instructions detailed in the [Installation Guide](../admin/install.md). -## First steps with the App +## First Steps with the App -As a first step you will want to define which Status(es) can be applied to individual tiles in a floor plan. This can be done by navigating to "Organization > Statuses" in the Nautobot UI, and creating or updating the desired Status records to include `nautobot_floor_plan | floor plan tile` as one of the Status's "Content Types". +### Defining Statuses for Floor Plan Tiles -The app installs with the the following statuses by default. `"Active", "Reserved", "Decommissioning", "Unavailable", "Planned"` +The first step is to define which Status(es) can be applied to individual tiles in a floor plan. Navigate to **"Organization > Statuses"** in the Nautobot UI and create or update the desired Status records to include `nautobot_floor_plan | floor plan tile` as one of the Status's *Content Types*. + +The app installs with the following statuses by default: +`"Active", "Reserved", "Decommissioning", "Unavailable", "Planned"` ![Status definition](../images/status-definition.png) -## What are the next steps? +## Next Steps + +### Adding a Floor Plan to a Location -For any [Location](https://docs.nautobot.com/projects/core/en/stable/core-functionality/sites-and-racks/#locations) defined within your Nautobot instance, you can navigate to the "detail" view for that Location and a new "Add Floor Plan" button will be present. +For any [Location](https://docs.nautobot.com/projects/core/en/stable/core-functionality/sites-and-racks/#locations) defined in your Nautobot instance, navigate to the **"detail"** view for that Location. A new **"Add Floor Plan"** button will be present. ![Add Floor Plan button](../images/add-floor-plan-button.png) -Clicking this button will bring you to a standard Nautobot create/edit form, in this case for defining parameters of the Floor Plan for this Location. +Clicking this button will open a standard Nautobot create/edit form. This form allows you to define the parameters of the floor plan for the selected Location. + +#### Floor Plan Parameters + +- **X Size** and **Y Size**: + Define the number of tiles in the floor plan. + +- **Tile Width** and **Tile Depth**: + Define the relative proportions of each tile when rendered in the Nautobot UI. + - You can use the default settings for a square grid. + - Alternatively, customize these parameters for a rectangular grid. + +- **Movable Tiles**: + Determine if you want tiles to be movable once placed. + - This feature is a optional setting to assist with Custom Label creation + - Default: `"True"` ![Add Floor Plan form](../images/add-floor-plan-form.png) -The "X size" and "Y size" parameters define the number of Tiles in the Floor Plan, and the "Tile width" and "Tile depth" parameters define the relative proportions of each Tile when rendered in the Nautobot UI. You can leave the tile parameters as defaults for a square grid, or set them as desired for a off-square rectangular grid. +#### Axis Labeling and Configuration + +Default settings allow you to configure labels, seeds, and steps for each axis of the floor plan. + +- **X Axis Settings** and **Y Axis Settings**: + These parameters are divided into panels with tabs for *default* or *custom* labels. + +- **X Axis Labels** and **Y Axis Labels**: + Represent grid labels as either `"Numbers"` or `"Letters"`. + - Default: `"Numbers"` + +- **X Axis Seed** and **Y Axis Seed**: + Define the starting point for grid labels. + - Default: `"1"` + +- **X Axis Step** and **Y Axis Step**: + Set a positive or negative integer step value to skip numbers or letters in grid labeling. + - Default: `"1"` + +![Add Floor Plan form part 2](../images/add-tile-axis-default.png) + +The **Custom Labels** tab provides options to configure a custom label range using the following parameters: `start`, `end`, `step`, `increment_letter`, and `label_type`. + +![Custom Label Examples](../images/custom-label-examples.png) +![Add Floor Plan form part 3](../images/add-tile-axis-numalpha.png) +![Add Floor Plan form part 4](../images/add-tile-axis-roman.png) + +## Parameters + +- **`start`** + Similar to the default seed parameters, `start` specifies the starting point for the custom label range. + +- **`end`** + Specifies the last label in the custom range. -The "X Axis Labels" and "Y Axis Labels" parameters can be used to represent "Numbers" or "Letters" for grid labeling. The default setting is "Numbers". +- **`step`** + This works like the default *X Axis Step* and *Y Axis Step* parameters, allowing you to set a positive or negative integer to control label spacing within the grid. + The default value is `1`. -The "X Axis Seed" and "Y Axis Seed" parameters allow you to define the starting location for a grid label. The default setting is "1". +- **`increment_letter`** *(optional)* + Applicable only for *numalpha* and *alphanumeric* label types, this parameter controls whether letter patterns increment. + - *Default*: `true` + - When set to `true`: + - The letter portions increment, but the numeric portions do not creating patterns like: + - For numalpha: `02AA, 02AB, 02AC` + - For alphanumeric: `A01, B01, C01` + - When set to `false`: + - For numalpha: + - The entire letter portion increments with every step creating patterns like: + - `02AA, 02BB, 02CC` + - For alphanumeric: + - The letter prefix does not increment, but the numeric portion does creating patterns like: + - `A01, A02, A03` -The "X Axis Step" and "Y Axis Step" parameters allow you to choose a positive or negative integer step value that are used to skip numbers or letters for grid labeling. The default setting is "1". + Both *numalpha* and *alphanumeric* label types support leading or non-leading zero formats. -After clicking "Create", you will be presented with a new floor plan render: +- **`label_type`** + Specifies the type of label. Supported types include: + - `numalpha` + - `alphanumeric` + - `roman` + - `greek` + - `hex` + - `binary` + - `letters` + - `numbers` + +!!! note + The total range of configured labels must not exceed the configured *X Size* or *Y Size* of the floor plan for their respective axis. + +## Configuration Examples + +### Single Range Example + +With an X Size of 10: + +```json +[{"start": "02A", "end": "02J", "step": 1, "increment_letter": true, "label_type": "numalpha"}] +``` + +```json +[{"start": "I", "end": "X", "step": 1, "label_type": "roman"}] +``` + +### Multiple Range Example + +With an X Size of 10: + +```json +[{"start": "02A", "end": "02E", "step": 1, "increment_letter": true, "label_type": "numalpha"},{"start": "02AA", "end": "02EE", "step": 1, "increment_letter": false, "label_type": "numalpha"}] +``` + +```json +[{"start": "1", "end": "5", "step": 1, "label_type": "binary"},{"start": "11", "end": "15", "step": 1, "label_type": "binary"}] +``` + +## Creating and Managing the Floor Plan + +After clicking **Create**, you will be presented with a newly rendered floor plan: ![Empty floor plan](../images/floor-plan-empty.png) -(This view will be accessible again in the future by navigating to the Location's "detail" view again and clicking the "Floor Plan" tab.) +!!! note + This view will be accessible again in the future by navigating to the Location's **detail** view and clicking the **Floor Plan** tab. + +### Adding Tiles to the Floor Plan -You can click the "+" icon in the corner of any rectangle in the grid to define information about this Tile in the Floor Plan. (If you've defined a large floor plan, or have a small display, you may find it useful to use your mouse wheel to zoom in first. You can also click and drag when zoomed in to pan around the grid.) This will bring you to a simple create/edit form for describing the Tile. +To add information to a tile, click the **"+"** icon in the corner of any rectangle in the grid. +- If you've defined a large floor plan or have a small display, you can use your mouse wheel to zoom in for a better view. +- While zoomed in, click and drag to pan around the grid. -You can assign a Status to each tile, and optionally assign a Rack or RackGroup as well as specifying the orientation of the Rack relative to the Floor Plan. You can also specify the size of a Tile if you want it to cover multiple "spaces" in the Floor Plan - this can be useful to document larger-than-usual Racks, or to mark entire sections of the Floor Plan as "Reserved" or "Unavailable". +Clicking a tile's **"+"** icon will open a simple create/edit form for describing the tile. -You can place racks within the Status or RackGroup tiles that are covering multiple spaces. When placing a Rack onto a RackGroup tile, the rack must be added to the appropriate RackGroup. RackGroup and Status tiles that cover multiple "spaces" can be increased or reduced in size as long as they don't overlap with other Status or RackGroup tiles. +![Add Tile form part 1](../images/add-tile-form.png) -When a Rack is assigned to a tile, the display of the tile will additionally include any Tenant and TenantGroup information for the rack. +### Tile Options -![Add Tile form](../images/add-tile-form.png) +For each tile, you can: +- **Assign a Status**: Choose from the predefined Statuses (e.g., "Active", "Reserved"). +- **Assign a Rack or RackGroup**: Specify the rack or rack group associated with the tile. +- **Specify Rack Orientation**: Define the orientation of the rack relative to the floor plan. +- **Adjust Tile Size**: Expand a tile to cover multiple spaces. + - Useful for documenting larger-than-usual racks or marking sections of the floor plan as "Reserved" or "Unavailable." -By repeating this process as many times as desired, you can populate the Floor Plan in detail to indicate the Status of each Tile as well as the position, Status, and space usage of your Racks: +### Working with RackGroup and Status Tiles + +When tiles cover multiple spaces: +- You can place racks within these tiles. +- For RackGroup tiles, racks must be added to the appropriate RackGroup. +- Tile size can be increased or decreased, as long as they do not overlap with other tiles. + +### Additional Tile Information + +When a rack is assigned to a tile, the display of the tile will also include: +- **Tenant Information**: The tenant and tenant group associated with the rack. ![Populated floor plan](../images/floor-plan-populated.png) -Once tiles have been added to a Floor Plan, the Floor Plan can no longer be resized. This is to prevent the resizing of a Floor Plan that could leave a Tile outside the bounds of the new dimensions. All the tiles would need to be removed or the Floor Plan would need to be deleted and recreated to change the dimensions. +### Resizing the Floor Plan + +Once tiles have been added, the floor plan can no longer be resized. +- This restriction prevents resizing that could place tiles outside the new dimensions. +- To change the floor plan's dimensions, you must: Remove all tiles, or Delete and recreate the floor plan. \ No newline at end of file diff --git a/docs/user/app_overview.md b/docs/user/app_overview.md index fe50ede..8b38026 100644 --- a/docs/user/app_overview.md +++ b/docs/user/app_overview.md @@ -36,7 +36,9 @@ Included is a non-exhaustive list of capabilities beyond a standard MVC (model v - Provides the ability to place racks in a group that spans multiple tiles. - Provides custom layout size in any rectangular shape using X & Y axis. - Provides the ability to resize the Floor Plan until Tiles have been placed. Once a Tile has been placed the Floor Plan cannot be resized until the Tiles have been removed. +- Provide the ability to make Tile objects movable or immovable. - Provides the ability to choose Numbers or Letters for grid labels. +- Provides the ability to define custom labels for grid labels. - Provides the ability for a user to define a specific number or letter as a starting point for grid labels. - Provides the ability for a user to define a positive or negative integer to allow for the skipping of letters or numbers for grid labels. - Provides the ability to save the generated SVG from a click of a "Save SVG" link. diff --git a/nautobot_floor_plan/api/serializers.py b/nautobot_floor_plan/api/serializers.py index e4800a5..b272c71 100644 --- a/nautobot_floor_plan/api/serializers.py +++ b/nautobot_floor_plan/api/serializers.py @@ -15,6 +15,16 @@ class Meta: fields = "__all__" +class FloorPlanCustomAxisLabelSerializer(NautobotModelSerializer, TaggedModelSerializerMixin): + """FloorPlanCustomAxisLabel Serializer.""" + + class Meta: + """Meta attributes.""" + + model = models.FloorPlanCustomAxisLabel + fields = "__all__" + + class FloorPlanTileSerializer(NautobotModelSerializer, TaggedModelSerializerMixin): """FloorPlanTile Serializer.""" diff --git a/nautobot_floor_plan/choices.py b/nautobot_floor_plan/choices.py index 9cc4fb5..8a3fc92 100644 --- a/nautobot_floor_plan/choices.py +++ b/nautobot_floor_plan/choices.py @@ -19,8 +19,32 @@ class RackOrientationChoices(ChoiceSet): ) +class CustomAxisLabelsChoices(ChoiceSet): + """Choices for custom axis label types.""" + + ROMAN = "roman" + GREEK = "greek" + BINARY = "binary" + HEX = "hex" + NUMALPHA = "numalpha" + LETTERS = "letters" + ALPHANUMERIC = "alphanumeric" + NUMBERS = "numbers" + + CHOICES = ( + (ROMAN, "Roman (e.g., I, II, III)"), + (GREEK, "Greek (e.g., α, β, γ)"), + (BINARY, "Binary (e.g., 1, 10, 11)"), + (HEX, "Hexadecimal (e.g., 1, A, F)"), + (NUMALPHA, "numalpha (e.g., 02A)"), + (LETTERS, "Letters (e.g., A, B, C)"), + (ALPHANUMERIC, "Alphanumeric (e.g., A01, B02)"), + (NUMBERS, "Numbers (e.g. 1, 2, 3)"), + ) + + class AxisLabelsChoices(ChoiceSet): - """Choices for grid numbering style.""" + """Choices for axis labels.""" NUMBERS = "numbers" LETTERS = "letters" diff --git a/nautobot_floor_plan/forms.py b/nautobot_floor_plan/forms.py index 89dc0e6..949f249 100644 --- a/nautobot_floor_plan/forms.py +++ b/nautobot_floor_plan/forms.py @@ -18,48 +18,83 @@ ) from nautobot.dcim.models import Location, Rack, RackGroup -from nautobot_floor_plan import choices, models, utils +from nautobot_floor_plan import choices, models +from nautobot_floor_plan.utils import general +from nautobot_floor_plan.utils.custom_validators import RangeValidator +from nautobot_floor_plan.utils.label_converters import LabelToPositionConverter, PositionToLabelConverter class FloorPlanForm(NautobotModelForm): - """FloorPlan creation/edit form.""" + """FloorPlan creation/edit form with support for custom axis label ranges.""" + + CUSTOM_RANGES_HELP_TEXT = ( + "Enter custom label ranges in JSON format.
" + "Distance between start and end cannot exceed the size of the floor plan.
" + "Examples: Click here for examples." + ) location = DynamicModelChoiceField(queryset=Location.objects.all()) + # X Axis Fields x_origin_seed = forms.CharField( label="X Axis Seed", help_text="The first value to begin X Axis at.", required=True, ) - y_origin_seed = forms.CharField( - label="Y Axis Seed", - help_text="The first value to begin Y Axis at.", - required=True, - ) x_axis_step = forms.IntegerField( label="X Axis Step", help_text="A positive or negative integer, excluding zero", required=True, ) + x_custom_ranges = forms.JSONField( + label="Custom Ranges for X Axis", + required=False, + help_text=CUSTOM_RANGES_HELP_TEXT, + ) + + # Y Axis Fields + y_origin_seed = forms.CharField( + label="Y Axis Seed", + help_text="The first value to begin Y Axis at.", + required=True, + ) y_axis_step = forms.IntegerField( label="Y Axis Step", help_text="A positive or negative integer, excluding zero", required=True, ) + y_custom_ranges = forms.JSONField( + label="Custom Ranges for Y Axis", + required=False, + help_text=CUSTOM_RANGES_HELP_TEXT, + ) + is_tile_movable = forms.BooleanField( + required=False, + label="Movable Tiles", + help_text="If true tiles can be edited and moved once placed on the Floorplan.", + ) - field_order = [ - "location", - "x_size", - "y_size", - "tile_width", - "tile_depth", - "x_axis_labels", - "x_origin_seed", - "x_axis_step", - "y_axis_labels", - "y_origin_seed", - "y_axis_step", - ] + fieldsets = ( + ("Floor Plan", ("location", "x_size", "y_size", "tile_width", "tile_depth", "is_tile_movable")), + ( + "X Axis Settings", + { + "tabs": ( + ("Default Labels", ("x_axis_labels", "x_origin_seed", "x_axis_step")), + ("Custom Labels", ("x_custom_ranges",)), + ), + }, + ), + ( + "Y Axis Settings", + { + "tabs": ( + ("Default Labels", ("y_axis_labels", "y_origin_seed", "y_axis_step")), + ("Custom Labels", ("y_custom_ranges",)), + ), + }, + ), + ) class Meta: """Meta attributes.""" @@ -68,9 +103,10 @@ class Meta: fields = "__all__" def __init__(self, *args, **kwargs): - """Overwrite the constructor to set initial values for select widget.""" + """Overwrite the constructor to set initial values and handle custom ranges.""" super().__init__(*args, **kwargs) + # Set initial values for select widget if not self.instance.created: self.initial["x_axis_labels"] = get_app_settings_or_config("nautobot_floor_plan", "default_x_axis_labels") self.initial["y_axis_labels"] = get_app_settings_or_config("nautobot_floor_plan", "default_y_axis_labels") @@ -83,38 +119,173 @@ def __init__(self, *args, **kwargs): self.y_letters = self.instance.y_axis_labels == choices.AxisLabelsChoices.LETTERS if self.x_letters and str(self.initial["y_origin_seed"]).isdigit(): - self.initial["x_origin_seed"] = utils.grid_number_to_letter(self.instance.x_origin_seed) + self.initial["x_origin_seed"] = general.grid_number_to_letter(self.instance.x_origin_seed) if self.y_letters and str(self.initial["y_origin_seed"]).isdigit(): - self.initial["y_origin_seed"] = utils.grid_number_to_letter(self.instance.y_origin_seed) + self.initial["y_origin_seed"] = general.grid_number_to_letter(self.instance.y_origin_seed) + + # Load existing custom ranges + if self.instance.pk: + x_ranges = list( + self.instance.custom_labels.filter(axis="X") + .values("start_label", "end_label", "step", "increment_letter", "label_type", "order") + .order_by("order") + ) + y_ranges = list( + self.instance.custom_labels.filter(axis="Y") + .values("start_label", "end_label", "step", "increment_letter", "label_type", "order") + .order_by("order") + ) + + # Set the properties based on whether custom ranges exist + self.has_x_custom_labels = bool(x_ranges) + self.has_y_custom_labels = bool(y_ranges) + + if x_ranges: + self.initial["x_custom_ranges"] = [ + { + "start": r["start_label"], + "end": r["end_label"], + "step": r["step"], + "increment_letter": r["increment_letter"], + "label_type": r["label_type"], + } + for r in x_ranges + ] + if y_ranges: + self.initial["y_custom_ranges"] = [ + { + "start": r["start_label"], + "end": r["end_label"], + "step": r["step"], + "increment_letter": r["increment_letter"], + "label_type": r["label_type"], + } + for r in y_ranges + ] def _clean_origin_seed(self, field_name, axis): - """Common clean method for origin_seed fields.""" + """Clean method for origin seed fields.""" value = self.cleaned_data.get(field_name) if not value: - return 1 - - self.x_letters = self.cleaned_data.get("x_axis_labels") == choices.AxisLabelsChoices.LETTERS - self.y_letters = self.cleaned_data.get("y_axis_labels") == choices.AxisLabelsChoices.LETTERS - - if self.x_letters and field_name == "x_origin_seed" or self.y_letters and field_name == "y_origin_seed": - if not str(value).isupper(): - self.add_error(field_name, f"{axis} origin start should use capital letters.") - return 0 - return utils.grid_letter_to_number(value) - - if not str(value).replace("-", "").isnumeric(): - self.add_error(field_name, f"{axis} origin start should use numbers.") - return 0 - return int(value) + return value + + # Determine if using letters based on axis labels + using_letters = ( + self.cleaned_data.get("x_axis_labels") == choices.AxisLabelsChoices.LETTERS + if axis == "X" + else self.cleaned_data.get("y_axis_labels") == choices.AxisLabelsChoices.LETTERS + ) + + # Validate format based on axis label type + if using_letters: + if not value.isalpha(): + raise forms.ValidationError(f"{axis} origin seed should be letters when using letter labels.") + if not value.isupper(): + raise forms.ValidationError(f"{axis} origin seed should be uppercase letters.") + # Convert letter to corresponding number + return general.grid_letter_to_number(value) + try: + return int(value) + except ValueError as e: + raise forms.ValidationError(f"{axis} origin seed should be a number when using numeric labels.") from e def clean_x_origin_seed(self): - """Validate input and convert y_origin to an integer.""" + """Validate the X origin seed.""" return self._clean_origin_seed("x_origin_seed", "X") def clean_y_origin_seed(self): - """Validate input and convert y_origin to an integer.""" + """Validate the Y origin seed.""" return self._clean_origin_seed("y_origin_seed", "Y") + def save(self, commit=True): + """Save the FloorPlan instance along with custom ranges.""" + instance = super().save(commit=False) + x_ranges = self.cleaned_data.get("x_custom_ranges", []) + y_ranges = self.cleaned_data.get("y_custom_ranges", []) + + # Set increment_letter defaults + for label_range in x_ranges: + if label_range.get("increment_letter", True) and label_range["label_type"] == "numbers": + label_range["increment_letter"] = False + + for label_range in y_ranges: + if label_range.get("increment_letter", True) and label_range["label_type"] == "numbers": + label_range["increment_letter"] = False + + if commit: + instance.save() + self.save_m2m() + + # Clear existing custom ranges + models.FloorPlanCustomAxisLabel.objects.filter(floor_plan=instance).delete() + + # Save X axis custom ranges + if x_ranges: + self.create_custom_axis_labels(x_ranges, instance, axis="X") + # Save Y axis custom ranges + if y_ranges: + self.create_custom_axis_labels(y_ranges, instance, axis="Y") + + return instance + + def create_custom_axis_labels(self, ranges, instance, axis): + """Helper function to create custom axis labels.""" + labels = [] + for idx, custom_range in enumerate(ranges): + labels.append( + models.FloorPlanCustomAxisLabel( + floor_plan=instance, + axis=axis, + start_label=custom_range["start"], + end_label=custom_range["end"], + step=custom_range.get("step", 1), + label_type=custom_range["label_type"], + increment_letter=custom_range.get("increment_letter", True), + order=idx, # Assign order based on index + ) + ) + models.FloorPlanCustomAxisLabel.objects.bulk_create(labels) + + def _validate_custom_ranges(self, field_name): + """Validate custom label ranges.""" + custom_ranges = self.cleaned_data.get(field_name, []) + if not custom_ranges: + return [] + + is_x_axis = field_name == "x_custom_ranges" + max_size = self.cleaned_data.get("x_size" if is_x_axis else "y_size") + validator = RangeValidator(max_size) + + # First validate each individual range + for label_range in custom_ranges: + validator.validate_required_keys(label_range) + + start = label_range["start"] + end = label_range["end"] + label_type = label_range["label_type"] + + validator.validate_label_type(label_type) + if label_type == choices.CustomAxisLabelsChoices.NUMBERS and label_range.get("increment_letter"): + validator.validate_increment_letter_for_numbers(label_range.get("increment_letter")) + if label_type in [choices.CustomAxisLabelsChoices.HEX, choices.CustomAxisLabelsChoices.BINARY]: + validator.validate_numeric_range(start, end, current_range=label_range) + else: + validator.validate_custom_range(start, end, label_type, current_range=label_range) + validator.validate_increment_letter(label_range, label_type) + + # Then validate that ranges don't overlap + validator.validate_multiple_ranges(custom_ranges) + + return custom_ranges + + def clean_x_custom_ranges(self): + """Validate the X axis custom ranges.""" + return self._validate_custom_ranges("x_custom_ranges") + + def clean_y_custom_ranges(self): + """Validate the Y axis custom ranges.""" + return self._validate_custom_ranges("y_custom_ranges") + class FloorPlanBulkEditForm(TagsBulkEditFormMixin, NautobotBulkEditForm): # pylint: disable=too-many-ancestors """FloorPlan bulk edit form.""" @@ -184,8 +355,53 @@ class Meta: fields = "__all__" exclude = ["allocation_type", "on_group_tile"] # pylint: disable=modelform-uses-exclude + def _convert_label_to_position(self, value, axis, fp_obj): + """Wrapper for the LabelToPositionConverter.""" + try: + converter = LabelToPositionConverter(value, axis, fp_obj) + absolute_position, _ = converter.convert() + return absolute_position, None # Return None for the error when successful + except ValueError as e: + return None, forms.ValidationError(str(e)) + + def _clean_custom_origin(self, field_name, axis): + """Clean method for custom label origins.""" + fp_obj = self.cleaned_data.get("floor_plan") + value = self.cleaned_data.get(field_name) + + try: + position, error = self._convert_label_to_position(value, axis, fp_obj) + if error: + raise error + + # Validate against floor plan size + max_size = fp_obj.x_size if axis == "X" else fp_obj.y_size + if position > max_size: + raise forms.ValidationError( + f"Position {value} (absolute: {position}) exceeds floor plan {axis} size of {max_size}" + ) + + return position + + except ValueError as e: + raise forms.ValidationError(f"Invalid {axis}-axis value: {str(e)}") + + def clean_x_origin(self): + """Clean method for x_origin field.""" + fp_obj = self.cleaned_data.get("floor_plan") + if fp_obj.custom_labels.filter(axis="X").exists(): + return self._clean_custom_origin("x_origin", "X") + return self._clean_origin("x_origin", "X") + + def clean_y_origin(self): + """Clean method for y_origin field.""" + fp_obj = self.cleaned_data.get("floor_plan") + if fp_obj.custom_labels.filter(axis="Y").exists(): + return self._clean_custom_origin("y_origin", "Y") + return self._clean_origin("y_origin", "Y") + def __init__(self, *args, **kwargs): - """Overwrite the constructor to define grid numbering style.""" + """Initialize the form and handle custom label conversions.""" super().__init__(*args, **kwargs) self.x_letters = False self.y_letters = False @@ -194,27 +410,37 @@ def __init__(self, *args, **kwargs): 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: - self.initial["x_origin"] = utils.axis_init_label_conversion( - fp_obj.x_origin_seed, - utils.grid_number_to_letter(self.instance.x_origin) if self.x_letters else self.initial.get("x_origin"), - fp_obj.x_axis_step, - self.x_letters, - ) - self.initial["y_origin"] = utils.axis_init_label_conversion( - fp_obj.y_origin_seed, - utils.grid_number_to_letter(self.instance.y_origin) if self.y_letters else self.initial.get("y_origin"), - fp_obj.y_axis_step, - self.y_letters, - ) - elif self.initial.get("x_origin") and self.initial.get("y_origin"): - self.initial["x_origin"] = utils.axis_init_label_conversion( - fp_obj.x_origin_seed, self.initial.get("x_origin"), fp_obj.x_axis_step, self.x_letters - ) - self.initial["y_origin"] = utils.axis_init_label_conversion( - fp_obj.y_origin_seed, self.initial.get("y_origin"), fp_obj.y_axis_step, self.y_letters - ) + if not fp_obj.is_tile_movable: + self.fields["x_origin"].disabled = True + self.fields["y_origin"].disabled = True + + if self.instance.x_origin or self.instance.y_origin: + if fp_obj.custom_labels.filter(axis="X").exists(): + converter = PositionToLabelConverter(self.instance.x_origin, "X", fp_obj) + if label := converter.convert(): + self.initial["x_origin"] = label + else: + self.initial["x_origin"] = general.axis_init_label_conversion( + fp_obj.x_origin_seed, + general.grid_number_to_letter(self.instance.x_origin) + if self.x_letters + else self.initial.get("x_origin"), + fp_obj.x_axis_step, + self.x_letters, + ) + if fp_obj.custom_labels.filter(axis="Y").exists(): + converter = PositionToLabelConverter(self.instance.y_origin, "Y", fp_obj) + if label := converter.convert(): + self.initial["y_origin"] = label + else: + self.initial["y_origin"] = general.axis_init_label_conversion( + fp_obj.y_origin_seed, + general.grid_number_to_letter(self.instance.y_origin) + if self.y_letters + else self.initial.get("y_origin"), + fp_obj.y_axis_step, + self.y_letters, + ) def letter_validator(self, field, value, axis): """Validate that origin uses combination of letters.""" @@ -255,13 +481,5 @@ def _clean_origin(self, field_name, axis): origin_seed, step, use_letters = fp_obj.y_origin_seed, fp_obj.y_axis_step, self.y_letters # Convert and return the label position using the specified conversion function - cleaned_value = utils.axis_clean_label_conversion(origin_seed, value, step, use_letters) + cleaned_value = general.axis_clean_label_conversion(origin_seed, value, step, use_letters) return int(cleaned_value) if not using_letters else cleaned_value - - def clean_x_origin(self): - """Validate input and convert x_origin to an integer.""" - return self._clean_origin("x_origin", "X") - - def clean_y_origin(self): - """Validate input and convert y_origin to an integer.""" - return self._clean_origin("y_origin", "Y") diff --git a/nautobot_floor_plan/migrations/0008_add_axis_step.py b/nautobot_floor_plan/migrations/0008_add_axis_step.py index 89a98eb..692e297 100644 --- a/nautobot_floor_plan/migrations/0008_add_axis_step.py +++ b/nautobot_floor_plan/migrations/0008_add_axis_step.py @@ -2,7 +2,7 @@ from django.db import migrations, models -import nautobot_floor_plan.utils +import nautobot_floor_plan.utils.general class Migration(migrations.Migration): @@ -14,11 +14,11 @@ class Migration(migrations.Migration): migrations.AddField( model_name="floorplan", name="x_axis_step", - field=models.IntegerField(default=1, validators=[nautobot_floor_plan.utils.validate_not_zero]), + field=models.IntegerField(default=1, validators=[nautobot_floor_plan.utils.general.validate_not_zero]), ), migrations.AddField( model_name="floorplan", name="y_axis_step", - field=models.IntegerField(default=1, validators=[nautobot_floor_plan.utils.validate_not_zero]), + field=models.IntegerField(default=1, validators=[nautobot_floor_plan.utils.general.validate_not_zero]), ), ] diff --git a/nautobot_floor_plan/migrations/0009_add_custom_label_support.py b/nautobot_floor_plan/migrations/0009_add_custom_label_support.py new file mode 100644 index 0000000..afd1deb --- /dev/null +++ b/nautobot_floor_plan/migrations/0009_add_custom_label_support.py @@ -0,0 +1,73 @@ +# Generated by Django 4.2.17 on 2024-12-19 20:44 + +import django.db.models.deletion +from django.db import migrations, models + +import nautobot_floor_plan.utils.custom_validators + + +class Migration(migrations.Migration): + dependencies = [ + ("nautobot_floor_plan", "0008_add_axis_step"), + ] + + operations = [ + migrations.AlterField( + model_name="floorplan", + name="x_axis_labels", + field=models.CharField(default="numbers", max_length=12), + ), + migrations.AlterField( + model_name="floorplan", + name="x_axis_step", + field=models.IntegerField( + default=1, validators=[nautobot_floor_plan.utils.custom_validators.ValidateNotZero(0)] + ), + ), + migrations.AlterField( + model_name="floorplan", + name="y_axis_labels", + field=models.CharField(default="numbers", max_length=12), + ), + migrations.AlterField( + model_name="floorplan", + name="y_axis_step", + field=models.IntegerField( + default=1, validators=[nautobot_floor_plan.utils.custom_validators.ValidateNotZero(0)] + ), + ), + migrations.AddField( + model_name="floorplan", + name="is_tile_movable", + field=models.BooleanField(default=True), + ), + migrations.CreateModel( + name="FloorPlanCustomAxisLabel", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("axis", models.CharField(max_length=1)), + ("label_type", models.CharField(default="letters", max_length=20)), + ("start_label", models.CharField(max_length=10)), + ("end_label", models.CharField(max_length=10)), + ( + "step", + models.IntegerField( + default=1, validators=[nautobot_floor_plan.utils.custom_validators.ValidateNotZero(0)] + ), + ), + ("increment_letter", models.BooleanField(default=True)), + ("order", models.PositiveIntegerField(default=0)), + ( + "floor_plan", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="custom_labels", + to="nautobot_floor_plan.floorplan", + ), + ), + ], + options={ + "ordering": ["floor_plan", "axis", "order"], + }, + ), + ] diff --git a/nautobot_floor_plan/models.py b/nautobot_floor_plan/models.py index 22e29f7..faa3e47 100644 --- a/nautobot_floor_plan/models.py +++ b/nautobot_floor_plan/models.py @@ -7,9 +7,15 @@ from django.db import models, transaction from nautobot.apps.models import PrimaryModel, StatusField, extras_features -from nautobot_floor_plan.choices import AllocationTypeChoices, AxisLabelsChoices, RackOrientationChoices +from nautobot_floor_plan.choices import ( + AllocationTypeChoices, + AxisLabelsChoices, + CustomAxisLabelsChoices, + RackOrientationChoices, +) from nautobot_floor_plan.svg import FloorPlanSVG -from nautobot_floor_plan.utils import validate_not_zero +from nautobot_floor_plan.utils.custom_validators import ValidateNotZero +from nautobot_floor_plan.utils.label_generator import FloorPlanLabelGenerator logger = logging.getLogger(__name__) @@ -51,13 +57,13 @@ class FloorPlan(PrimaryModel): help_text='Relative depth of each "tile" in the floor plan (cm, inches, etc.)', ) x_axis_labels = models.CharField( - max_length=10, + max_length=12, choices=AxisLabelsChoices, default=AxisLabelsChoices.NUMBERS, help_text="Grid labels of X axis (horizontal).", ) y_axis_labels = models.CharField( - max_length=10, + max_length=12, choices=AxisLabelsChoices, default=AxisLabelsChoices.NUMBERS, help_text="Grid labels of Y axis (vertical).", @@ -69,15 +75,16 @@ class FloorPlan(PrimaryModel): validators=[MinValueValidator(0)], default=1, help_text="User defined starting value for grid labeling" ) x_axis_step = models.IntegerField( - validators=[validate_not_zero], + validators=[ValidateNotZero(0)], default=1, help_text="Positive or negative integer that will be used to step labeling.", ) y_axis_step = models.IntegerField( - validators=[validate_not_zero], + validators=[ValidateNotZero(0)], default=1, help_text="Positive or negative integer that will be used to step labeling.", ) + is_tile_movable = models.BooleanField(default=True, help_text="Determines if Tiles can be moved once placed") class Meta: """Metaclass attributes.""" @@ -146,6 +153,128 @@ def validate_no_resizing_with_tiles(self): f"FloorPlan must maintain original size: ({original.x_size}, {original.y_size}), " ) + def generate_labels(self, axis, count): + """ + Generate labels for the specified axis. + + This method creates an instance of FloorPlanLabelGenerator and uses it to generate labels + based on the specified axis and count. It will first check for any custom labels defined + for the axis and use them if available; otherwise, it will generate default labels. + """ + generator = FloorPlanLabelGenerator(self) + return generator.generate_labels(axis, count) + + def reset_seed_for_custom_labels(self): + """Reset seed and step values when custom labels are added.""" + # Only proceed if there are custom labels + if not self.custom_labels.exists(): + return + + changed = False + x_has_custom = self.custom_labels.filter(axis="X").exists() + y_has_custom = self.custom_labels.filter(axis="Y").exists() + + if x_has_custom and (self.x_origin_seed != 1 or self.x_axis_step != 1): + self.x_origin_seed = 1 + self.x_axis_step = 1 + changed = True + + if y_has_custom and (self.y_origin_seed != 1 or self.y_axis_step != 1): + self.y_origin_seed = 1 + self.y_axis_step = 1 + changed = True + + if changed: + # Get the current values before updating + initial_instance = self.__class__.objects.get(pk=self.pk) + x_initial = initial_instance.x_origin_seed + y_initial = initial_instance.y_origin_seed + + # Update tile positions only for axes that have custom labels + tiles = self.update_tile_origins( + x_initial=x_initial if x_has_custom else self.x_origin_seed, + x_updated=1 if x_has_custom else self.x_origin_seed, + y_initial=y_initial if y_has_custom else self.y_origin_seed, + y_updated=1 if y_has_custom else self.y_origin_seed, + ) + + # Save without triggering another reset + super().save() + + # Update tiles + for tile in tiles: + tile.validated_save() + + +@extras_features( + "custom_fields", + "custom_validators", + "graphql", + "relationships", + "webhooks", +) +class FloorPlanCustomAxisLabel(models.Model): + """Model allowing for the creation of custom grid labels.""" + + floor_plan = models.ForeignKey( + to="FloorPlan", + on_delete=models.CASCADE, + related_name="custom_labels", + ) + axis = models.CharField( + max_length=1, + choices=(("X", "X Axis"), ("Y", "Y Axis")), + ) + label_type = models.CharField( + max_length=20, + choices=CustomAxisLabelsChoices, + default=AxisLabelsChoices.LETTERS, + help_text="Type of labeling system to use", + ) + start_label = models.CharField( + max_length=10, + help_text="Starting label for this custom label range.", + ) + end_label = models.CharField( + max_length=10, + help_text="Ending label for this custom label range.", + ) + step = models.IntegerField( + validators=[ValidateNotZero(0)], + default=1, + help_text="Positive or negative step for this label range.", + ) + increment_letter = models.BooleanField( + default=True, + help_text="For letter-based labels, determines increment pattern.", + ) + + order = models.PositiveIntegerField( + default=0, + help_text="Order of the custom label range.", + ) + + class Meta: + """Meta attributes.""" + + ordering = ["floor_plan", "axis", "order"] + + def save(self, *args, **kwargs): + """Override save to reset seed values when custom labels are added.""" + super().save(*args, **kwargs) + # Reset the corresponding seed value to 1 + self.floor_plan.reset_seed_for_custom_labels() + + def clean(self): + """Add validation to ensure seed values are reset.""" + super().clean() + # If this is a new custom label (no pk) or the axis has changed + if not self.pk or (self.pk and self._state.fields_cache.get("axis") != self.axis): + if self.axis == "X" and self.floor_plan.x_origin_seed != 1: + self.floor_plan.x_origin_seed = 1 + elif self.axis == "Y" and self.floor_plan.y_origin_seed != 1: + self.floor_plan.y_origin_seed = 1 + @extras_features( "custom_fields", @@ -157,8 +286,6 @@ def validate_no_resizing_with_tiles(self): "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.""" diff --git a/nautobot_floor_plan/svg.py b/nautobot_floor_plan/svg.py index 3718eea..9f4fc6e 100644 --- a/nautobot_floor_plan/svg.py +++ b/nautobot_floor_plan/svg.py @@ -9,8 +9,7 @@ from django.utils.http import urlencode from nautobot.core.templatetags.helpers import fgcolor -from nautobot_floor_plan.choices import AllocationTypeChoices, AxisLabelsChoices, RackOrientationChoices -from nautobot_floor_plan.utils import grid_number_to_letter +from nautobot_floor_plan.choices import AllocationTypeChoices, RackOrientationChoices logger = logging.getLogger(__name__) @@ -28,7 +27,6 @@ class FloorPlanSVG: RACK_TILE_INSET = 3 RACK_FRONT_DEPTH = 15 RACK_BUTTON_OFFSET = 5 - RACK_BORDER_OFFSET = 8 RACK_ORIENTATION_OFFSET = 14 RACKGROUP_TEXT_OFFSET = 12 Y_LABEL_TEXT_OFFSET = 34 @@ -88,39 +86,28 @@ def _setup_drawing(self, width, depth): return drawing - def _label_text(self, label_text_out, floor_plan, label_text_in, is_letters): - """Change label based off defined increment or decrement step.""" - if label_text_out == floor_plan["seed"]: - return label_text_out - label_text_out = label_text_in + floor_plan["step"] - - # Handle negative values and wrapping - if is_letters and label_text_out <= 0: - label_text_out = 18278 if label_text_in == 0 else 18278 + (label_text_in + floor_plan["step"]) - - return label_text_out - - def _draw_tile_link(self, drawing, axis, x_letters, y_letters): + def _draw_tile_link(self, drawing, axis): + """Draw a '+' link for adding a new tile at the specified grid position.""" query_params = urlencode( { "floor_plan": self.floor_plan.pk, - "x_origin": grid_number_to_letter(axis["x"]) if x_letters else axis["x"], - "y_origin": grid_number_to_letter(axis["y"]) if y_letters else axis["y"], + "x_origin": axis["x"], + "y_origin": axis["y"], "return_url": self.return_url, } ) add_url = f"{self.add_url}?{query_params}" add_link = drawing.add(drawing.a(href=add_url, target="_top")) + # Use grid indices for positioning + x_pos = axis["x_idx"] + y_pos = axis["y_idx"] + add_link.add( drawing.rect( ( - (axis["x"] - self.floor_plan.x_origin_seed + 0.5) * self.GRID_SIZE_X - + self.GRID_OFFSET - - (self.TEXT_LINE_HEIGHT / 2), - (axis["y"] - self.floor_plan.y_origin_seed + 0.5) * self.GRID_SIZE_Y - + self.GRID_OFFSET - - (self.TEXT_LINE_HEIGHT / 2), + (x_pos + 0.5) * self.GRID_SIZE_X + self.GRID_OFFSET - (self.TEXT_LINE_HEIGHT / 2), + (y_pos + 0.5) * self.GRID_SIZE_Y + self.GRID_OFFSET - (self.TEXT_LINE_HEIGHT / 2), ), (self.TEXT_LINE_HEIGHT, self.TEXT_LINE_HEIGHT), class_="add-tile-button", @@ -131,8 +118,8 @@ def _draw_tile_link(self, drawing, axis, x_letters, y_letters): drawing.text( "+", insert=( - (axis["x"] - self.floor_plan.x_origin_seed + 0.5) * self.GRID_SIZE_X + self.GRID_OFFSET, - (axis["y"] - self.floor_plan.y_origin_seed + 0.5) * self.GRID_SIZE_Y + self.GRID_OFFSET, + (x_pos + 0.5) * self.GRID_SIZE_X + self.GRID_OFFSET, + (y_pos + 0.5) * self.GRID_SIZE_Y + self.GRID_OFFSET, ), class_="button-text", ) @@ -140,25 +127,13 @@ def _draw_tile_link(self, drawing, axis, x_letters, y_letters): def _draw_grid(self, drawing): """Render the grid underlying all tiles.""" - # Set inital values for x and y axis label location - x_letters = self.floor_plan.x_axis_labels == AxisLabelsChoices.LETTERS - y_letters = self.floor_plan.y_axis_labels == AxisLabelsChoices.LETTERS - x_floor_plan = {"seed": self.floor_plan.x_origin_seed, "step": self.floor_plan.x_axis_step} - y_floor_plan = {"seed": self.floor_plan.y_origin_seed, "step": self.floor_plan.y_axis_step} - # Initial states for labels - x_label_text = 0 - y_label_text = 0 - max_y_length = max( - len(str(self._label_text(y, y_floor_plan, 0, y_letters))) - for y in range(self.floor_plan.y_origin_seed, self.floor_plan.y_size + self.floor_plan.y_origin_seed) - ) - y_label_text_offset = ( - self.Y_LABEL_TEXT_OFFSET - (6 - len(str(self.floor_plan.y_origin_seed))) if max_y_length > 1 else 0 - ) - if max_y_length >= 4: - y_label_text_offset = self.Y_LABEL_TEXT_OFFSET + 4 + self._draw_grid_lines(drawing) + x_labels, y_labels = self._generate_axis_labels() + self._draw_axis_labels(drawing, x_labels, y_labels) + self._draw_tile_links(drawing, x_labels, y_labels) - # Draw grid lines + def _draw_grid_lines(self, drawing): + """Draw the vertical and horizontal grid lines.""" for x in range(0, self.floor_plan.x_size + 1): drawing.add( drawing.line( @@ -182,107 +157,71 @@ def _draw_grid(self, drawing): ) ) - # Draw axis labels and links - for x in range(self.floor_plan.x_origin_seed, self.floor_plan.x_size + self.floor_plan.x_origin_seed): - x_label_text = self._label_text(x, x_floor_plan, x_label_text, x_letters) - label = grid_number_to_letter(x_label_text) if x_letters else str(x_label_text) + def _generate_axis_labels(self): + """Generate labels for the X and Y axes.""" + x_labels = self.floor_plan.generate_labels("X", self.floor_plan.x_size) + y_labels = self.floor_plan.generate_labels("Y", self.floor_plan.y_size) + return x_labels, y_labels + + def _draw_axis_labels(self, drawing, x_labels, y_labels): + """Draw labels on the X and Y axes.""" + for idx, label in enumerate(x_labels): drawing.add( drawing.text( label, insert=( - (x - self.floor_plan.x_origin_seed + 0.5) * self.GRID_SIZE_X + self.GRID_OFFSET, + (idx + 0.5) * self.GRID_SIZE_X + self.GRID_OFFSET, self.BORDER_WIDTH + self.TEXT_LINE_HEIGHT / 2, ), class_="grid-label", ) ) + max_y_length = max(len(str(label)) for label in y_labels) + y_label_text_offset = self._calculate_y_label_offset(max_y_length) - for y in range(self.floor_plan.y_origin_seed, self.floor_plan.y_size + self.floor_plan.y_origin_seed): - y_label_text = self._label_text(y, y_floor_plan, y_label_text, y_letters) - label = grid_number_to_letter(y_label_text) if y_letters else str(y_label_text) + for idx, label in enumerate(y_labels): drawing.add( drawing.text( label, insert=( self.BORDER_WIDTH + self.TEXT_LINE_HEIGHT / 2 - y_label_text_offset, - (y - self.floor_plan.y_origin_seed + 0.5) * self.GRID_SIZE_Y + self.GRID_OFFSET, + (idx + 0.5) * self.GRID_SIZE_Y + self.GRID_OFFSET, ), class_="grid-label", ) ) - for y in range(self.floor_plan.y_origin_seed, self.floor_plan.y_size + self.floor_plan.y_origin_seed): - for x in range(self.floor_plan.x_origin_seed, self.floor_plan.x_size + self.floor_plan.x_origin_seed): - axis = {"x": x, "y": y} - self._draw_tile_link(drawing, axis, x_letters, y_letters) - - def _draw_edit_delete_button(self, drawing, tile, button_offset, grid_offset): - if tile.allocation_type == AllocationTypeChoices.RACK: - tile_inset = 0 - else: - tile_inset = self.TILE_INSET - - origin = ( - (tile.x_origin - self.floor_plan.x_origin_seed) * self.GRID_SIZE_X + self.GRID_OFFSET + tile_inset, - (tile.y_origin - self.floor_plan.y_origin_seed) * self.GRID_SIZE_Y + self.GRID_OFFSET + tile_inset, - ) - - # Add a button for editing the tile definition - edit_url = reverse("plugins:nautobot_floor_plan:floorplantile_edit", kwargs={"pk": tile.pk}) - query_params = urlencode({"return_url": self.return_url}) - edit_url = f"{self.base_url}{edit_url}?{query_params}" - link = drawing.add(drawing.a(href=edit_url, target="_top")) - link.add( - drawing.rect( - (origin[0] + self.TILE_INSET + button_offset, origin[1] + self.TILE_INSET + grid_offset), - (self.TEXT_LINE_HEIGHT, self.TEXT_LINE_HEIGHT), - class_="edit-tile-button", - rx=self.CORNER_RADIUS, - ) - ) - link.add( - drawing.text( - "✎", - insert=( - origin[0] + self.TILE_INSET + self.TEXT_LINE_HEIGHT / 2 + button_offset, - origin[1] + self.TILE_INSET + self.TEXT_LINE_HEIGHT / 2 + grid_offset, - ), - class_="button-text", - ) - ) - - # Add a button for deleting the tile definition - delete_url = reverse("plugins:nautobot_floor_plan:floorplantile_delete", kwargs={"pk": tile.pk}) - query_params = urlencode({"return_url": self.return_url}) - delete_url = f"{self.base_url}{delete_url}?{query_params}" - link = drawing.add(drawing.a(href=delete_url, target="_top")) - link.add( - drawing.rect( - ( - origin[0] - + tile.x_size * self.GRID_SIZE_X - - self.RACK_TILE_INSET * self.TILE_INSET - - self.TEXT_LINE_HEIGHT, - origin[1] + self.TILE_INSET + grid_offset, - ), - (self.TEXT_LINE_HEIGHT, self.TEXT_LINE_HEIGHT), - class_="delete-tile-button", - rx=self.CORNER_RADIUS, - ) - ) - link.add( - drawing.text( - "X", - insert=( - origin[0] - + tile.x_size * self.GRID_SIZE_X - - self.RACK_TILE_INSET * self.TILE_INSET - - self.TEXT_LINE_HEIGHT / 2, - origin[1] + self.TILE_INSET + self.TEXT_LINE_HEIGHT / 2 + grid_offset, - ), - class_="button-text", - ) + def _calculate_y_label_offset(self, max_y_length): + """Calculate the offset for Y-axis labels.""" + # Add prefix length for binary (0b) and hex (0x) labels when calculating max length + adjusted_length = max_y_length + if str(self.floor_plan.y_origin_seed).startswith(("0b", "0x")): + adjusted_length = max_y_length + 2 + # Base offset calculation + base_offset = ( + self.Y_LABEL_TEXT_OFFSET - (6 - len(str(self.floor_plan.y_origin_seed))) if adjusted_length > 1 else 0 ) + # Calculate additional offset + # Add 1 to additional offset for 02WW scenario + if adjusted_length == 4: + adjusted_length = adjusted_length + 1 + if adjusted_length > 4: + # Add 10 for each increment of 2 beyond 4 and handle odd cases + additional_offset = ((adjusted_length - 4 + 1) // 2) * 10 + else: + additional_offset = 0 + return base_offset + additional_offset + + def _draw_tile_links(self, drawing, x_labels, y_labels): + """Draw links for each tile in the grid.""" + for y_idx, y_label in enumerate(y_labels): + for x_idx, x_label in enumerate(x_labels): + try: + axis = {"x": x_label, "y": y_label, "x_idx": x_idx, "y_idx": y_idx} + self._draw_tile_link(drawing, axis) + except (ValueError, TypeError) as e: + logger.warning("Error processing grid position (%s, %s): %s", x_idx, y_idx, e) + continue def _draw_tile(self, drawing, tile): """Render an individual FloorPlanTile to the drawing.""" @@ -319,26 +258,6 @@ def _draw_underlay_tiles(self, drawing, tile): class_="tile-status", ) ) - # TODO Make this a user option in the future or remove. It would draw a smaller status box around a Rack tile. - # Tile contains a rack and is being placed on a group of rackgroup tiles or status tiles - # else: - # origin = ( - # (tile.x_origin - 1) * self.GRID_SIZE_X + self.GRID_OFFSET + self.BORDER_WIDTH, - # (tile.y_origin - 1) * self.GRID_SIZE_Y + self.GRID_OFFSET + self.BORDER_WIDTH, - # ) - # # Draw the tile outline and fill it with its status color - # drawing.add( - # drawing.rect( - # origin, - # ( - # self.GRID_SIZE_X * tile.x_size - self.BORDER_WIDTH * self.TILE_INSET, - # self.GRID_SIZE_Y * tile.y_size - self.RACK_BORDER_OFFSET * self.TILE_INSET, - # ), - # rx=self.CORNER_RADIUS, - # style=f"fill: #{tile.status.color}", - # class_="tile-status", - # ) - # ) def _draw_defined_rackgroup_tile(self, drawing, tile): """Add Status and RackGroup text to a rendered tile.""" @@ -549,6 +468,74 @@ def _draw_rack_tile(self, drawing, tile): if tile.on_group_tile is True: self._draw_edit_delete_button(drawing, tile, self.RACK_BUTTON_OFFSET, self.GRID_OFFSET) + def _draw_edit_delete_button(self, drawing, tile, button_offset, grid_offset): + if tile.allocation_type == AllocationTypeChoices.RACK: + tile_inset = 0 + else: + tile_inset = self.TILE_INSET + + origin = ( + (tile.x_origin - self.floor_plan.x_origin_seed) * self.GRID_SIZE_X + self.GRID_OFFSET + tile_inset, + (tile.y_origin - self.floor_plan.y_origin_seed) * self.GRID_SIZE_Y + self.GRID_OFFSET + tile_inset, + ) + + # Add a button for editing the tile definition + edit_url = reverse("plugins:nautobot_floor_plan:floorplantile_edit", kwargs={"pk": tile.pk}) + query_params = urlencode({"return_url": self.return_url}) + edit_url = f"{self.base_url}{edit_url}?{query_params}" + link = drawing.add(drawing.a(href=edit_url, target="_top")) + link.add( + drawing.rect( + (origin[0] + self.TILE_INSET + button_offset, origin[1] + self.TILE_INSET + grid_offset), + (self.TEXT_LINE_HEIGHT, self.TEXT_LINE_HEIGHT), + class_="edit-tile-button", + rx=self.CORNER_RADIUS, + ) + ) + link.add( + drawing.text( + "✎", + insert=( + origin[0] + self.TILE_INSET + self.TEXT_LINE_HEIGHT / 2 + button_offset, + origin[1] + self.TILE_INSET + self.TEXT_LINE_HEIGHT / 2 + grid_offset, + ), + class_="button-text", + ) + ) + + # Add a button for deleting the tile definition + delete_url = reverse("plugins:nautobot_floor_plan:floorplantile_delete", kwargs={"pk": tile.pk}) + query_params = urlencode({"return_url": self.return_url}) + delete_url = f"{self.base_url}{delete_url}?{query_params}" + link = drawing.add(drawing.a(href=delete_url, target="_top")) + link.add( + drawing.rect( + ( + origin[0] + + tile.x_size * self.GRID_SIZE_X + - self.RACK_TILE_INSET * self.TILE_INSET + - self.TEXT_LINE_HEIGHT, + origin[1] + self.TILE_INSET + grid_offset, + ), + (self.TEXT_LINE_HEIGHT, self.TEXT_LINE_HEIGHT), + class_="delete-tile-button", + rx=self.CORNER_RADIUS, + ) + ) + link.add( + drawing.text( + "X", + insert=( + origin[0] + + tile.x_size * self.GRID_SIZE_X + - self.RACK_TILE_INSET * self.TILE_INSET + - self.TEXT_LINE_HEIGHT / 2, + origin[1] + self.TILE_INSET + self.TEXT_LINE_HEIGHT / 2 + grid_offset, + ), + class_="button-text", + ) + ) + def render(self): """Generate an SVG document representing a FloorPlan.""" logger.debug("Setting up drawing...") diff --git a/nautobot_floor_plan/tables.py b/nautobot_floor_plan/tables.py index bbe7312..386d22e 100644 --- a/nautobot_floor_plan/tables.py +++ b/nautobot_floor_plan/tables.py @@ -5,7 +5,11 @@ from nautobot.core.templatetags.helpers import hyperlinked_object from nautobot_floor_plan import models -from nautobot_floor_plan.templatetags.seed_helpers import grid_location_conversion, seed_conversion +from nautobot_floor_plan.templatetags.seed_helpers import ( + render_axis_origin, + render_axis_step, + render_origin_seed, +) class FloorPlanTable(BaseTable): @@ -15,8 +19,8 @@ class FloorPlanTable(BaseTable): pk = ToggleColumn() floor_plan = tables.Column(empty_values=[], orderable=False) location = tables.Column(linkify=True) - x_origin_seed = tables.Column() - y_origin_seed = tables.Column() + x_origin_seed = tables.Column(verbose_name="X Origin Seed") + y_origin_seed = tables.Column(verbose_name="Y Origin Seed") tags = TagColumn() actions = ButtonsColumn(models.FloorPlan) @@ -25,12 +29,20 @@ def render_floor_plan(self, record): return hyperlinked_object(record) def render_x_origin_seed(self, record): - """Render x_origin in letters if requried.""" - return seed_conversion(record, "x") + """Render x_origin seed or converted custom start label if defined.""" + return render_origin_seed(record, "x") + + def render_x_axis_step(self, record): + """Render x_axis step or custom step if defined.""" + return render_axis_step(record, "x") def render_y_origin_seed(self, record): - """Render y_origin in letters if requried.""" - return seed_conversion(record, "y") + """Render y_origin seed or converted custom start label if defined.""" + return render_origin_seed(record, "y") + + def render_y_axis_step(self, record): + """Render y_axis step or custom step if defined.""" + return render_axis_step(record, "y") class Meta(BaseTable.Meta): """Meta attributes.""" @@ -87,12 +99,12 @@ def render_floor_plan_tile(self, record): return hyperlinked_object(record) def render_x_origin(self, record): - """Render x_origin in letters if requried.""" - return grid_location_conversion(record, "x") + """Render x_origin using the generalized render_axis_origin method.""" + return render_axis_origin(record, "X") def render_y_origin(self, record): - """Render y_origin in letters if requried.""" - return grid_location_conversion(record, "y") + """Render y_origin using the generalized render_axis_origin method.""" + return render_axis_origin(record, "Y") class Meta(BaseTable.Meta): """Meta attributes.""" diff --git a/nautobot_floor_plan/templates/nautobot_floor_plan/floorplan_create.html b/nautobot_floor_plan/templates/nautobot_floor_plan/floorplan_create.html new file mode 100644 index 0000000..0640219 --- /dev/null +++ b/nautobot_floor_plan/templates/nautobot_floor_plan/floorplan_create.html @@ -0,0 +1,105 @@ +{% extends 'generic/object_create.html' %} +{% load static %} + +{% load form_helpers %} +{% load seed_helpers %} + +{% block form %} +
+
Floor Plan
+
+ {% for field_name in form.fieldsets.0.1 %} + {% render_field form|get_fieldset_field:field_name %} + {% endfor %} +
+
+ +
+
X Axis Settings
+
+ +
+
+ +
+
Y Axis Settings
+
+ +
+
+ + + +{% include 'inc/extras_features_edit_form_fields.html' %} +{% endblock %} diff --git a/nautobot_floor_plan/templates/nautobot_floor_plan/floorplan_retrieve.html b/nautobot_floor_plan/templates/nautobot_floor_plan/floorplan_retrieve.html index e2c6846..3962a4a 100644 --- a/nautobot_floor_plan/templates/nautobot_floor_plan/floorplan_retrieve.html +++ b/nautobot_floor_plan/templates/nautobot_floor_plan/floorplan_retrieve.html @@ -36,27 +36,27 @@ X Axis Labels - {{ object.x_axis_labels }} + {{ object|render_axis_label:'x' }} X Axis Seed - {{ object|seed_conversion:'x' }} + {{ object|render_origin_seed:'x' }} X Axis Step - {{ object.x_axis_step }} + {{ object|render_axis_step:'x' }} Y Axis Labels - {{ object.y_axis_labels }} + {{ object|render_axis_label:'y' }} Y Axis Seed - {{ object|seed_conversion:'y' }} + {{ object|render_origin_seed:'y' }} Y Axis Step - {{ object.y_axis_step }} + {{ object|render_axis_step:'y' }} Tile(s) diff --git a/nautobot_floor_plan/templatetags/seed_helpers.py b/nautobot_floor_plan/templatetags/seed_helpers.py index d8d1606..344afee 100644 --- a/nautobot_floor_plan/templatetags/seed_helpers.py +++ b/nautobot_floor_plan/templatetags/seed_helpers.py @@ -2,7 +2,8 @@ from django import template -from nautobot_floor_plan import choices, utils +from nautobot_floor_plan import choices +from nautobot_floor_plan.utils import general, label_converters register = template.Library() @@ -14,21 +15,43 @@ def seed_conversion(floor_plan, axis): seed = getattr(floor_plan, f"{axis}_origin_seed") if letters == choices.AxisLabelsChoices.LETTERS: - seed = utils.grid_number_to_letter(seed) + seed = general.grid_number_to_letter(seed) return f"{seed}" @register.filter() -def grid_location_conversion(floor_plan_tile, axis): - """Convert FloorPlanTile coordinate to letter if necessary.""" - letters = getattr(floor_plan_tile.floor_plan, f"{axis}_axis_labels") - grid = getattr(floor_plan_tile, f"{axis}_origin") +def render_axis_origin(record, axis): + """ + Generalized function to render axis origin using PositionToLabelConverter or default conversion. - if letters == choices.AxisLabelsChoices.LETTERS: - grid = utils.grid_number_to_letter(grid) + Args: + record: The record containing the axis origin. + axis (str): The axis ('X' or 'Y'). + + Returns: + str: The converted axis label. + """ + # Determine axis-specific attributes + axis_seed = getattr(record.floor_plan, f"{axis.lower()}_origin_seed") + axis_step = getattr(record.floor_plan, f"{axis.lower()}_axis_step") + axis_labels_choice = getattr(record.floor_plan, f"{axis.lower()}_axis_labels") + origin_value = getattr(record, f"{axis.lower()}_origin") + + # Check if custom labels exist for the axis + if record.floor_plan.custom_labels.filter(axis=axis).exists(): + converter = label_converters.PositionToLabelConverter(origin_value, axis, record.floor_plan) + return converter.convert() + + is_letters = axis_labels_choice == choices.AxisLabelsChoices.LETTERS + origin_value_str = str(origin_value) # Convert to string for checking - return f"{grid}" + if is_letters: + converted_location = axis_seed + (int(origin_value_str) - axis_seed) * axis_step + return general.letter_conversion(converted_location) # Return the numeric value directly + + # Proceed with axis_init_label_conversion if it's not a digit + return general.axis_init_label_conversion(axis_seed, origin_value_str, axis_step, is_letters) @register.filter() @@ -36,3 +59,52 @@ def count_children_floor_plans(location): """Returns count of Children with FloorPlans for a given location.""" count = location.children.filter(floor_plan__isnull=False).count() return count + + +@register.filter() +def get_fieldset_field(form, field_name): + """Retrieve a field from the form using a dynamic field name.""" + try: + return form[field_name] + except KeyError: + return None + + +@register.filter +def render_origin_seed(obj, axis): + """Render custom seed info for the specified axis if it exists, otherwise display the default seed.""" + custom_label = obj.custom_labels.filter(axis=axis.upper()).first() + if custom_label: + try: + converter = label_converters.LabelConverterFactory.get_converter(custom_label.label_type) + # Convert and display the custom label start + display_label = converter.from_numeric(int(custom_label.start_label)) + + # Preserve prefix for numalpha labels + if custom_label.label_type == choices.CustomAxisLabelsChoices.NUMALPHA: + prefix, _ = general.extract_prefix_and_letter(custom_label.start_label) + _, letters = general.extract_prefix_and_letter(display_label) + return f"{prefix}{letters}" + return display_label + except ValueError: + return custom_label.start_label + # Fall back to default seed + return seed_conversion(obj, axis.lower()) + + +@register.filter +def render_axis_label(obj, axis): + """Render custom label for the specified axis if it exists, otherwise display the default label.""" + custom_label = obj.custom_labels.filter(axis=axis.upper()).first() + if custom_label: + return custom_label.label_type + return getattr(obj, f"{axis.lower()}_axis_labels") + + +@register.filter +def render_axis_step(obj, axis): + """Render custom step for the specified axis if it exists, otherwise display the default step.""" + custom_label = obj.custom_labels.filter(axis=axis.upper()).first() + if custom_label: + return custom_label.step + return getattr(obj, f"{axis.lower()}_axis_step") diff --git a/nautobot_floor_plan/tests/test_converters.py b/nautobot_floor_plan/tests/test_converters.py new file mode 100644 index 0000000..9259163 --- /dev/null +++ b/nautobot_floor_plan/tests/test_converters.py @@ -0,0 +1,360 @@ +"""Test floorplan converters.""" + +from nautobot.core.testing import TestCase + +from nautobot_floor_plan import choices, forms +from nautobot_floor_plan.tests import fixtures +from nautobot_floor_plan.utils import general, label_converters + + +class TestLabelConverters(TestCase): + """Test custom label conversion utilities.""" + + def setUp(self): + """Create test data.""" + data = fixtures.create_prerequisites() + self.floors = data["floors"] + + def test_binary_converter(self): + """Test binary label conversion.""" + converter = label_converters.BinaryConverter() + test_cases = [ + ("0b0001", 1), + ("0b0010", 2), + ("0b1010", 10), + ] + for label, number in test_cases: + self.assertEqual(converter.to_numeric(label), number) + self.assertEqual(converter.from_numeric(number), label) + + def test_hex_converter(self): + """Test Hexadecimal label conversion.""" + converter = label_converters.HexConverter() + test_cases = [ + ("0x0001", 1), + ("0x000A", 10), + ("0x000F", 15), + ] + for label, number in test_cases: + self.assertEqual(converter.to_numeric(label), number) + self.assertEqual(converter.from_numeric(number), label) + + def test_numalpha_converter(self): + """Test numalpha label conversion.""" + converter = label_converters.NumalphaConverter() + test_cases = [ + ("07A", 1), + ("07B", 2), + ("07Z", 26), + ("08A", 1), + ] + for label, expected_number in test_cases: + # Test conversion to numeric + self.assertEqual(converter.to_numeric(label), expected_number) + + # Test conversion back to label (with prefix preservation) + prefix, _ = general.extract_prefix_and_letter(label) + converted_label = converter.from_numeric(expected_number, prefix=prefix) + self.assertEqual(converted_label, label) + + # Test prefix extraction + extracted_prefix, letter = general.extract_prefix_and_letter(label) + self.assertEqual(extracted_prefix + letter, label) + + def test_roman_converter(self): + """Test roman numeral conversion.""" + converter = label_converters.RomanConverter() + test_cases = [ + ("I", 1), + ("V", 5), + ("X", 10), + ("L", 50), + ] + for label, number in test_cases: + self.assertEqual(converter.to_numeric(label), number) + self.assertEqual(converter.from_numeric(number), label) + + def test_alphanumeric_converter(self): + """Test alphanumeric label conversion.""" + converter = label_converters.AlphanumericConverter() + + # Test with increment_letter=True (default, incrementing numbers) + converter.set_increment_prefix(False) + converter.to_numeric("A1") # Initialize format without leading zeros + self.assertEqual(converter.from_numeric(1), "A1") + self.assertEqual(converter.from_numeric(5), "A5") + self.assertEqual(converter.to_numeric("A1"), 1) + self.assertEqual(converter.to_numeric("A5"), 5) + + # Test with increment_letter=False (incrementing prefix) + converter.set_increment_prefix(True) + converter.to_numeric("A1") # Initialize format without leading zeros + self.assertEqual(converter.from_numeric(1), "A1") + self.assertEqual(converter.from_numeric(2), "B1") + self.assertEqual(converter.to_numeric("A1"), 1) + self.assertEqual(converter.to_numeric("B1"), 2) + + # Test with leading zeros + converter.set_increment_prefix(False) + converter.to_numeric("A01") # Initialize format with leading zeros + self.assertEqual(converter.from_numeric(1), "A01") + self.assertEqual(converter.from_numeric(5), "A05") + + # Test prefix incrementing with leading zeros + converter.set_increment_prefix(True) + converter.to_numeric("A01") # Initialize format with leading zeros + self.assertEqual(converter.from_numeric(1), "A01") + self.assertEqual(converter.from_numeric(2), "B01") + + # Test invalid input + with self.assertRaises(ValueError): + converter.to_numeric("ABC") # No number + + +class TestPositionAndLabelConverters(TestCase): + """Test position-to-label and label-to-position conversion.""" + + def setUp(self): + """Create test data.""" + data = fixtures.create_prerequisites() + self.floors = data["floors"] + + # Create and save a form instance + self.form = forms.FloorPlanForm( + data={ + "location": self.floors[1].pk, + "x_size": 10, + "y_size": 20, + "tile_depth": 1, + "tile_width": 2, + "x_origin_seed": 1, + "x_axis_step": 1, + "x_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "x_custom_ranges": "null", + "y_origin_seed": 1, + "y_axis_step": 1, + "y_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "y_custom_ranges": "null", + } + ) + self.assertTrue(self.form.is_valid(), msg=self.form.errors) + self.floor_plan = self.form.save(commit=True) # Save the instance to the database + + def test_numeric_ranges(self): + """Test numeric ranges with both ascending and descending steps.""" + numeric_ranges = [ + {"start": "01", "end": "05", "step": 1, "increment_letter": False, "label_type": "numbers"}, + {"start": "15", "end": "11", "step": -1, "increment_letter": False, "label_type": "numbers"}, + ] + self.form.create_custom_axis_labels(numeric_ranges, self.floor_plan, axis="X") + + # Test position to label conversion + test_cases = [ + {"position": 1, "expected": "01"}, + {"position": 3, "expected": "03"}, + {"position": 5, "expected": "05"}, + {"position": 6, "expected": "15"}, + {"position": 8, "expected": "13"}, + {"position": 10, "expected": "11"}, + ] + self._test_position_to_label_conversion(test_cases) + self._test_label_to_position_conversion(test_cases) + self._test_out_of_range_values() + + def test_alphanumeric_ranges(self): + """Test alphanumeric ranges with both ascending and descending steps.""" + alphanumeric_ranges = [ + {"start": "A01", "end": "A05", "step": 1, "increment_letter": False, "label_type": "alphanumeric"}, + {"start": "B05", "end": "B01", "step": -1, "increment_letter": False, "label_type": "alphanumeric"}, + ] + self.form.create_custom_axis_labels(alphanumeric_ranges, self.floor_plan, axis="X") + + test_cases = [ + {"position": 1, "expected": "A01"}, + {"position": 3, "expected": "A03"}, + {"position": 5, "expected": "A05"}, + {"position": 6, "expected": "B05"}, + {"position": 8, "expected": "B03"}, + {"position": 10, "expected": "B01"}, + ] + self._test_position_to_label_conversion(test_cases) + self._test_label_to_position_conversion(test_cases) + self._test_out_of_range_values() + + def test_alphanumeric_incrementing_prefix_ranges(self): + """Test alphanumeric ranges with both ascending and descending steps and incrementing prefix.""" + alphanumeric_ranges = [ + {"start": "A01", "end": "E01", "step": 1, "increment_letter": True, "label_type": "alphanumeric"}, + {"start": "F05", "end": "F01", "step": -1, "increment_letter": False, "label_type": "alphanumeric"}, + ] + self.form.create_custom_axis_labels(alphanumeric_ranges, self.floor_plan, axis="X") + + test_cases = [ + {"position": 1, "expected": "A01"}, + {"position": 3, "expected": "C01"}, + {"position": 5, "expected": "E01"}, + {"position": 6, "expected": "F05"}, + {"position": 8, "expected": "F03"}, + {"position": 10, "expected": "F01"}, + ] + self._test_position_to_label_conversion(test_cases) + self._test_label_to_position_conversion(test_cases) + self._test_out_of_range_values() + + def test_numalpha_ranges(self): + """Test numalpha ranges with both ascending and descending steps.""" + numalpha_ranges = [ + {"start": "02A", "end": "02E", "step": 1, "increment_letter": True, "label_type": "numalpha"}, + {"start": "03E", "end": "03A", "step": -1, "increment_letter": True, "label_type": "numalpha"}, + ] + self.form.create_custom_axis_labels(numalpha_ranges, self.floor_plan, axis="X") + + test_cases = [ + {"position": 1, "expected": "02A"}, + {"position": 3, "expected": "02C"}, + {"position": 5, "expected": "02E"}, + {"position": 6, "expected": "03E"}, + {"position": 8, "expected": "03C"}, + {"position": 10, "expected": "03A"}, + ] + self._test_position_to_label_conversion(test_cases) + self._test_label_to_position_conversion(test_cases) + self._test_out_of_range_values() + + def test_letter_ranges(self): + """Test letter ranges with both ascending and descending steps.""" + letter_ranges = [ + {"start": "A", "end": "E", "step": 1, "increment_letter": True, "label_type": "letters"}, + {"start": "K", "end": "G", "step": -1, "increment_letter": True, "label_type": "letters"}, + ] + self.form.create_custom_axis_labels(letter_ranges, self.floor_plan, axis="X") + + test_cases = [ + {"position": 1, "expected": "A"}, + {"position": 3, "expected": "C"}, + {"position": 5, "expected": "E"}, + {"position": 6, "expected": "K"}, + {"position": 8, "expected": "I"}, + {"position": 10, "expected": "G"}, + ] + self._test_position_to_label_conversion(test_cases) + self._test_label_to_position_conversion(test_cases) + self._test_out_of_range_values() + + def test_roman_ranges(self): + """Test roman numeral ranges with both ascending and descending steps.""" + roman_ranges = [ + {"start": "I", "end": "V", "step": 1, "increment_letter": True, "label_type": "roman"}, + {"start": "X", "end": "VI", "step": -1, "increment_letter": True, "label_type": "roman"}, + ] + self.form.create_custom_axis_labels(roman_ranges, self.floor_plan, axis="X") + + test_cases = [ + {"position": 1, "expected": "I"}, + {"position": 3, "expected": "III"}, + {"position": 5, "expected": "V"}, + {"position": 6, "expected": "X"}, + {"position": 8, "expected": "VIII"}, + {"position": 10, "expected": "VI"}, + ] + self._test_position_to_label_conversion(test_cases) + self._test_label_to_position_conversion(test_cases) + self._test_out_of_range_values() + + def test_hex_ranges(self): + """Test hexadecimal ranges with both ascending and descending steps.""" + hex_ranges = [ + {"start": "1", "end": "5", "step": 1, "increment_letter": True, "label_type": "hex"}, + {"start": "10", "end": "6", "step": -1, "increment_letter": True, "label_type": "hex"}, + ] + self.form.create_custom_axis_labels(hex_ranges, self.floor_plan, axis="X") + + test_cases = [ + {"position": 1, "expected": "0x0001"}, + {"position": 3, "expected": "0x0003"}, + {"position": 5, "expected": "0x0005"}, + {"position": 6, "expected": "0x000A"}, + {"position": 8, "expected": "0x0008"}, + {"position": 10, "expected": "0x0006"}, + ] + self._test_position_to_label_conversion(test_cases) + self._test_label_to_position_conversion(test_cases) + self._test_out_of_range_values() + + def test_binary_ranges(self): + """Test binary ranges with both ascending and descending steps.""" + binary_ranges = [ + {"start": "1", "end": "5", "step": 1, "increment_letter": True, "label_type": "binary"}, + {"start": "10", "end": "6", "step": -1, "increment_letter": True, "label_type": "binary"}, + ] + self.form.create_custom_axis_labels(binary_ranges, self.floor_plan, axis="X") + + test_cases = [ + {"position": 1, "expected": "0b0001"}, + {"position": 3, "expected": "0b0011"}, + {"position": 5, "expected": "0b0101"}, + {"position": 6, "expected": "0b1010"}, + {"position": 8, "expected": "0b1000"}, + {"position": 10, "expected": "0b0110"}, + ] + self._test_position_to_label_conversion(test_cases) + self._test_label_to_position_conversion(test_cases) + self._test_out_of_range_values() + + def test_greek_ranges(self): + """Test Greek letter ranges with both ascending and descending steps.""" + greek_ranges = [ + {"start": "α", "end": "ε", "step": 1, "increment_letter": True, "label_type": "greek"}, + {"start": "κ", "end": "ζ", "step": -1, "increment_letter": True, "label_type": "greek"}, + ] + self.form.create_custom_axis_labels(greek_ranges, self.floor_plan, axis="X") + + test_cases = [ + {"position": 1, "expected": "α"}, # alpha + {"position": 3, "expected": "γ"}, # gamma + {"position": 5, "expected": "ε"}, # epsilon + {"position": 6, "expected": "κ"}, # kappa + {"position": 8, "expected": "θ"}, # theta + {"position": 10, "expected": "ζ"}, # zeta + ] + self._test_position_to_label_conversion(test_cases) + self._test_label_to_position_conversion(test_cases) + self._test_out_of_range_values() + + def _test_position_to_label_conversion(self, test_cases): + """Helper method to test position to label conversion.""" + for test in test_cases: + with self.subTest(test=test): + converter = label_converters.PositionToLabelConverter(test["position"], "X", self.floor_plan) + label = converter.convert() + self.assertEqual( + label, + test["expected"], + f"Position {test['position']} converted to {label}, expected {test['expected']}", + ) + + def _test_label_to_position_conversion(self, test_cases): + """Helper method to test label to position conversion.""" + for test in test_cases: + with self.subTest(test=test): + converter = label_converters.LabelToPositionConverter(test["expected"], "X", self.floor_plan) + position, label = converter.convert() + self.assertEqual( + position, + test["position"], + f"Label {test['expected']} converted to position {position}, expected {test['position']}", + ) + self.assertEqual(label, test["expected"]) + + def _test_out_of_range_values(self): + """Helper method to test out of range values.""" + # Test position beyond all ranges + converter = label_converters.PositionToLabelConverter(20, "X", self.floor_plan) + label = converter.convert() + self.assertIsNone(label) + + # Test label not in any range + converter = label_converters.LabelToPositionConverter("25", "X", self.floor_plan) + with self.assertRaises(ValueError) as context: + converter.convert() + self.assertIn("not within any defined range", str(context.exception)) diff --git a/nautobot_floor_plan/tests/test_forms.py b/nautobot_floor_plan/tests/test_forms.py index fb2ac9d..c8f56d4 100644 --- a/nautobot_floor_plan/tests/test_forms.py +++ b/nautobot_floor_plan/tests/test_forms.py @@ -61,9 +61,11 @@ def test_valid_extra_inputs(self): "x_axis_labels": choices.AxisLabelsChoices.NUMBERS, "x_origin_seed": 1, "x_axis_step": 1, + "x_custom_ranges": "null", "y_axis_labels": choices.AxisLabelsChoices.NUMBERS, "y_origin_seed": 1, "y_axis_step": 1, + "y_custom_ranges": "null", "tags": [tag], } ) @@ -105,6 +107,550 @@ def test_invalid_required_fields(self): for message in form.errors.values(): self.assertIn("This field is required.", message) + def test_form_fieldsets_structure(self): + """Test that the form's fieldset structure is correct.""" + form = forms.FloorPlanForm() + + # Test basic fieldset structure + self.assertEqual(len(form.fieldsets), 3) + + # Test Floor Plan Details tab + self.assertIn("Floor Plan", dict(form.fieldsets)) + self.assertIn("location", form.fieldsets[0][1]) + self.assertIn("x_size", form.fieldsets[0][1]) + + # Test X Axis Settings tabs + x_axis_settings = form.fieldsets[1][1] + self.assertIn("tabs", x_axis_settings) + self.assertEqual(len(x_axis_settings["tabs"]), 2) + + # Test Y Axis Settings tabs + y_axis_settings = form.fieldsets[2][1] + self.assertIn("tabs", y_axis_settings) + self.assertEqual(len(y_axis_settings["tabs"]), 2) + + def test_seed_step_reset_with_custom_labels(self): + """Test resetting of seed and step when custom labels are configured.""" + + initial_form = forms.FloorPlanForm( + data={ + "location": self.floors[0].pk, + "x_size": 10, + "y_size": 10, + "tile_depth": 100, + "tile_width": 200, + "x_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "x_origin_seed": 4, + "x_axis_step": 2, + "x_custom_ranges": {}, + "y_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "y_origin_seed": 3, + "y_axis_step": -1, + "y_custom_ranges": {}, + } + ) + + self.assertTrue(initial_form.is_valid()) + initial_form.save() + + floor_plan = models.FloorPlan.objects.get(location=self.floors[0]) + + models.FloorPlanCustomAxisLabel.objects.create( + floor_plan=floor_plan, + axis="X", + label_type=choices.CustomAxisLabelsChoices.BINARY, + start_label="1", + end_label="10", + step=1, + increment_letter=True, + order=1, + ) + + self.assertEqual(floor_plan.y_origin_seed, 3) + self.assertEqual(floor_plan.y_axis_step, -1) + + models.FloorPlanCustomAxisLabel.objects.create( + floor_plan=floor_plan, + axis="Y", + label_type=choices.CustomAxisLabelsChoices.HEX, + start_label="3", + end_label="12", + step=1, + increment_letter=True, + order=1, + ) + + self.assertEqual(floor_plan.x_origin_seed, 1) + self.assertEqual(floor_plan.y_origin_seed, 1) + self.assertEqual(floor_plan.x_axis_step, 1) + self.assertEqual(floor_plan.y_axis_step, 1) + + def test_increment_letter_default_false_for_numbers(self): + """Test setting increment_letter to False when label_type is numbers.""" + + initial_form = forms.FloorPlanForm( + data={ + "location": self.floors[0].pk, + "x_size": 10, + "y_size": 10, + "tile_depth": 100, + "tile_width": 200, + "x_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "x_origin_seed": 4, + "x_axis_step": 2, + "x_custom_ranges": [ + {"start": "01", "end": "05", "step": 1, "label_type": "numbers"}, + ], + "y_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "y_origin_seed": 3, + "y_axis_step": -1, + "y_custom_ranges": [], + } + ) + + self.assertTrue(initial_form.is_valid()) + initial_form.save() + + floor_plan = models.FloorPlan.objects.get(location=self.floors[0]) + + # Retrieve the custom label created + custom_label = models.FloorPlanCustomAxisLabel.objects.get(floor_plan=floor_plan, axis="X") + + # Assert that increment_letter is False for numeric labels + self.assertEqual(custom_label.increment_letter, False) + + def test_custom_ranges_validation(self): + """Test validation of custom range inputs.""" + test_cases = [ + # Valid cases + {"x_custom_ranges": '[{"start": "1", "end": "10", "step": 1, "label_type": "hex"}]', "valid": True}, + {"x_custom_ranges": '[{"start": "3", "end": "12", "step": 1, "label_type": "binary"}]', "valid": True}, + { + "x_custom_ranges": '[{"start": "07A", "end": "07J", "step": 1, "increment_letter": false, "label_type": "numalpha"}]', + "valid": True, + }, + # Invalid cases + { + "x_custom_ranges": '[{"start": "07A", "end": "08Z", "step": 1, "increment_letter": false, "label_type": "numalpha"}]', + "valid": False, + "error": "Range: '07' != '08'. Use separate ranges for different prefixes", + }, + { + "x_custom_ranges": '[{"start": "1", "end": "10", "step": 1, "label_type": "let"}]', + "valid": False, + "error": "Invalid label type", + }, + ] + + for test_case in test_cases: + form_data = { + "location": self.floors[0].pk, + "x_size": 10, + "y_size": 10, + "tile_depth": 100, + "tile_width": 100, + "x_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "y_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "x_origin_seed": 1, + "y_origin_seed": 1, + "x_axis_step": 1, + "y_axis_step": 1, + "x_custom_ranges": test_case["x_custom_ranges"], + } + + form = forms.FloorPlanForm(data=form_data) + is_valid = form.is_valid() + + self.assertEqual(is_valid, test_case["valid"]) + if not is_valid and "error" in test_case: + errors = str(form.errors) + self.assertIn(test_case["error"], errors) + + def test_step_aware_range_validation(self): + """Test that range validation correctly considers step values.""" + test_cases = [ + { + "range": [{"start": "1", "end": "20", "step": 2, "label_type": "binary", "increment_letter": True}], + "size": 10, + "should_pass": True, + }, + { + "range": [{"start": "1", "end": "20", "step": 1, "label_type": "binary", "increment_letter": True}], + "size": 10, + "should_pass": False, + "error": "has effective size 20", + }, + { + "range": [ + {"start": "07A", "end": "07Z", "step": 1, "label_type": "numalpha", "increment_letter": False} + ], + "size": 26, + "should_pass": True, + }, + { + "range": [ + {"start": "02AA", "end": "02ZZ", "step": 1, "label_type": "numalpha", "increment_letter": False} + ], + "size": 26, + "should_pass": True, + }, + { + "range": [{"start": "AA", "end": "ZZ", "step": 1, "label_type": "letters", "increment_letter": False}], + "size": 26, + "should_pass": True, + }, + { + "range": [ + {"start": "07A", "end": "08A", "step": 1, "label_type": "numalpha", "increment_letter": True} + ], + "size": 10, + "should_pass": False, + "error": "Range: '07' != '08'. Use separate ranges for different prefixes", + }, + ] + + for test in test_cases: + form_data = { + "location": self.floors[0].pk, + "x_size": test["size"], + "y_size": test["size"], + "tile_depth": 100, + "tile_width": 100, + "x_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "y_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "x_origin_seed": 1, + "y_origin_seed": 1, + "x_axis_step": 1, + "y_axis_step": 1, + "x_custom_ranges": test["range"], + } + form = forms.FloorPlanForm(data=form_data) + is_valid = form.is_valid() + + if test["should_pass"]: + self.assertTrue(is_valid, f"Form should be valid for range: {test['range']}") + else: + self.assertFalse(is_valid, f"Form should be invalid for range: {test['range']}") + self.assertIn(test["error"], str(form.errors)) + + def test_alphanumeric_range_validation(self): + """Test validation of alphanumeric range inputs.""" + test_cases = [ + # Valid cases + { + "x_custom_ranges": '[{"start": "A1", "end": "A5", "step": 1, "increment_letter": true, "label_type": "alphanumeric"},{"start": "B1", "end": "B5", "step": 1, "increment_letter": true, "label_type": "alphanumeric"}]', + "valid": True, + }, + { + "x_custom_ranges": '[{"start": "A1", "end": "J1", "step": 1, "increment_letter": false, "label_type": "alphanumeric"}]', + "valid": True, + }, + # Invalid cases - range too large + { + "x_custom_ranges": '[{"start": "A1", "end": "A20", "step": 1, "increment_letter": true, "label_type": "alphanumeric"}]', + "valid": False, + "error": "Range from A1 to A20 has effective size 20, exceeding maximum size of 10", + }, + # Invalid cases - non-alphanumeric input + { + "x_custom_ranges": '[{"start": "123", "end": "456", "step": 1, "increment_letter": true, "label_type": "alphanumeric"}]', + "valid": False, + "error": "Invalid alphanumeric range: '123' to '456' must include alphabetic characters. Use label_type 'numbers' if no letters are needed.", + }, + ] + + for test_case in test_cases: + form_data = { + "location": self.floors[0].pk, + "x_size": 10, + "y_size": 10, + "tile_depth": 100, + "tile_width": 100, + "x_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "y_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "x_origin_seed": 1, + "y_origin_seed": 1, + "x_axis_step": 1, + "y_axis_step": 1, + "x_custom_ranges": test_case["x_custom_ranges"], + } + + form = forms.FloorPlanForm(data=form_data) + is_valid = form.is_valid() + + if test_case["valid"]: + self.assertTrue(is_valid, f"Form should be valid for {test_case['x_custom_ranges']}") + else: + self.assertFalse(is_valid, f"Form should be invalid for {test_case['x_custom_ranges']}") + if "error" in test_case: + self.assertIn(test_case["error"], str(form.errors)) + + def test_custom_number_range_validation(self): + """Test validation of custom number range inputs.""" + test_cases = [ + # Valid cases + { + "x_custom_ranges": '[{"start": "1", "end": "5", "step": 1, "increment_letter": false, "label_type": "numbers"},{"start": "10", "end": "15", "step": 1, "increment_letter": false, "label_type": "numbers"}]', + "valid": True, + }, + { + "x_custom_ranges": '[{"start": "01", "end": "05", "step": 1, "increment_letter": false, "label_type": "numbers"}]', + "valid": True, + }, + # Invalid cases - range too large + { + "x_custom_ranges": '[{"start": "1", "end": "20", "step": 1, "increment_letter": false, "label_type": "numbers"}]', + "valid": False, + "error": "Range from 1 to 20 has effective size 20, exceeding maximum size of 10", + }, + # Invalid cases - non-numeric input + { + "x_custom_ranges": '[{"start": "ABC", "end": "DEF", "step": 1, "increment_letter": false, "label_type": "numbers"}]', + "valid": False, + "error": "Invalid number format", + }, + # Invalid cases - overlapping ranges + { + "x_custom_ranges": '[{"start": "1", "end": "5", "step": 1, "increment_letter": false, "label_type": "numbers"},{"start": "3", "end": "7", "step": 1, "increment_letter": false, "label_type": "numbers"}]', + "valid": False, + "error": "Ranges overlap", + }, + # Invalid cases - invalid increment_letter + { + "x_custom_ranges": '[{"start": "01", "end": "05", "step": 1, "increment_letter": true, "label_type": "numbers"}]', + "valid": False, + "error": "increment_letter must be False when using numeric labels", + }, + ] + + for test_case in test_cases: + form_data = { + "location": self.floors[0].pk, + "x_size": 10, + "y_size": 10, + "tile_depth": 100, + "tile_width": 100, + "x_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "y_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "x_origin_seed": 1, + "y_origin_seed": 1, + "x_axis_step": 1, + "y_axis_step": 1, + "x_custom_ranges": test_case["x_custom_ranges"], + } + + form = forms.FloorPlanForm(data=form_data) + is_valid = form.is_valid() + + if test_case["valid"]: + self.assertTrue(is_valid, f"Form should be valid for {test_case['x_custom_ranges']}") + else: + self.assertFalse(is_valid, f"Form should be invalid for {test_case['x_custom_ranges']}") + if "error" in test_case: + self.assertIn(test_case["error"], str(form.errors)) + + def test_roman_range_validation(self): + """Test validation of Roman numeral range inputs.""" + test_cases = [ + # Valid cases + { + "x_custom_ranges": '[{"start": "I", "end": "V", "step": 1, "increment_letter": true, "label_type": "roman"},{"start": "XI", "end": "XV", "step": 1, "increment_letter": true, "label_type": "roman"}]', + "valid": True, + }, + # Invalid cases + { + "x_custom_ranges": '[{"start": "I", "end": "XX", "step": 1, "increment_letter": true, "label_type": "roman"}]', + "valid": False, + "error": "Range from I to XX has effective size 20, exceeding maximum size of 10", + }, + { + "x_custom_ranges": '[{"start": "ABC", "end": "DEF", "step": 1, "increment_letter": true, "label_type": "roman"}]', + "valid": False, + "errors": [ + "Invalid values for roman - Invalid Roman numeral: ABC", + "Invalid values for roman - Invalid Roman numeral character at position 0 in: ABC", + ], + }, + ] + + for test_case in test_cases: + form_data = { + "location": self.floors[0].pk, + "x_size": 10, + "y_size": 10, + "tile_depth": 100, + "tile_width": 100, + "x_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "y_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "x_origin_seed": 1, + "y_origin_seed": 1, + "x_axis_step": 1, + "y_axis_step": 1, + "x_custom_ranges": test_case["x_custom_ranges"], + } + + form = forms.FloorPlanForm(data=form_data) + is_valid = form.is_valid() + + if test_case["valid"]: + self.assertTrue(is_valid, f"Form should be valid for {test_case['x_custom_ranges']}") + else: + self.assertFalse(is_valid, f"Form should be invalid for {test_case['x_custom_ranges']}") + if "error" in test_case: + self.assertIn(test_case["error"], str(form.errors)) + elif "errors" in test_case: + error_str = str(form.errors) + self.assertTrue( + any(error in error_str for error in test_case["errors"]), + f"Expected one of {test_case['errors']} in error message, but got: {error_str}", + ) + + def test_letters_range_validation(self): + """Test validation of letter range inputs.""" + test_cases = [ + # Valid cases + { + "x_custom_ranges": '[{"start": "A", "end": "Z", "step": 1, "increment_letter": false, "label_type": "letters"},{"start": "AA", "end": "AZ", "step": 1, "increment_letter": false, "label_type": "letters"}]', + "valid": True, + }, + { + "x_custom_ranges": '[{"start": "A", "end": "Z", "step": 1, "increment_letter": false, "label_type": "letters"},{"start": "AA", "end": "ZZ", "step": 1, "increment_letter": false, "label_type": "letters"}]', + "valid": True, + }, + # Invalid cases - range too large + { + "x_custom_ranges": '[{"start": "A", "end": "AAA", "step": 1, "increment_letter": true, "label_type": "letters"}]', + "valid": False, + "error": "Range from A to AAA has effective size 703, exceeding maximum size of 52", + }, + # Invalid cases - non-letter input + { + "x_custom_ranges": '[{"start": "123", "end": "456", "step": 1, "increment_letter": false, "label_type": "letters"}]', + "valid": False, + "error": "Invalid values for letters: '123, 456'", + }, + ] + + for test_case in test_cases: + form_data = { + "location": self.floors[0].pk, + "x_size": 52, + "y_size": 52, + "tile_depth": 100, + "tile_width": 100, + "x_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "y_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "x_origin_seed": 1, + "y_origin_seed": 1, + "x_axis_step": 1, + "y_axis_step": 1, + "x_custom_ranges": test_case["x_custom_ranges"], + } + + form = forms.FloorPlanForm(data=form_data) + is_valid = form.is_valid() + + if test_case["valid"]: + self.assertTrue(is_valid, f"Form should be valid for {test_case['x_custom_ranges']}") + else: + self.assertFalse(is_valid, f"Form should be invalid for {test_case['x_custom_ranges']}") + if "error" in test_case: + self.assertIn(test_case["error"], str(form.errors)) + + def test_binary_range_validation(self): + """Test validation of binary range inputs.""" + test_cases = [ + # Valid cases + { + "x_custom_ranges": '[{"start": "1", "end": "5", "step": 1, "increment_letter": true, "label_type": "binary"},{"start": "6", "end": "10", "step": 1, "increment_letter": true, "label_type": "binary"}]', + "valid": True, + }, + # Invalid cases + { + "x_custom_ranges": '[{"start": "1", "end": "20", "step": 1, "increment_letter": true, "label_type": "binary"}]', + "valid": False, + "error": "Range from 1 to 20 has effective size 20, exceeding maximum size of 10", + }, + { + "x_custom_ranges": '[{"start": "abc", "end": "def", "step": 1, "increment_letter": true, "label_type": "binary"}]', + "valid": False, + "error": "Invalid numeric values - invalid literal for int() with base 10: 'abc'", + }, + ] + + for test_case in test_cases: + form_data = { + "location": self.floors[0].pk, + "x_size": 10, + "y_size": 10, + "tile_depth": 100, + "tile_width": 100, + "x_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "y_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "x_origin_seed": 1, + "y_origin_seed": 1, + "x_axis_step": 1, + "y_axis_step": 1, + "x_custom_ranges": test_case["x_custom_ranges"], + } + + form = forms.FloorPlanForm(data=form_data) + is_valid = form.is_valid() + + if test_case["valid"]: + self.assertTrue(is_valid, f"Form should be valid for {test_case['x_custom_ranges']}") + else: + self.assertFalse(is_valid, f"Form should be invalid for {test_case['x_custom_ranges']}") + if "error" in test_case: + self.assertIn(test_case["error"], str(form.errors)) + + def test_hex_range_validation(self): + """Test validation of hexadecimal range inputs.""" + test_cases = [ + # Valid cases + { + "x_custom_ranges": '[{"start": "1", "end": "15", "step": 1, "increment_letter": true, "label_type": "hex"},{"start": "16", "end": "30", "step": 1, "increment_letter": true, "label_type": "hex"}]', + "valid": True, + }, + # Invalid cases + { + "x_custom_ranges": '[{"start": "0G", "end": "1F", "step": 1, "increment_letter": true, "label_type": "hex"}]', + "valid": False, + "error": "Invalid numeric values - invalid literal for int() with base 10: '0G'", + }, + { + "x_custom_ranges": '[{"start": "XX", "end": "YY", "step": 1, "increment_letter": true, "label_type": "hex"}]', + "valid": False, + "error": "Invalid numeric values - invalid literal for int() with base 10: 'XX'", + }, + ] + + for test_case in test_cases: + form_data = { + "location": self.floors[0].pk, + "x_size": 32, + "y_size": 32, + "tile_depth": 100, + "tile_width": 100, + "x_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "y_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "x_origin_seed": 1, + "y_origin_seed": 1, + "x_axis_step": 1, + "y_axis_step": 1, + "x_custom_ranges": test_case["x_custom_ranges"], + } + + form = forms.FloorPlanForm(data=form_data) + is_valid = form.is_valid() + + if test_case["valid"]: + self.assertTrue(is_valid, f"Form should be valid for {test_case['x_custom_ranges']}") + else: + self.assertFalse(is_valid, f"Form should be invalid for {test_case['x_custom_ranges']}") + if "error" in test_case: + self.assertIn(test_case["error"], str(form.errors)) + class TestFloorPlanTileForm(TestCase): """Test FloorPlanTileForm forms.""" diff --git a/nautobot_floor_plan/tests/test_general.py b/nautobot_floor_plan/tests/test_general.py new file mode 100644 index 0000000..3ddb710 --- /dev/null +++ b/nautobot_floor_plan/tests/test_general.py @@ -0,0 +1,94 @@ +"""Test floorplan general.""" + +import unittest + +from nautobot_floor_plan.utils import general + + +class TestGeneralUtils(unittest.TestCase): + """Test class.""" + + def test_grid_number_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(general.grid_number_to_letter(num), expected) + + def test_gird_letter_to_number(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(general.grid_letter_to_number(letter), expected) + + def test_extract_prefix_and_letter(self): + self.assertEqual(general.extract_prefix_and_letter("02A"), ("02", "A")) + self.assertEqual(general.extract_prefix_and_letter("12A"), ("12", "A")) + self.assertEqual(general.extract_prefix_and_letter("123A"), ("123", "A")) + + def test_extract_prefix_and_number(self): + self.assertEqual(general.extract_prefix_and_number("A02"), ("A", "02")) + self.assertEqual(general.extract_prefix_and_number("AA1"), ("AA", "1")) + self.assertEqual(general.extract_prefix_and_number("B23"), ("B", "23")) + + def test_letter_conversion(self): + self.assertEqual(general.letter_conversion(1), "A") + self.assertEqual(general.letter_conversion(26), "Z") + self.assertEqual(general.letter_conversion(27), "AA") + self.assertEqual(general.letter_conversion(18278), "ZZZ") + + def test_axis_init_label_conversion(self): + self.assertEqual(general.axis_init_label_conversion(1, 1, 1, False), 1) + self.assertEqual(general.axis_init_label_conversion(1, 2, 1, False), 2) + self.assertEqual(general.axis_init_label_conversion(1, "AA", 1, True), "AA") + self.assertEqual(general.axis_init_label_conversion(1, "Z", 1, True), "Z") + + def test_axis_clean_label_conversion(self): + # Test with letters + self.assertEqual(general.axis_clean_label_conversion(1, "A", 1, True), "1") # A should return 1 + self.assertEqual(general.axis_clean_label_conversion(1, "B", 1, True), "2") # B should return 2 + self.assertEqual(general.axis_clean_label_conversion(1, "Z", 1, True), "26") # Z should return 26 + self.assertEqual(general.axis_clean_label_conversion(1, "AA", 1, True), "27") # AA should return 27 + + # Test with numbers + self.assertEqual(general.axis_clean_label_conversion(1, 1, 1, False), "1") # 1 should return 1 + self.assertEqual(general.axis_clean_label_conversion(1, 2, 1, False), "2") # 2 should return 2 + + # Test with custom ranges + custom_ranges = [{"start": "1", "end": "5"}] + self.assertEqual(general.axis_clean_label_conversion(1, "3", 1, False, custom_ranges), "3") # Within range + self.assertEqual(general.axis_clean_label_conversion(1, "6", 1, False, custom_ranges), "6") # Outside range + + # Edge cases + self.assertEqual(general.axis_clean_label_conversion(1, "ZZZ", 1, True), "18278") + self.assertEqual(general.axis_clean_label_conversion(1, "DDD", 1, True), "2812") + + # Test for Error + with self.assertRaises(TypeError): + general.axis_init_label_conversion(1, None, 1, True) # None should raise ValueError + + with self.assertRaises(TypeError): + general.axis_init_label_conversion(1, 1.5, 1, True) # Float should raise ValueError diff --git a/nautobot_floor_plan/tests/test_label_generator.py b/nautobot_floor_plan/tests/test_label_generator.py new file mode 100644 index 0000000..1801fbd --- /dev/null +++ b/nautobot_floor_plan/tests/test_label_generator.py @@ -0,0 +1,353 @@ +"""Test floorplan label generator.""" + +from nautobot.core.testing import TestCase + +from nautobot_floor_plan import models +from nautobot_floor_plan.tests import fixtures +from nautobot_floor_plan.utils.custom_validators import RangeValidator + + +class TestNumericLabelGenerator(TestCase): + """Test cases for numeric label generation (numbers and alphanumeric).""" + + def setUp(self): + """Create LocationType, Status, and Location records.""" + data = fixtures.create_prerequisites() + self.floors = data["floors"] + self.status = data["status"] + self.floor_plan = models.FloorPlan(location=self.floors[0], x_size=10, y_size=10) + self.floor_plan.validated_save() + self.validator = RangeValidator(max_size=10) + + def create_custom_labels(self, labels_config, axis="X"): + """Helper to create custom labels from config.""" + for config in labels_config: + models.FloorPlanCustomAxisLabel.objects.create( + floor_plan=self.floor_plan, + axis=axis, + start_label=config["start"], + end_label=config["end"], + step=config["step"], + increment_letter=config["increment_letter"], + label_type=config["label_type"], + ) + + def test_custom_number_ranges(self): + """Test custom number ranges with basic incrementing.""" + config = [{"start": "1", "end": "5", "step": 1, "increment_letter": True, "label_type": "numbers"}] + self.create_custom_labels(config) + labels = self.floor_plan.generate_labels("X", 5) + expected = ["1", "2", "3", "4", "5"] + self.assertEqual(labels[: len(expected)], expected) + + def test_custom_number_ranges_with_leading_zeros(self): + """Test custom number ranges with leading zeros.""" + config = [{"start": "01", "end": "05", "step": 1, "increment_letter": True, "label_type": "numbers"}] + self.create_custom_labels(config) + labels = self.floor_plan.generate_labels("X", 5) + expected = ["01", "02", "03", "04", "05"] + self.assertEqual(labels[: len(expected)], expected) + + def test_custom_number_ranges_negative_step(self): + """Test custom number ranges with negative steps.""" + config = [{"start": "5", "end": "1", "step": -1, "increment_letter": True, "label_type": "numbers"}] + self.create_custom_labels(config) + labels = self.floor_plan.generate_labels("X", 5) + expected = ["5", "4", "3", "2", "1"] + self.assertEqual(labels[: len(expected)], expected) + + def test_custom_number_ranges_with_leading_zeros_negative_step(self): + """Test custom number ranges with leading zeros and negative steps.""" + config = [{"start": "05", "end": "01", "step": -1, "increment_letter": False, "label_type": "numbers"}] + self.create_custom_labels(config) + labels = self.floor_plan.generate_labels("X", 5) + expected = ["05", "04", "03", "02", "01"] + self.assertEqual(labels[: len(expected)], expected) + + def test_custom_number_ranges_multiple_ranges(self): + """Test multiple custom number ranges.""" + config = [ + {"start": "01", "end": "03", "step": 1, "increment_letter": True, "label_type": "numbers"}, + {"start": "10", "end": "12", "step": 1, "increment_letter": True, "label_type": "numbers"}, + ] + self.create_custom_labels(config) + labels = self.floor_plan.generate_labels("X", 6) + expected = ["01", "02", "03", "10", "11", "12"] + self.assertEqual(labels[: len(expected)], expected) + + def test_custom_number_ranges_mixed_formats(self): + """Test custom number ranges with mixed formats.""" + config = [ + {"start": "1", "end": "3", "step": 1, "increment_letter": True, "label_type": "numbers"}, + {"start": "04", "end": "06", "step": 1, "increment_letter": True, "label_type": "numbers"}, + ] + self.create_custom_labels(config) + labels = self.floor_plan.generate_labels("X", 6) + expected = ["1", "2", "3", "04", "05", "06"] + self.assertEqual(labels[: len(expected)], expected) + + def test_custom_alphanumeric_ranges(self): + """Test custom alphanumeric ranges with number incrementing.""" + config = [{"start": "A01", "end": "A05", "step": 1, "increment_letter": False, "label_type": "alphanumeric"}] + self.create_custom_labels(config) + labels = self.floor_plan.generate_labels("X", 5) + expected = ["A01", "A02", "A03", "A04", "A05"] + self.assertEqual(labels[: len(expected)], expected) + + def test_custom_alphanumeric_ranges_no_leading_zero(self): + """Test custom alphanumeric ranges without leading zeros.""" + config = [{"start": "A1", "end": "A5", "step": 1, "increment_letter": False, "label_type": "alphanumeric"}] + self.create_custom_labels(config) + labels = self.floor_plan.generate_labels("X", 5) + expected = ["A1", "A2", "A3", "A4", "A5"] + self.assertEqual(labels[: len(expected)], expected) + + def test_custom_alphanumeric_ranges_increment_prefix(self): + """Test custom alphanumeric ranges with prefix incrementing.""" + config = [{"start": "A01", "end": "E01", "step": 1, "increment_letter": True, "label_type": "alphanumeric"}] + self.create_custom_labels(config) + labels = self.floor_plan.generate_labels("X", 5) + expected = ["A01", "B01", "C01", "D01", "E01"] + self.assertEqual(labels[: len(expected)], expected) + + def test_custom_alphanumeric_ranges_increment_prefix_no_leading_zero(self): + """Test custom alphanumeric ranges with prefix incrementing and no leading zeros.""" + config = [{"start": "A1", "end": "E1", "step": 1, "increment_letter": True, "label_type": "alphanumeric"}] + self.create_custom_labels(config) + labels = self.floor_plan.generate_labels("X", 5) + expected = ["A1", "B1", "C1", "D1", "E1"] + self.assertEqual(labels[: len(expected)], expected) + + def test_custom_alphanumeric_ranges_negative(self): + """Test custom alphanumeric ranges with negative steps.""" + config = [{"start": "A05", "end": "A01", "step": -1, "increment_letter": False, "label_type": "alphanumeric"}] + self.create_custom_labels(config) + labels = self.floor_plan.generate_labels("X", 5) + expected = ["A05", "A04", "A03", "A02", "A01"] + self.assertEqual(labels[: len(expected)], expected) + + def test_custom_alphanumeric_ranges_negative_prefix(self): + """Test custom alphanumeric ranges with negative steps and prefix incrementing.""" + config = [{"start": "E01", "end": "A01", "step": -1, "increment_letter": True, "label_type": "alphanumeric"}] + self.create_custom_labels(config) + labels = self.floor_plan.generate_labels("X", 5) + expected = ["E01", "D01", "C01", "B01", "A01"] + self.assertEqual(labels[: len(expected)], expected) + + def test_default_labels_generation_when_custom_range_insufficient_numbers(self): + """Test that default labels are generated when custom range is insufficient using numbers label.""" + # Create a custom label range that is smaller than the requested count + config = [{"start": "1", "end": "3", "step": 1, "increment_letter": True, "label_type": "numbers"}] + self.create_custom_labels(config) + + # Request more labels than the custom range provides + labels = self.floor_plan.generate_labels("X", 5) # Request 5 labels + + # Expected labels should include the custom range and then default labels + expected = ["1", "2", "3", "4", "5"] + self.assertEqual(labels[: len(expected)], expected) + + def test_default_labels_generation_when_custom_range_insufficient_numalpha(self): + """Test that default labels are generated when custom range is insufficient using numalpha label.""" + # create custom label with numalpha that is smaller than the requested count + config = [{"start": "02A", "end": "02C", "step": 1, "increment_letter": True, "label_type": "numalpha"}] + self.create_custom_labels(config) + + # Request more labels than the custom range provides + labels = self.floor_plan.generate_labels("X", 5) # Request 5 labels + + # Expected labels should include the custom range and then default labels + expected = ["02A", "02B", "02C", "4", "5"] + self.assertEqual(labels[: len(expected)], expected) + + +class TestLetterLabelGenerator(TestCase): + """Test cases for letter-based label generation (letters and numalpha).""" + + def setUp(self): + """Create LocationType, Status, and Location records.""" + data = fixtures.create_prerequisites() + self.floors = data["floors"] + self.status = data["status"] + self.floor_plan = models.FloorPlan(location=self.floors[0], x_size=10, y_size=10) + self.floor_plan.validated_save() + self.validator = RangeValidator(max_size=10) + + def create_custom_labels(self, labels_config, axis="X"): + """Helper to create custom labels from config.""" + for config in labels_config: + models.FloorPlanCustomAxisLabel.objects.create( + floor_plan=self.floor_plan, + axis=axis, + start_label=config["start"], + end_label=config["end"], + step=config["step"], + increment_letter=config["increment_letter"], + label_type=config["label_type"], + ) + + def test_custom_letter_ranges(self): + """Test custom letter ranges with different configurations.""" + config = [{"start": "C", "end": "Z", "step": 1, "increment_letter": True, "label_type": "letters"}] + self.create_custom_labels(config) + labels = self.floor_plan.generate_labels("X", 24) + expected = [chr(65 + i) for i in range(2, 26)] # C through Z + self.assertEqual(labels[: len(expected)], expected) + + def test_custom_letter_ranges_negative_step(self): + """Test custom letter ranges with negative steps.""" + config = [{"start": "E", "end": "A", "step": -1, "increment_letter": True, "label_type": "letters"}] + self.create_custom_labels(config) + labels = self.floor_plan.generate_labels("X", 5) + expected = ["E", "D", "C", "B", "A"] + self.assertEqual(labels[: len(expected)], expected) + + def test_custom_letter_ranges_multiple_ranges(self): + """Test multiple custom letter ranges.""" + config = [ + {"start": "A", "end": "C", "step": 1, "increment_letter": True, "label_type": "letters"}, + {"start": "X", "end": "Z", "step": 1, "increment_letter": True, "label_type": "letters"}, + ] + self.create_custom_labels(config) + labels = self.floor_plan.generate_labels("X", 6) + expected = ["A", "B", "C", "X", "Y", "Z"] + self.assertEqual(labels[: len(expected)], expected) + + def test_custom_numalpha_ranges(self): + """Test custom numalpha ranges with letter incrementing.""" + config = [{"start": "02A", "end": "02E", "step": 1, "increment_letter": True, "label_type": "numalpha"}] + self.create_custom_labels(config) + labels = self.floor_plan.generate_labels("X", 5) + expected = ["02A", "02B", "02C", "02D", "02E"] + self.assertEqual(labels[: len(expected)], expected) + + def test_custom_numalpha_ranges_no_leading_zero(self): + """Test custom numalpha ranges without leading zeros.""" + config = [{"start": "2A", "end": "2E", "step": 1, "increment_letter": True, "label_type": "numalpha"}] + self.create_custom_labels(config) + labels = self.floor_plan.generate_labels("X", 5) + expected = ["2A", "2B", "2C", "2D", "2E"] + self.assertEqual(labels[: len(expected)], expected) + + def test_multiple_numalpha_ranges(self): + """Test multiple numalpha ranges.""" + config = [ + {"start": "01A", "end": "01C", "step": 1, "increment_letter": True, "label_type": "numalpha"}, + {"start": "02X", "end": "02Z", "step": 1, "increment_letter": True, "label_type": "numalpha"}, + ] + self.create_custom_labels(config) + labels = self.floor_plan.generate_labels("X", 6) + expected = ["01A", "01B", "01C", "02X", "02Y", "02Z"] + self.assertEqual(labels[: len(expected)], expected) + + def test_custom_numalpha_ranges_multi_letter_negative_step(self): + """Test custom numalpha ranges with multi-letter sequences and negative steps.""" + config = [ + {"start": "02EE", "end": "02EA", "step": -1, "increment_letter": True, "label_type": "numalpha"}, + {"start": "02E", "end": "02A", "step": -1, "increment_letter": False, "label_type": "numalpha"}, + ] + self.create_custom_labels(config) + labels = self.floor_plan.generate_labels("X", 10) + expected = ["02EE", "02ED", "02EC", "02EB", "02EA", "02E", "02D", "02C", "02B", "02A"] + self.assertEqual(labels[: len(expected)], expected) + + def test_custom_numalpha_ranges_multi_letter_negative_step_all_letters(self): + """Test custom numalpha ranges with multi-letter sequences and negative steps, decrementing all letters.""" + config = [ + {"start": "02EE", "end": "02AA", "step": -1, "increment_letter": False, "label_type": "numalpha"}, + {"start": "02E", "end": "02A", "step": -1, "increment_letter": False, "label_type": "numalpha"}, + ] + self.create_custom_labels(config) + labels = self.floor_plan.generate_labels("X", 10) + expected = ["02EE", "02DD", "02CC", "02BB", "02AA", "02E", "02D", "02C", "02B", "02A"] + self.assertEqual(labels[: len(expected)], expected) + + +class TestLabelRangeOrder(TestCase): + """Test cases for custom label range ordering.""" + + def setUp(self): + """Create LocationType, Status, and Location records.""" + data = fixtures.create_prerequisites() + self.floors = data["floors"] + self.status = data["status"] + self.floor_plan = models.FloorPlan(location=self.floors[0], x_size=10, y_size=10) + self.floor_plan.validated_save() + + def create_custom_labels(self, labels_config, axis="X"): + """Helper to create custom labels from config.""" + for config in labels_config: + models.FloorPlanCustomAxisLabel.objects.create( + floor_plan=self.floor_plan, + axis=axis, + start_label=config["start"], + end_label=config["end"], + step=config["step"], + increment_last_letter=config["increment_last_letter"], + label_type=config["label_type"], + order=config["order"], + ) + + +def test_custom_range_order_consistency(self): + """Test that custom range order is maintained when saving and retrieving.""" + # Create initial ranges in specific order + config = [ + { + "start": "02EE", + "end": "02AA", + "step": -1, + "increment_last_letter": True, + "label_type": "numalpha", + "order": 1, + }, + { + "start": "02E", + "end": "02A", + "step": -1, + "increment_last_letter": False, + "label_type": "numalpha", + "order": 2, + }, + ] + self.create_custom_labels(config) + + # Verify order in database + ranges = self.floor_plan.get_custom_ranges("X") + self.assertEqual(len(ranges), 2) + self.assertEqual(ranges[0].start_label, "02EE") + self.assertEqual(ranges[1].start_label, "02E") + + # Verify order in JSON representation + json_ranges = self.floor_plan.get_custom_ranges_as_json("X") + self.assertEqual(json_ranges[0]["start"], "02EE") + self.assertEqual(json_ranges[1]["start"], "02E") + + +def test_custom_range_order_mixed_types(self): + """Test that custom range order is maintained with different label types.""" + config = [ + { + "start": "A01", + "end": "A05", + "step": 1, + "increment_last_letter": True, + "label_type": "alphanumeric", + "order": 1, + }, + {"start": "01", "end": "05", "step": 1, "increment_last_letter": True, "label_type": "numbers", "order": 2}, + {"start": "02A", "end": "02E", "step": 1, "increment_last_letter": True, "label_type": "numalpha", "order": 3}, + ] + self.create_custom_labels(config) + + # Verify order in database + ranges = self.floor_plan.get_custom_ranges("X") + self.assertEqual(len(ranges), 3) + self.assertEqual(ranges[0].start_label, "A01") + self.assertEqual(ranges[1].start_label, "01") + self.assertEqual(ranges[2].start_label, "02A") + + # Verify label generation maintains order + labels = self.floor_plan.generate_labels("X", 15) + expected = ["A01", "A02", "A03", "A04", "A05", "01", "02", "03", "04", "05", "02A", "02B", "02C", "02D", "02E"] + self.assertEqual(labels, expected) diff --git a/nautobot_floor_plan/tests/test_tables.py b/nautobot_floor_plan/tests/test_tables.py new file mode 100644 index 0000000..e1e2c1d --- /dev/null +++ b/nautobot_floor_plan/tests/test_tables.py @@ -0,0 +1,52 @@ +"""Test floorplan tables.""" + +from nautobot.core.testing import TestCase + +from nautobot_floor_plan import models +from nautobot_floor_plan.tables import FloorPlanTable +from nautobot_floor_plan.tests import fixtures + + +class TestFloorPlanTable(TestCase): + """Test FloorPlan Table.""" + + def setUp(self): + """Create LocationType, Status, and Location records.""" + data = fixtures.create_prerequisites() + self.floors = data["floors"] + self.status = data["status"] + + def test_floor_plan_table_rendering(self): + """Test FloorPlanTable renders correct values for custom labels.""" + floor_plan = models.FloorPlan(location=self.floors[0], x_size=1, y_size=1) + floor_plan.validated_save() + test_cases = [ + { + "custom_label": { + "axis": "X", + "label_type": "numalpha", + "start_label": "07A", + "end_label": "07Z", + "step": 2, + }, + "expected_seed": "07A", + "expected_step": 2, + }, + {"custom_label": None, "expected_seed": "1", "expected_step": 1}, + ] + + table = FloorPlanTable([floor_plan]) + + for test in test_cases: + if test["custom_label"]: + models.FloorPlanCustomAxisLabel.objects.create(floor_plan=floor_plan, **test["custom_label"]) + + rendered_seed = table.render_x_origin_seed(floor_plan) + rendered_step = table.render_x_axis_step(floor_plan) + + self.assertEqual(rendered_seed, test["expected_seed"]) + self.assertEqual(rendered_step, test["expected_step"]) + + # Clean up for next test + if test["custom_label"]: + models.FloorPlanCustomAxisLabel.objects.all().delete() diff --git a/nautobot_floor_plan/tests/test_template_tags.py b/nautobot_floor_plan/tests/test_template_tags.py new file mode 100644 index 0000000..5977185 --- /dev/null +++ b/nautobot_floor_plan/tests/test_template_tags.py @@ -0,0 +1,112 @@ +"""Test cases for template tags.""" + +from nautobot.core.testing import TestCase + +from nautobot_floor_plan import choices, models +from nautobot_floor_plan.templatetags.seed_helpers import ( + get_fieldset_field, + render_axis_label, + render_axis_origin, + seed_conversion, +) +from nautobot_floor_plan.tests import fixtures + + +class TestSeedHelpers(TestCase): + """Test cases for seed helper template tags.""" + + def setUp(self): + """Create test objects.""" + data = fixtures.create_prerequisites() + self.floors = data["floors"] + self.status = data["status"] + + def test_seed_conversion(self): + """Test conversion of seed numbers to letters.""" + floor_plan = models.FloorPlan.objects.create( + location=self.floors[0], x_size=3, y_size=3, x_origin_seed=1, y_origin_seed=1 + ) + floor_plan.validated_save() + # Test numeric seed + floor_plan.x_axis_labels = choices.AxisLabelsChoices.NUMBERS + floor_plan.x_origin_seed = 1 + self.assertEqual(seed_conversion(floor_plan, "x"), "1") + + # Test letter conversion + floor_plan.x_axis_labels = choices.AxisLabelsChoices.LETTERS + floor_plan.x_origin_seed = 1 + self.assertEqual(seed_conversion(floor_plan, "x"), "A") + + # Test larger numbers + floor_plan.x_origin_seed = 26 + self.assertEqual(seed_conversion(floor_plan, "x"), "Z") + + floor_plan.x_origin_seed = 27 + self.assertEqual(seed_conversion(floor_plan, "x"), "AA") + + def test_grid_location_conversion(self): + """Test conversion of grid locations to letters.""" + floor_plan = models.FloorPlan.objects.create( + location=self.floors[0], x_size=3, y_size=3, x_origin_seed=1, y_origin_seed=1 + ) + floor_plan.validated_save() + floor_plan_tile = models.FloorPlanTile(floor_plan=floor_plan, x_origin=1, y_origin=1, status=self.status) + floor_plan_tile.validated_save() + # Test numeric grid + floor_plan.x_axis_labels = choices.AxisLabelsChoices.NUMBERS + floor_plan_tile.x_origin = 1 + self.assertEqual(render_axis_origin(floor_plan_tile, "x"), 1) + + # Test letter conversion + floor_plan.x_axis_labels = choices.AxisLabelsChoices.LETTERS + floor_plan_tile.x_origin = 1 + self.assertEqual(render_axis_origin(floor_plan_tile, "x"), "A") + + # Test larger numbers + floor_plan_tile.x_origin = 26 + self.assertEqual(render_axis_origin(floor_plan_tile, "x"), "Z") + + floor_plan_tile.x_origin = 27 + self.assertEqual(render_axis_origin(floor_plan_tile, "x"), "AA") + + def test_get_fieldset_field(self): + """Test retrieving fields from a form fieldset.""" + + class MockForm(dict): + """Test retrieving fields from a form fieldset.""" + + form = MockForm() + form["test_field"] = "test_value" + + # Test existing field + self.assertEqual(get_fieldset_field(form, "test_field"), "test_value") + + # Test non-existent field + self.assertIsNone(get_fieldset_field(form, "non_existent_field")) + + def test_render_axis_label(self): + """Test render_axis_label template filter.""" + floor_plan = models.FloorPlan.objects.create( + location=self.floors[1], x_size=3, y_size=3, x_origin_seed=1, y_origin_seed=1 + ) + test_cases = [ + { + "custom_label": {"axis": "X", "label_type": "binary", "start_label": "1", "end_label": "10", "step": 1}, + "expected": "binary", + }, + {"custom_label": None, "axis_labels": choices.AxisLabelsChoices.NUMBERS, "expected": "numbers"}, + ] + + for test in test_cases: + if test["custom_label"]: + models.FloorPlanCustomAxisLabel.objects.create(floor_plan=floor_plan, **test["custom_label"]) + else: + # Set the default axis labels on the floor plan + floor_plan.x_axis_labels = test["axis_labels"] + floor_plan.save() + + result = render_axis_label(floor_plan, "X") + self.assertEqual(result, test["expected"]) + + # Clean up for next test + models.FloorPlanCustomAxisLabel.objects.all().delete() diff --git a/nautobot_floor_plan/tests/test_utils.py b/nautobot_floor_plan/tests/test_utils.py deleted file mode 100644 index 266cd42..0000000 --- a/nautobot_floor_plan/tests/test_utils.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Test floorplan utils.""" - -import unittest - -from nautobot_floor_plan import utils - - -class TestUtils(unittest.TestCase): - """Test class.""" - - def test_grid_number_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.grid_number_to_letter(num), expected) - - def test_gird_letter_to_number(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.grid_letter_to_number(letter), expected) diff --git a/nautobot_floor_plan/tests/test_views.py b/nautobot_floor_plan/tests/test_views.py index 6ebbc9e..a80b3de 100644 --- a/nautobot_floor_plan/tests/test_views.py +++ b/nautobot_floor_plan/tests/test_views.py @@ -34,4 +34,6 @@ def setUpTestData(cls): "y_axis_step": 1, "x_axis_labels": choices.AxisLabelsChoices.NUMBERS, "y_axis_labels": choices.AxisLabelsChoices.NUMBERS, + "x_custom_ranges": [{}], # Placeholder for x_custom_ranges + "y_custom_ranges": [{}], # Placeholder for y_custom_ranges } diff --git a/nautobot_floor_plan/utils/custom_validators.py b/nautobot_floor_plan/utils/custom_validators.py new file mode 100644 index 0000000..fa0fea7 --- /dev/null +++ b/nautobot_floor_plan/utils/custom_validators.py @@ -0,0 +1,336 @@ +"""Validators for nautobot_floor_plan.""" + +from dataclasses import dataclass + +from django import forms +from django.core.exceptions import ValidationError +from django.core.validators import BaseValidator + +from nautobot_floor_plan import choices +from nautobot_floor_plan.utils import general +from nautobot_floor_plan.utils.label_converters import LabelConverterFactory + + +class RangeValidator: + """Helper class to validate custom ranges.""" + + def __init__(self, max_size): + """Initialize validator with max size.""" + self.max_size = max_size + self.current_range = None + + def validate_required_keys(self, label_range): + """Validate that all required keys are present in the range.""" + required_keys = {"start", "end", "label_type"} + if not all(k in label_range for k in required_keys): + raise forms.ValidationError(f"Range is missing required keys {required_keys}.") + + def validate_label_type(self, label_type): + """Validate the label type is valid.""" + if label_type not in dict(choices.CustomAxisLabelsChoices.CHOICES): + raise forms.ValidationError( + f"Invalid label type '{label_type}' ." + f"Valid types are: {', '.join(choices.CustomAxisLabelsChoices.values())}" + ) + + @dataclass + class RangeData: + """ + Represents data required for range calculations. + + Attributes: + start (str): The starting label of the range. + end (str): The ending label of the range. + start_num (int): The numeric equivalent of the starting label. + end_num (int): The numeric equivalent of the ending label. + step (int): The step interval for generating range labels. + label_type (str): The type of labels (e.g., 'alphanumeric', 'numbers'). + """ + + start: str + end: str + start_num: int + end_num: int + step: int + label_type: str + + def validate_numeric_range(self, start, end, current_range=None): + """Validate numeric ranges (hex, binary) considering step size.""" + self.current_range = current_range + try: + start_num = int(start) + end_num = int(end) + step = self.get_step_from_range() + + range_data = self.RangeData( + start=start, end=end, start_num=start_num, end_num=end_num, step=step, label_type="" + ) + + effective_size = self._calculate_numeric_range_size( + range_data.start_num, range_data.end_num, range_data.step + ) + + if effective_size > self.max_size: + raise ValidationError( + f"Range from {start} to {end} has effective size {effective_size}, " + f"exceeding maximum size of {self.max_size}" + ) + except ValueError as e: + raise ValidationError(f"Invalid numeric values - {str(e)}") from e + + def get_step_from_range(self): + """Get step value from the range.""" + return self.current_range.get("step", 1) if self.current_range else 1 + + def validate_numalpha_prefix(self, start, end, _): + """Validate that numalpha prefixes match.""" + start_prefix, _ = general.extract_prefix_and_letter(start) + end_prefix, _ = general.extract_prefix_and_letter(end) + + if start_prefix != end_prefix: + raise ValidationError( + f"Range: '{start_prefix}' != '{end_prefix}'. Use separate ranges for different prefixes" + ) + + def validate_increment_letter_for_numbers(self, increment_letter): + """Validate increment_letter for numbers.""" + if increment_letter: + raise ValidationError("increment_letter must be False when using numeric labels") + + def validate_custom_range(self, start, end, label_type, current_range=None): + """Validate custom label ranges considering step size and prefix matching.""" + self.current_range = current_range + try: + converter = LabelConverterFactory.get_converter(label_type) + + # Set number-only mode for number labels + if label_type == choices.CustomAxisLabelsChoices.NUMBERS: + converter.set_number_only_mode(True) + + # Get numeric values and create range data + range_data = self.RangeData( + start=start, + end=end, + start_num=converter.to_numeric(start), + end_num=converter.to_numeric(end), + step=self.get_step_from_range(), + label_type=label_type, + ) + + # Check numalpha prefix matching + if label_type == choices.CustomAxisLabelsChoices.NUMALPHA: + self.validate_numalpha_prefix(start, end, current_range) + + # Check alphanumeric labels for valid letters + if label_type == choices.CustomAxisLabelsChoices.ALPHANUMERIC: + if not (self._contains_letter(start) and self._contains_letter(end)): + raise ValidationError( + f"Invalid alphanumeric range: '{start}' to '{end}' must include alphabetic characters. " + f"Use label_type '{choices.CustomAxisLabelsChoices.NUMBERS}' if no letters are needed." + ) + + effective_size = self._calculate_effective_size(range_data) + + if effective_size > self.max_size: + raise ValidationError( + f"Range from {start} to {end} has effective size {effective_size}, " + f"exceeding maximum size of {self.max_size}" + ) + except ValueError as e: + raise ValidationError(f"Invalid values for {label_type} - {str(e)}") from e + + def _contains_letter(self, label): + """Check if a label contains at least one alphabetic character.""" + return any(char.isalpha() for char in label) + + def _calculate_effective_size(self, range_data: RangeData): + """Calculate the effective size of the range based on label type and configuration.""" + if range_data.label_type in [choices.CustomAxisLabelsChoices.LETTERS, choices.CustomAxisLabelsChoices.NUMALPHA]: + return self._calculate_letter_range_size(range_data.start, range_data.end) + return self._calculate_numeric_range_size(range_data.start_num, range_data.end_num, range_data.step) + + def _calculate_letter_range_size(self, start, end): + """Calculate the size of letter-based ranges.""" + _, start_letters = general.extract_prefix_and_letter(start) + _, end_letters = general.extract_prefix_and_letter(end) + + # Check if this is a letters-only range + if self.current_range and self.current_range.get("label_type") == choices.CustomAxisLabelsChoices.LETTERS: + if not start.isalpha() or not end.isalpha(): + raise ValidationError(f"Invalid values for letters: '{start}, {end}'") + + if self.current_range and self.current_range.get("increment_letter"): + # For increment_letter=True, use full grid_letter_to_number conversion + start_pos = general.grid_letter_to_number(start_letters) + end_pos = general.grid_letter_to_number(end_letters) + else: + # For increment_letter=False, only look at first character + start_pos = general.grid_letter_to_number(start_letters[0]) + end_pos = general.grid_letter_to_number(end_letters[0]) + + step = self.get_step_from_range() + range_size = end_pos - start_pos + 1 + + if step and step < 0: + return range_size if start_pos >= end_pos else 0 + return range_size if start_pos <= end_pos else 0 + + def _calculate_numeric_range_size(self, start_num, end_num, step): + """Calculate the size of numeric ranges.""" + range_data = self.RangeData( + start=str(start_num), + end=str(end_num), + start_num=start_num, + end_num=end_num, + step=step, + label_type="", + ) + + if range_data.step and range_data.step < 0: + if range_data.start_num < range_data.end_num: + raise ValidationError( + f"With negative step {range_data.step}, start value must be greater than end value" + ) + return len(range(range_data.start_num, range_data.end_num - 1, range_data.step)) + + if range_data.start_num > range_data.end_num: + raise ValidationError(f"With positive step {range_data.step}, start value must be less than end value") + return len(range(range_data.start_num, range_data.end_num + 1, abs(range_data.step) if range_data.step else 1)) + + def validate_increment_letter(self, label_range, label_type): + """Validate increment_letter if present for letter type.""" + if label_type == choices.CustomAxisLabelsChoices.LETTERS: + increment_letter = label_range.get("increment_letter") + if increment_letter is not None and not isinstance(increment_letter, bool): + raise forms.ValidationError("increment_letter at must be a boolean value.") + + def check_range_overlap(self, range1, range2): + """Check if two ranges overlap.""" + # Only check overlap for ranges of the same label type + if range1["label_type"] != range2["label_type"]: + return False + + label_type = range1["label_type"] + converter = LabelConverterFactory.get_converter(label_type) + + def alphanumeric_overlap(range1, range2): + """Check overlap for alphanumeric ranges.""" + # Extract prefix and numbers using the utility function + prefix1, num1 = general.extract_prefix_and_number(range1["start"]) + _, num1_end = general.extract_prefix_and_number(range1["end"]) + prefix2, num2 = general.extract_prefix_and_number(range2["start"]) + _, num2_end = general.extract_prefix_and_number(range2["end"]) + + # Different prefixes can't overlap + if prefix1 != prefix2: + return False + + # Convert to integers for comparison + num1_start = int(num1) + num1_end = int(num1_end) + num2_start = int(num2) + num2_end = int(num2_end) + + # Check numeric overlap + return not (num1_end < num2_start or num2_end < num1_start) + + def numalpha_overlap(range1, range2): + """Check overlap for numalpha ranges.""" + # Extract prefix (numbers) and letters + prefix1, letter1_start = general.extract_prefix_and_letter(range1["start"]) + _, letter1_end = general.extract_prefix_and_letter(range1["end"]) + prefix2, letter2_start = general.extract_prefix_and_letter(range2["start"]) + _, letter2_end = general.extract_prefix_and_letter(range2["end"]) + + # Different numeric prefixes can't overlap + if prefix1 != prefix2: + return False + + # For numalpha ranges, we need to consider the direction (step) + step1 = range1.get("step", 1) + step2 = range2.get("step", 1) + + # Convert letters to numeric values using existing utility function + start1 = general.grid_letter_to_number(letter1_start) + end1 = general.grid_letter_to_number(letter1_end) + start2 = general.grid_letter_to_number(letter2_start) + end2 = general.grid_letter_to_number(letter2_end) + + # Adjust ranges based on step direction + if step1 < 0: + start1, end1 = end1, start1 + if step2 < 0: + start2, end2 = end2, start2 + + # Check for overlap + return not (end1 < start2 or end2 < start1) + + if label_type == choices.CustomAxisLabelsChoices.ALPHANUMERIC: + return alphanumeric_overlap(range1, range2) + + if label_type == choices.CustomAxisLabelsChoices.NUMALPHA: + return numalpha_overlap(range1, range2) + + if label_type == choices.CustomAxisLabelsChoices.NUMBERS: + converter.set_number_only_mode(True) + + def create_range_data(range_info): + """Helper to create a RangeData object.""" + # Validate step value first + step = range_info.get("step", 1) + if step == 0: + raise ValidationError("Step value must be a non-zero integer.") + + return self.RangeData( + start=range_info["start"], + end=range_info["end"], + start_num=converter.to_numeric(range_info["start"]), + end_num=converter.to_numeric(range_info["end"]), + step=step, + label_type=label_type, + ) + + try: + range1_data = create_range_data(range1) + range2_data = create_range_data(range2) + + def generate_labels(range_data): + """Generate label set for a range.""" + return { + converter.from_numeric(i) + for i in range(range_data.start_num, range_data.end_num + 1, range_data.step) + } + + labels1 = generate_labels(range1_data) + labels2 = generate_labels(range2_data) + + # Check for any common labels + return bool(labels1.intersection(labels2)) + except ValueError as e: + raise ValidationError(f"Invalid values for {label_type} - {str(e)}") from e + + def validate_multiple_ranges(self, ranges): + """Validate that multiple ranges don't overlap.""" + if not ranges or len(ranges) <= 1: + return + + # Check each pair of ranges for overlap + for i, range1 in enumerate(ranges): + for range2 in ranges[i + 1 :]: + if self.check_range_overlap(range1, range2): + raise ValidationError("Ranges overlap") + + +class ValidateNotZero(BaseValidator): + """Ensure that the field's value is not zero.""" + + message = "Must be a positive or negative Integer not equal to zero." + code = "zero_not_allowed" + + def __call__(self, value): + """Ensure that the field's value is not zero.""" + if value == 0: + raise ValidationError( + self.message, + code=self.code, + ) diff --git a/nautobot_floor_plan/utils.py b/nautobot_floor_plan/utils/general.py similarity index 50% rename from nautobot_floor_plan/utils.py rename to nautobot_floor_plan/utils/general.py index 0fdd010..d3d6a7c 100644 --- a/nautobot_floor_plan/utils.py +++ b/nautobot_floor_plan/utils/general.py @@ -25,28 +25,77 @@ def grid_letter_to_number(letter): return number +def extract_prefix_and_letter(label): + """Helper function to split a label into prefix and letter parts.""" + prefix = "" + letters = label + + for i, char in enumerate(label): + if char.isalpha(): + prefix = label[:i] + letters = label[i:] + break + + return prefix, letters + + +def extract_prefix_and_number(label): + """Helper function to split a label into prefix and number parts.""" + prefix = "" + numbers = label + + for i, char in enumerate(label): + if char.isdigit(): + prefix = label[:i] + numbers = label[i:] + break + + return prefix, numbers + + +def letter_conversion(converted_location): + """Returns letter conversion and handles wrap around.""" + # Set wrap around value of ZZZ + total_cells = 18278 # Total cells for AAA-ZZZ + # Adjust for wrap-around when working with letters A or ZZZ + if converted_location < 1: + converted_location = total_cells + converted_location + elif converted_location > total_cells: + converted_location -= total_cells + return grid_number_to_letter(converted_location) + + def axis_init_label_conversion(axis_origin, axis_location, step, is_letters): - """Returns the correct label position, converting to letters if `letters` is True.""" - if is_letters: - axis_location = grid_letter_to_number(axis_location) - # Calculate the converted location based on origin, step, and location - converted_location = axis_origin + (int(axis_location) - int(axis_origin)) * step - # Check if we need wrap around due to letters being chosen - if is_letters: - # Set wrap around value of ZZZ - total_cells = 18278 - # Adjust for wrap-around when working with letters A or ZZZ - if converted_location < 1: - converted_location = total_cells + converted_location - elif converted_location > total_cells: - converted_location -= total_cells - result_label = grid_number_to_letter(converted_location) - return result_label - return converted_location + """Convert an axis location to its label based on the origin, step, and label type.""" + try: + if is_letters: + axis_location = grid_letter_to_number(axis_location) + converted_location = axis_origin + (int(axis_location) - int(axis_origin)) * step -def axis_clean_label_conversion(axis_origin, axis_label, step, is_letters): + if is_letters: + return letter_conversion(converted_location) + + return converted_location + except ValidationError as e: + raise ValidationError( + f"Error in axis conversion: axis_origin={axis_origin}, " + f"axis_location={axis_location}, step={step}, is_letters={is_letters}" + ) from e + + +def axis_clean_label_conversion(axis_origin, axis_label, step, is_letters, custom_ranges=None): """Returns the correct database label position.""" + # First check if we have custom ranges that apply + if custom_ranges: + for custom_range in custom_ranges: + start = custom_range["start"] + end = custom_range["end"] + # If our label falls within this custom range, use it + if start <= axis_label <= end: + return axis_label # Return the original label as-is + + # If no custom range applies, use the default conversion logic total_cells = 18278 # Convert letters to numbers if needed if is_letters: @@ -75,6 +124,9 @@ def axis_clean_label_conversion(axis_origin, axis_label, step, is_letters): return str(original_location) +# Depreciate in version 2.6 and remove in 2.7 + + def validate_not_zero(value): """Prevent the usage of 0 as a value in the step form field or model attribute.""" if value == 0: diff --git a/nautobot_floor_plan/utils/label_converters.py b/nautobot_floor_plan/utils/label_converters.py new file mode 100644 index 0000000..6ef8459 --- /dev/null +++ b/nautobot_floor_plan/utils/label_converters.py @@ -0,0 +1,704 @@ +"""Label conversion utilities.""" + +import logging +import re + +from nautobot_floor_plan.choices import CustomAxisLabelsChoices +from nautobot_floor_plan.utils.general import ( + extract_prefix_and_letter, + extract_prefix_and_number, + grid_letter_to_number, + grid_number_to_letter, +) + +logger = logging.getLogger(__name__) + + +class BaseLabelConverter: + """Base class for converting position to labels and back.""" + + def __init__(self, axis, fp_obj): + """Initializing base Label variables.""" + self.axis = axis + self.fp_obj = fp_obj + self.current_position = 1 + + def _get_custom_ranges(self): + """Retrieve and order custom ranges for the axis.""" + return self.fp_obj.custom_labels.filter(axis=self.axis).order_by("id") + + def _get_label_converter(self, label_type): + """Retrieve the proper converter for label type.""" + return LabelConverterFactory.get_converter(label_type) + + def _calculate_range_size(self, custom_range): + """Calculate the range size for the given custom range.""" + converter = self._get_label_converter(custom_range.label_type) + if custom_range.label_type == CustomAxisLabelsChoices.ALPHANUMERIC: + converter.set_increment_prefix(custom_range.increment_letter) + start_value = converter.to_numeric(custom_range.start_label) + end_value = converter.to_numeric(custom_range.end_label) + return abs(end_value - start_value) + 1 + + def _is_descending_range(self, start_value, end_value): + """Check if the range is descending.""" + return start_value > end_value + + def _check_label_in_range(self, label_value, start_value, end_value): + """Check if a label value falls within a numeric or letter range.""" + return min(start_value, end_value) <= label_value <= max(start_value, end_value) + + def _is_within_range(self, value, start, end): + """Check if a value falls within a numeric range.""" + min_value, max_value = min(start, end), max(start, end) + return min_value <= value <= max_value + + def _adjust_position(self, range_size): + """Increment the current position by the range size.""" + self.current_position += range_size + + def _calculate_relative_position(self, value, start_value, end_value): + """Calculate relative position based on ascending/descending range.""" + is_descending = self._is_descending_range(start_value, end_value) + if is_descending: + return start_value - value + 1 + return value - start_value + 1 + + def _is_letter_based_type(self, label_type): + """Check if the label type is letter-based.""" + return label_type in [CustomAxisLabelsChoices.NUMALPHA, CustomAxisLabelsChoices.LETTERS] + + def _convert_numeric_values(self, custom_range, value): + """Convert value and range bounds to numeric values.""" + converter = self._get_label_converter(custom_range.label_type) + converter.set_increment_prefix(custom_range.increment_letter) + + numeric_value = converter.to_numeric(value) + start_value = converter.to_numeric(custom_range.start_label) + end_value = converter.to_numeric(custom_range.end_label) + + return converter, numeric_value, start_value, end_value + + def _extract_prefix_and_letters(self, label, label_type): + """Extract prefix and letters based on label type.""" + if label_type == CustomAxisLabelsChoices.NUMALPHA: + return extract_prefix_and_letter(label) + return "", label + + def _calculate_position_from_values(self, numeric_value, start_value, end_value): + """Calculate position from numeric values, handling ascending/descending ranges.""" + relative_position = self._calculate_relative_position(numeric_value, start_value, end_value) + return self.current_position + relative_position - 1 + + +class PositionToLabelConverter(BaseLabelConverter): + """Class to modify the position to proper custom label in forms.""" + + def __init__(self, position, axis, fp_obj): + """Initialize Position to Label Converter variables.""" + super().__init__(axis, fp_obj) + self.position = position + + def convert(self): + """Main method to convert a position to its display label.""" + for custom_range in self._get_custom_ranges(): + range_size = self._calculate_range_size(custom_range) + + if self._position_in_range(range_size): + return self._calculate_label(custom_range) + + self._adjust_position(range_size) + + return None + + def _position_in_range(self, range_size): + """Check if the position falls within the current range.""" + return self.current_position <= self.position < self.current_position + range_size + + def _calculate_relative_numeric_value(self, start_value, end_value, relative_position): + """Calculate numeric value based on ascending/descending range.""" + is_descending = self._is_descending_range(start_value, end_value) + if is_descending: + numeric_value = start_value - (relative_position - 1) + return max(numeric_value, end_value) + + numeric_value = start_value + (relative_position - 1) + return min(numeric_value, end_value) + + def _calculate_label(self, custom_range): + """Generate the display label for the position.""" + relative_position = self.position - self.current_position + 1 + converter = self._get_label_converter(custom_range.label_type) + converter.set_increment_prefix(custom_range.increment_letter) + + start_value = converter.to_numeric(custom_range.start_label) + end_value = converter.to_numeric(custom_range.end_label) + + if not custom_range.increment_letter: + numeric_value = self._calculate_relative_numeric_value(start_value, end_value, relative_position) + else: + # Use existing incrementing logic for letter-based labels + is_descending = self._is_descending_range(start_value, end_value) + numeric_value = ( + start_value - (relative_position - 1) if is_descending else start_value + (relative_position - 1) + ) + + return converter.from_numeric(numeric_value) + + +class LabelToPositionConverter(BaseLabelConverter): + """Convert a label to its absolute position based on custom ranges.""" + + def __init__(self, label, axis, fp_obj): + """Initialize the label-to-position converter.""" + super().__init__(axis, fp_obj) + self.label = label + + def convert(self): + """Main method to convert a label to its absolute position.""" + for custom_range in self._get_custom_ranges(): + try: + converter = self._get_label_converter(custom_range.label_type) + converter.set_increment_prefix(custom_range.increment_letter) + + if self._label_in_range(custom_range): + absolute_position = self._calculate_position(custom_range, converter) + return absolute_position, self.label + + self._adjust_position(self._calculate_range_size(custom_range)) + + except ValueError as e: + logger.error("Error processing range: %s", e) + continue + + raise ValueError(f"Value {self.label} is not within any defined range") + + def _label_in_range(self, custom_range): + """Check if label is within the given range.""" + if custom_range.label_type in [CustomAxisLabelsChoices.ALPHANUMERIC, CustomAxisLabelsChoices.NUMBERS]: + return self._label_in_alphanumeric_range(custom_range) + if self._is_letter_based_type(custom_range.label_type): + return self._label_in_letter_range(custom_range) + return self._label_in_numeric_range(custom_range) + + def _calculate_position(self, custom_range, converter): + """Calculate absolute position for any label type.""" + if custom_range.label_type in [CustomAxisLabelsChoices.ALPHANUMERIC, CustomAxisLabelsChoices.NUMBERS]: + return self._calculate_alphanumeric_position(custom_range) + + numeric_value = converter.to_numeric(self.label) + start_value = converter.to_numeric(custom_range.start_label) + end_value = converter.to_numeric(custom_range.end_label) + + return self._calculate_position_from_values(numeric_value, start_value, end_value) + + def _label_in_letter_range(self, custom_range): + """Check if the label is within a letter-based range.""" + if custom_range.label_type == CustomAxisLabelsChoices.LETTERS: + _, value, start_value, end_value = self._convert_numeric_values(custom_range, self.label) + return self._check_label_in_range(value, start_value, end_value) + + # Original logic for NUMALPHA + start_prefix, start_letters = self._extract_prefix_and_letters( + custom_range.start_label, custom_range.label_type + ) + value_prefix, value_letters = self._extract_prefix_and_letters(self.label, custom_range.label_type) + + return value_prefix == start_prefix and len(value_letters) == len(start_letters) + + def _label_in_numeric_range(self, custom_range): + """Check if the label is within a numeric range.""" + try: + _, numeric_value, start_value, end_value = self._convert_numeric_values(custom_range, self.label) + return self._check_label_in_range(numeric_value, start_value, end_value) + except ValueError as e: + logger.error("Error converting numeric values: %s", e) + return False + + def _extract_alphanumeric_parts(self, label, custom_range): + """Extract and convert alphanumeric parts from label and range.""" + # Extract parts + parts = { + "label": extract_prefix_and_number(str(label)), + "start": extract_prefix_and_number(custom_range.start_label), + "end": extract_prefix_and_number(custom_range.end_label), + } + + # Convert numeric parts + try: + numbers = { + "label": int(parts["label"][1]), + "start": int(parts["start"][1]), + "end": int(parts["end"][1]), + } + prefixes = { + "label": parts["label"][0], + "start": parts["start"][0], + "end": parts["end"][0], + } + return prefixes, numbers + except ValueError: + return None, None + + def _check_letter_based_range(self, prefixes, numbers, start_num): + """Check if label is within a letter-based range.""" + if numbers["label"] != start_num: + return False + + converter = self._get_label_converter(CustomAxisLabelsChoices.LETTERS) + values = { + "label": converter.to_numeric(prefixes["label"]), + "start": converter.to_numeric(prefixes["start"]), + "end": converter.to_numeric(prefixes["end"]), + } + return self._check_label_in_range(values["label"], values["start"], values["end"]) + + def _label_in_alphanumeric_range(self, custom_range): + """Check if the label is within an alphanumeric range.""" + try: + prefixes, numbers = self._extract_alphanumeric_parts(self.label, custom_range) + if not prefixes or not numbers: + return False + + if custom_range.increment_letter: + return self._check_letter_based_range(prefixes, numbers, numbers["start"]) + + # Incrementing numbers: check letter equality and number range + if prefixes["label"] != prefixes["start"]: + return False + + return self._check_label_in_range(numbers["label"], numbers["start"], numbers["end"]) + + except (ValueError, AttributeError) as e: + logger.error("Error in _label_in_alphanumeric_range: %s", e) + return False + + def _calculate_letter_based_position(self, prefixes): + """Calculate position for letter-based labels.""" + converter = self._get_label_converter(CustomAxisLabelsChoices.LETTERS) + values = { + "label": converter.to_numeric(prefixes["label"]), + "start": converter.to_numeric(prefixes["start"]), + "end": converter.to_numeric(prefixes["end"]), + } + return self._calculate_position_from_values(values["label"], values["start"], values["end"]) + + def _calculate_alphanumeric_position(self, custom_range): + """Calculate position for alphanumeric labels.""" + try: + prefixes, numbers = self._extract_alphanumeric_parts(self.label, custom_range) + if not prefixes or not numbers: + return None + + if custom_range.increment_letter: + return self._calculate_letter_based_position(prefixes) + + # Handle ascending/descending number ranges + return self._calculate_position_from_values(numbers["label"], numbers["start"], numbers["end"]) + + except (ValueError, AttributeError) as e: + logger.error("Error in _calculate_alphanumeric_position: %s", e) + return None + + +class LabelConverter: + """Base class for label conversion.""" + + def __init__(self): + """Initialize converter.""" + self._increment_prefix = False + self.current_label = None + + def to_numeric(self, label): + """Convert label to numeric value.""" + raise NotImplementedError + + def from_numeric(self, number): + """Convert numeric value to label.""" + raise NotImplementedError + + def set_increment_prefix(self, increment_prefix): + """Set whether to increment the prefix instead of the number.""" + raise NotImplementedError + + +class NumalphaConverter(LabelConverter): + """Numalpha (e.g., 02A, 02AA) conversion.""" + + def __init__(self): + """Initialize the converter.""" + super().__init__() + self._prefix = "" + self._current_label = None + self._start_label = None + self._end_label = None + + def _handle_letter_only_conversion(self, letters): + """Handle conversion for letter-only labels.""" + return grid_letter_to_number(letters) + + def _handle_mixed_conversion(self, letters): + """Handle conversion for mixed alphanumeric labels.""" + if not letters: + return 1 + if self._increment_prefix: + return grid_letter_to_number(letters) + return ord(letters[0]) - ord("A") + 1 + + def to_numeric(self, label): + """Convert alphanumeric label to numeric value.""" + self._current_label = label + if not self._start_label: + self._start_label = label + self._end_label = label + + prefix, letters = extract_prefix_and_letter(label) + + if not letters: + raise ValueError(f"Invalid numalpha label: {label}") + + # Store the prefix for use in from_numeric + self._prefix = prefix + + # For letters type, always use grid_letter_to_number + if self._current_label.isalpha(): + numeric_value = self._handle_letter_only_conversion(letters) + else: + numeric_value = self._handle_mixed_conversion(letters) + + return numeric_value + + def _generate_letter_pattern(self, number, pattern_letters): + """Generate the letter pattern based on the numeric value.""" + if not self._increment_prefix: + # Use the pattern from start label + letter_length = len(pattern_letters) + if letter_length: + letter = grid_number_to_letter(number) + return letter * letter_length + return grid_number_to_letter(number) + + def from_numeric(self, number, prefix=""): + """Convert numeric value to Numalpha label.""" + if number < 1: + raise ValueError("Number must be positive") + + # Use provided prefix if given, otherwise use stored prefix + use_prefix = prefix if prefix else self._prefix + + # Extract pattern letters from the start label + _, pattern_letters = extract_prefix_and_letter(self._start_label) + + result_letters = self._generate_letter_pattern(number, pattern_letters) + result = f"{use_prefix}{result_letters}" + return result + + def set_increment_prefix(self, increment_prefix): + """Set whether to increment the prefix instead of the number.""" + self._increment_prefix = increment_prefix + + +class RomanConverter(LabelConverter): + """Roman numeral conversion.""" + + ROMAN_VALUES = [ + ("M", 1000), + ("CM", 900), + ("D", 500), + ("CD", 400), + ("C", 100), + ("XC", 90), + ("L", 50), + ("XL", 40), + ("X", 10), + ("IX", 9), + ("V", 5), + ("IV", 4), + ("I", 1), + ] + + def __init__(self): + """Initialize the converter.""" + super().__init__() + self._current_value = None + + def _convert_next_numeral(self, label, index): + """Convert next Roman numeral and return its value and new index.""" + # Try two character combinations first + if index + 1 < len(label): + double_char = label[index : index + 2] + for roman, value in self.ROMAN_VALUES: + if double_char == roman: + return value, index + 2 + + # Try single character + single_char = label[index] + for roman, value in self.ROMAN_VALUES: + if single_char == roman: + return value, index + 1 + + raise ValueError(f"Invalid Roman numeral character at position {index} in: {label}") + + def to_numeric(self, label): + """Convert Roman numeral to integer.""" + if not label: + raise ValueError("Roman numeral cannot be empty") + + result = 0 + index = 0 + label = label.upper() + + while index < len(label): + value, index = self._convert_next_numeral(label, index) + result += value + + self._current_value = result + return result + + def from_numeric(self, number, prefix=""): + """Convert integer to Roman numeral.""" + if not 0 < number < 4000: + raise ValueError("Number must be between 1 and 3999") + + result = [] + remaining = number + + for roman, value in self.ROMAN_VALUES: + while remaining >= value: + result.append(roman) + remaining -= value + + roman_numeral = "".join(result) + return f"{prefix}{roman_numeral}" if prefix else roman_numeral + + def set_increment_prefix(self, increment_prefix: bool) -> None: + """Set whether to increment the prefix instead of the number. + + For Roman numerals, this setting has no effect as Roman numerals + are always incremented as a whole number. + """ + + +class GreekConverter(LabelConverter): + """Greek letter conversion.""" + + GREEK_LETTERS = "αβγδεζηθικλμνξοπρστυφχψω" + GREEK_LETTER_MAP = {letter: i + 1 for i, letter in enumerate(GREEK_LETTERS)} + + def __init__(self): + """Initialize the converter.""" + super().__init__() + self._current_value = None + self._prefix = "" + + def to_numeric(self, label) -> int: + """Convert Greek letter to numeric value.""" + if not label: + raise ValueError("Greek letter cannot be empty") + + # Handle prefixed numbers (like α1, β2) + prefix = "" + greek_part = label.lower() + + # Split numeric suffix if exists + for i, char in enumerate(label): + if char.isdigit(): + prefix = label[i:] + greek_part = label[:i].lower() + break + + try: + base_value = self.GREEK_LETTER_MAP.get(greek_part) + if base_value is None: + raise ValueError(f"Invalid Greek letter: {greek_part}") + + self._current_value = base_value + if prefix: + self._prefix = prefix + return int(f"{base_value}{prefix}") + return base_value + + except ValueError as e: + raise ValueError(f"Invalid Greek letter: {label}") from e + + def from_numeric(self, number, prefix=""): + """Convert numeric value to Greek letter.""" + if number < 1 or number > len(self.GREEK_LETTERS): + raise ValueError(f"Number must be between 1 and {len(self.GREEK_LETTERS)}") + + greek_letter = self.GREEK_LETTERS[number - 1] + if prefix: + return f"{prefix}{greek_letter}" + if self._prefix: + return f"{greek_letter}{self._prefix}" + return greek_letter + + def set_increment_prefix(self, increment_prefix: bool) -> None: + """Set whether to increment the prefix instead of the number. + + For Greek letters, this setting has no effect as Greek letters + are always incremented as a whole. + """ + + +class BinaryConverter(LabelConverter): + """Binary number conversion.""" + + def __init__(self, min_digits=4): + """Initialize the converter with a minimum digit width.""" + super().__init__() + self.min_digits = min_digits + + def to_numeric(self, label) -> int: + """Convert a label to a numeric value.""" + try: + # If the label starts with '0b', interpret as binary + if isinstance(label, str) and label.startswith("0b"): + return int(label, 2) + # Otherwise treat as decimal + return int(label) + except ValueError as e: + raise ValueError(f"Label must be an integer or binary value, received: {label}") from e + + def from_numeric(self, number, prefix=""): + """Convert a numeric value to a binary string with a '0b' prefix.""" + if number < 0: + raise ValueError("Binary conversion requires positive numbers") + + binary = bin(number)[2:].zfill(self.min_digits) # Ensure minimum digit width + return f"{prefix}0b{binary}" if prefix else f"0b{binary}" + + def set_increment_prefix(self, increment_prefix: bool) -> None: + """Set whether to increment the prefix instead of the number. + + For binary numbers, this setting has no effect as binary numbers + are always incremented as a whole number. + """ + + +class HexConverter(LabelConverter): + """Hexadecimal number conversion.""" + + def __init__(self, min_digits=4): + """Initialize the converter with a minimum digit width.""" + super().__init__() + self.min_digits = min_digits + + def to_numeric(self, label) -> int: + """Convert a label to a numeric value.""" + try: + # If the label starts with '0x', interpret as hex + if isinstance(label, str) and label.startswith("0x"): + return int(label, 16) + # Otherwise treat as decimal + return int(label) + except ValueError as e: + raise ValueError(f"Label must be an integer or hex value, received: {label}") from e + + def from_numeric(self, number, prefix=""): + """Convert numeric value to hex string.""" + if number < 0: + raise ValueError("Hex conversion requires positive numbers") + + hex_val = hex(number)[2:].upper().zfill(self.min_digits) + return f"{prefix}0x{hex_val}" if prefix else f"0x{hex_val}" + + def set_increment_prefix(self, increment_prefix: bool) -> None: + """Set whether to increment the prefix instead of the number. + + For hex numbers, this setting has no effect as hex numbers + are always incremented as a whole number. + """ + + +class AlphanumericConverter(LabelConverter): + """Alphanumeric (e.g., A01, A1) and pure number (e.g., 01, 1) conversion.""" + + def __init__(self): + """Initialize the converter.""" + super().__init__() + self._prefix = "" + self._use_leading_zeros = None + self._number = None + self._is_number_only = False + self._increment_prefix = False + + def to_numeric(self, label) -> int: + """Convert alphanumeric or numeric label to numeric value.""" + if self._is_number_only: + if not label.isdigit(): + raise ValueError(f"Invalid number format: {label}") + self._use_leading_zeros = len(label) > 1 and label[0] == "0" + self._number = label + return int(label) + + prefix, number = extract_prefix_and_number(label) + if not number.isdigit(): + raise ValueError(f"Invalid alphanumeric label: {label}. Must have a numeric part.") + + self._prefix = prefix + self._use_leading_zeros = len(number) > 1 and number[0] == "0" + self._number = number + + if self._increment_prefix: + return sum((ord(c) - ord("A") + 1) * (26**i) for i, c in enumerate(reversed(prefix))) + return int(number) + + def from_numeric(self, number, prefix=""): + """Convert numeric value to alphanumeric or numeric label.""" + if number < 0: + raise ValueError("Number must be positive") + + if self._is_number_only: + return f"{number:02d}" if self._use_leading_zeros else str(number) + + if self._increment_prefix: + prefix = self._generate_prefix(number) + return f"{prefix}{self._number if self._use_leading_zeros else int(self._number)}" + + return f"{self._prefix}{number:02d}" if self._use_leading_zeros else f"{self._prefix}{number}" + + def _generate_prefix(self, number): + """Generate alphabetic prefix for a given numeric value.""" + if number < 1: + raise ValueError("Number must be positive") + return grid_number_to_letter(number) + + def set_increment_prefix(self, increment_prefix): + """Set whether to increment the prefix instead of the number.""" + self._increment_prefix = increment_prefix + + def set_number_only_mode(self, is_number_only): + """Set whether the converter should handle pure numbers.""" + self._is_number_only = is_number_only + + def set_prefix(self, prefix): + """Set the prefix for the label converter.""" + if not re.match(r"^[A-Z]+$", prefix): + raise ValueError("Prefix must be a non-empty string of uppercase letters") + self._prefix = prefix + + +class LabelConverterFactory: + """Factory for creating label converters.""" + + _converters = { + CustomAxisLabelsChoices.ROMAN: RomanConverter, + CustomAxisLabelsChoices.GREEK: GreekConverter, + CustomAxisLabelsChoices.BINARY: BinaryConverter, + CustomAxisLabelsChoices.HEX: HexConverter, + CustomAxisLabelsChoices.NUMALPHA: NumalphaConverter, + CustomAxisLabelsChoices.LETTERS: NumalphaConverter, + CustomAxisLabelsChoices.ALPHANUMERIC: AlphanumericConverter, + CustomAxisLabelsChoices.NUMBERS: AlphanumericConverter, + } + + @classmethod + def get_converter(cls, label_type): + """Get the appropriate converter for the label type.""" + converter_class = cls._converters.get(label_type) + if not converter_class: + raise ValueError( + f"Unsupported label type: {label_type}. " f"Supported types are: {', '.join(cls._converters.keys())}" + ) + return converter_class() diff --git a/nautobot_floor_plan/utils/label_generator.py b/nautobot_floor_plan/utils/label_generator.py new file mode 100644 index 0000000..d0751c0 --- /dev/null +++ b/nautobot_floor_plan/utils/label_generator.py @@ -0,0 +1,237 @@ +"""Label generation functionality for floor plans.""" + +from nautobot_floor_plan.choices import ( + AxisLabelsChoices, + CustomAxisLabelsChoices, +) +from nautobot_floor_plan.utils.general import ( + extract_prefix_and_letter, + extract_prefix_and_number, + grid_letter_to_number, + grid_number_to_letter, +) +from nautobot_floor_plan.utils.label_converters import LabelConverterFactory + + +class FloorPlanLabelGenerator: + """Handles generation of labels for floor plan axes.""" + + def __init__(self, floor_plan): + """Store instance of floor plan.""" + self.floor_plan = floor_plan + + def generate_labels(self, axis, count): + """Generate labels for the specified axis.""" + # Get custom ranges in the order they were created + custom_ranges = self.floor_plan.custom_labels.filter(axis=axis).order_by("id") + + if not custom_ranges.exists(): + return self._generate_default_labels(axis, count) + + labels = [] + for custom_range in custom_ranges: + range_labels = self._generate_labels_for_range( + custom_range, + custom_range.increment_letter, + ) + labels.extend(range_labels) + + if len(labels) >= count: + return labels[:count] + + # If custom ranges don't provide enough labels, fall back to default labeling + if len(labels) < count: + remaining_count = count - len(labels) + if axis == "X": + start = self.floor_plan.x_origin_seed + (len(labels) * self.floor_plan.x_axis_step) + step = self.floor_plan.x_axis_step + is_letters = self.floor_plan.x_axis_labels == AxisLabelsChoices.LETTERS + else: + start = self.floor_plan.y_origin_seed + (len(labels) * self.floor_plan.y_axis_step) + step = self.floor_plan.y_axis_step + is_letters = self.floor_plan.y_axis_labels == AxisLabelsChoices.LETTERS + + default_labels = self._generate_default_range(start, step, remaining_count, is_letters) + labels.extend(default_labels) + + return labels + + def _generate_default_labels(self, axis, count): + """Generate default labels for an axis.""" + if axis == "X": + start = self.floor_plan.x_origin_seed + step = self.floor_plan.x_axis_step + is_letters = self.floor_plan.x_axis_labels == AxisLabelsChoices.LETTERS + else: + start = self.floor_plan.y_origin_seed + step = self.floor_plan.y_axis_step + is_letters = self.floor_plan.y_axis_labels == AxisLabelsChoices.LETTERS + + return self._generate_default_range(start, step, count, is_letters) + + def _generate_default_range(self, start, step, count, is_letters): + """Generate a sequence of default labels.""" + labels = [] + current = start + + for _ in range(count): + if is_letters: + # Wrap around to 18278 (ZZZ) when going in reverse + if current < 1: + current = 18278 + current + elif current > 18278: + current = current - 18278 + labels.append(grid_number_to_letter(current)) + else: + labels.append(str(current)) + current += step + + return labels + + def _generate_labels_for_range(self, custom_range, increment_letter=True): + """Helper to generate labels for a given range.""" + labels = [] + start = str(custom_range.start_label) + end = str(custom_range.end_label) + step = custom_range.step + label_type = custom_range.label_type + + try: + converter = LabelConverterFactory.get_converter(label_type) + + if label_type == CustomAxisLabelsChoices.NUMBERS: + converter.set_number_only_mode(True) + elif label_type == CustomAxisLabelsChoices.ALPHANUMERIC: + converter.set_increment_prefix(increment_letter) + + if label_type in [CustomAxisLabelsChoices.NUMALPHA, CustomAxisLabelsChoices.LETTERS]: + if label_type == CustomAxisLabelsChoices.LETTERS: + labels = self._generate_letter_labels(start, end, increment_letter, step) + else: + labels = self._generate_numalpha_labels(start, end, increment_letter, step) + else: + labels = self._generate_numeric_labels(converter, start, end, step) + + except ValueError as e: + raise ValueError(f"Error in label generation: {e}") from e + + return labels + + def _generate_numalpha_labels(self, start, end, increment_letter, step=1): + """Generate labels for NUMALPHA type.""" + labels = [] + start_prefix, start_letters = extract_prefix_and_letter(start) + end_prefix, end_letters = extract_prefix_and_letter(end) + + if start_prefix != end_prefix: + raise ValueError(f"Prefix mismatch: {start_prefix} != {end_prefix}") + + current_prefix, current_letters = start_prefix, start_letters + + while True: + labels.append(f"{current_prefix}{current_letters}") + + if labels[-1] == end: + break + + if step > 0: + current_letters = self._increment_letters(current_letters, increment_letter, len(current_letters)) + if self._should_stop_label_generation(current_letters, end_letters): + break + else: + current_letters = self._decrement_letters(current_letters, increment_letter, end_letters) + if not current_letters: + break + + return labels + + def _generate_letter_labels(self, start, end, increment_letter, step=1): + """Generate labels for LETTERS type.""" + labels = [] + start_prefix, start_letters = extract_prefix_and_number(start) + _, end_letters = extract_prefix_and_number(end) + + while True: + labels.append(f"{start_prefix}{start_letters}") + + if start_letters == end_letters: + break + + # Increment or decrement letters based on step direction + if step > 0: + start_letters = self._increment_letters(start_letters, increment_letter, len(start_letters)) + else: + start_letters = self._decrement_letters(start_letters, increment_letter, end_letters) + + return labels + + def _increment_letters(self, current_letters, increment_letter, length): + """Increment the letters based on the desired strategy.""" + if increment_letter: + # Use grid_number_to_letter and grid_letter_to_number for the carry-over logic + current_number = grid_letter_to_number(current_letters) + next_number = current_number + 1 + return grid_number_to_letter(next_number) + # Increment all letters + next_letter = chr(ord(current_letters[0]) + 1) + return next_letter * length + + def _decrement_letters(self, letters, increment_letter, end_letters): + """Decrement letters in a label.""" + if not letters: + return None + + # Convert letters to list for manipulation + letter_list = list(letters) + + if increment_letter: + # Only decrement the last letter + last_idx = len(letter_list) - 1 + if letter_list[last_idx] > "A": + letter_list[last_idx] = chr(ord(letter_list[last_idx]) - 1) + else: + return None + else: + # Decrement all letters together + for i, letter in enumerate(letter_list): + if letter > "A": + letter_list[i] = chr(ord(letter) - 1) + else: + return None + + result = "".join(letter_list) + + # Check if we've gone past the end + if end_letters and ( + (increment_letter and result < end_letters) or (not increment_letter and result < end_letters) + ): + return None + + return result + + def _generate_numeric_labels(self, converter, start, end, step): + """Generate labels for numeric or other types.""" + labels = [] + # Set the format based on the start label + if hasattr(converter, "set_format"): + converter.set_format(start) + + current = converter.to_numeric(start) + end_val = converter.to_numeric(end) + + while True: + labels.append(converter.from_numeric(current)) + if current == end_val: + break + current += step + if (step > 0 and current > end_val) or (step < 0 and current < end_val): + break + return labels + + def _should_stop_label_generation(self, current_letters, end_letters): + """Determine if the label generation should stop.""" + if len(current_letters) > len(end_letters): + return True + if len(current_letters) == len(end_letters) and current_letters > end_letters: + return True + return False diff --git a/poetry.lock b/poetry.lock index 613d84e..c1705b8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -365,127 +365,114 @@ pycparser = "*" [[package]] name = "charset-normalizer" -version = "3.4.0" +version = "3.4.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false -python-versions = ">=3.7.0" +python-versions = ">=3.7" files = [ - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, - {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, - {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, + {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, + {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, ] [[package]] name = "click" -version = "8.1.7" +version = "8.1.8" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" files = [ - {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, - {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, + {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, + {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, ] [package.dependencies] @@ -754,13 +741,13 @@ profile = ["gprof2dot (>=2022.7.29)"] [[package]] name = "django" -version = "4.2.17" +version = "4.2.18" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.8" files = [ - {file = "Django-4.2.17-py3-none-any.whl", hash = "sha256:3a93350214ba25f178d4045c0786c61573e7dbfa3c509b3551374f1e11ba8de0"}, - {file = "Django-4.2.17.tar.gz", hash = "sha256:6b56d834cc94c8b21a8f4e775064896be3b4a4ca387f2612d4406a5927cd2fdc"}, + {file = "Django-4.2.18-py3-none-any.whl", hash = "sha256:ba52eff7e228f1c775d5b0db2ba53d8c49d2f8bfe6ca0234df6b7dd12fb25b19"}, + {file = "Django-4.2.18.tar.gz", hash = "sha256:52ae8eacf635617c0f13b44f749e5ea13dc34262819b2cc8c8636abb08d82c4b"}, ] [package.dependencies] @@ -1059,6 +1046,23 @@ Django = ">=3.2" [package.extras] tablib = ["tablib"] +[[package]] +name = "django-tables2" +version = "2.7.5" +description = "Table/data-grid framework for Django" +optional = false +python-versions = ">=3.9" +files = [ + {file = "django_tables2-2.7.5-py3-none-any.whl", hash = "sha256:d9338937797207ffb6f481be2125c5ec3a0bb1858d409c672cc25fc5d654cb22"}, + {file = "django_tables2-2.7.5.tar.gz", hash = "sha256:fb5dcaa09379cf3947598ec7e1bd5f26ed63aafdee3b23963446763bbeac37bf"}, +] + +[package.dependencies] +django = ">=4.2" + +[package.extras] +tablib = ["tablib"] + [[package]] name = "django-taggit" version = "5.0.1" @@ -1241,13 +1245,13 @@ dev = ["flake8", "markdown", "twine", "wheel"] [[package]] name = "gitdb" -version = "4.0.11" +version = "4.0.12" description = "Git Object Database" optional = false python-versions = ">=3.7" files = [ - {file = "gitdb-4.0.11-py3-none-any.whl", hash = "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4"}, - {file = "gitdb-4.0.11.tar.gz", hash = "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b"}, + {file = "gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf"}, + {file = "gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571"}, ] [package.dependencies] @@ -1255,20 +1259,20 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.43" +version = "3.1.44" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" files = [ - {file = "GitPython-3.1.43-py3-none-any.whl", hash = "sha256:eec7ec56b92aad751f9912a73404bc02ba212a23adb2c7098ee668417051a1ff"}, - {file = "GitPython-3.1.43.tar.gz", hash = "sha256:35f314a9f878467f5453cc1fee295c3e18e52f1b99f10f6cf5b1682e968a9e7c"}, + {file = "GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110"}, + {file = "gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269"}, ] [package.dependencies] gitdb = ">=4.0.1,<5" [package.extras] -doc = ["sphinx (==4.3.2)", "sphinx-autodoc-typehints", "sphinx-rtd-theme", "sphinxcontrib-applehelp (>=1.0.2,<=1.0.4)", "sphinxcontrib-devhelp (==1.0.2)", "sphinxcontrib-htmlhelp (>=2.0.0,<=2.0.1)", "sphinxcontrib-qthelp (==1.0.3)", "sphinxcontrib-serializinghtml (==1.1.5)"] +doc = ["sphinx (>=7.1.2,<7.2)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"] test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions"] [[package]] @@ -1562,13 +1566,13 @@ testing = ["Django", "attrs", "colorama", "docopt", "pytest (<9.0.0)"] [[package]] name = "jinja2" -version = "3.1.4" +version = "3.1.5" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" files = [ - {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, - {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, + {file = "jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"}, + {file = "jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb"}, ] [package.dependencies] @@ -1982,13 +1986,13 @@ mkdocstrings = ">=0.25" [[package]] name = "nautobot" -version = "2.3.13" +version = "2.3.16" description = "Source of truth and network automation platform." optional = false python-versions = "<3.13,>=3.8" files = [ - {file = "nautobot-2.3.13-py3-none-any.whl", hash = "sha256:b851be96be0ab667cba570507a4ff19298b63df128680e3694af2b4bf8b7db02"}, - {file = "nautobot-2.3.13.tar.gz", hash = "sha256:13d35823be0c9af2a702d8ece52cb4a0f0e1035cd823203f0aa6130ef03daa82"}, + {file = "nautobot-2.3.16-py3-none-any.whl", hash = "sha256:60a1043c97ca0c6575c01ee7b92d28da761843d449d6ad1f038ba2dafeefcaf3"}, + {file = "nautobot-2.3.16.tar.gz", hash = "sha256:92aed5dfbf457f52f47b96191103dd327981b0173bc8f813dc03a6c929cda45b"}, ] [package.dependencies] @@ -2008,7 +2012,10 @@ django-prometheus = ">=2.3.1,<2.4.0" django-redis = ">=5.4.0,<5.5.0" django-silk = ">=5.1.0,<5.2.0" django-structlog = {version = ">=8.1.0,<9.0.0", extras = ["celery"]} -django-tables2 = ">=2.7.0,<2.8.0" +django-tables2 = [ + {version = "2.7.0", markers = "python_version < \"3.9\""}, + {version = ">=2.7.4,<2.8.0", markers = "python_version >= \"3.9\""}, +] django-taggit = ">=5.0.0,<5.1.0" django-timezone-field = ">=7.0,<7.1" django-tree-queries = ">=0.19.0,<0.20.0" @@ -2020,14 +2027,14 @@ emoji = ">=2.12.1,<2.13.0" GitPython = ">=3.1.43,<3.2.0" graphene-django = ">=2.16.0,<2.17.0" graphene-django-optimizer = ">=0.8.0,<0.9.0" -Jinja2 = ">=3.1.4,<3.2.0" +Jinja2 = ">=3.1.5,<3.2.0" jsonschema = ">=4.7.0,<5.0.0" kombu = ">=5.4.2,<5.5.0" Markdown = ">=3.6,<3.7" MarkupSafe = ">=2.1.5,<2.2.0" netaddr = ">=1.3.0,<1.4.0" netutils = ">=1.6.0,<2.0.0" -nh3 = ">=0.2.19,<0.3.0" +nh3 = ">=0.2.20,<0.3.0" packaging = ">=23.1" Pillow = ">=10.3.0,<10.4.0" prometheus-client = ">=0.20.0,<0.21.0" @@ -2062,17 +2069,17 @@ nicer-shell = ["ipython"] [[package]] name = "netutils" -version = "1.10.0" +version = "1.12.0" description = "Common helper functions useful in network automation." optional = false python-versions = "<4.0,>=3.8" files = [ - {file = "netutils-1.10.0-py3-none-any.whl", hash = "sha256:19b8cc3d2cf567a986f916c90f298d241af03a71c62ec6d38d6dc3395347670b"}, - {file = "netutils-1.10.0.tar.gz", hash = "sha256:f457fb85cb622e89aa0403fb2128c50986f7ce38d93a5873981727d088619793"}, + {file = "netutils-1.12.0-py3-none-any.whl", hash = "sha256:7cb37796ce86637814f8c899f64db2b054986b0eda719d3fcadc293d451a4db1"}, + {file = "netutils-1.12.0.tar.gz", hash = "sha256:96a790d11921063a6a64ee79c6e8c5a5ffcd05cbee07dd2b614d98c4416cffdd"}, ] [package.extras] -optionals = ["jsonschema (>=4.17.3,<5.0.0)", "napalm (>=4.0.0,<5.0.0)"] +optionals = ["jsonschema (>=4.17.3,<5.0.0)", "legacycrypt (==0.3)", "napalm (>=4.0.0,<5.0.0)"] [[package]] name = "nh3" @@ -2482,13 +2489,13 @@ files = [ [[package]] name = "pygments" -version = "2.18.0" +version = "2.19.1" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" files = [ - {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, - {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, + {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, + {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, ] [package.extras] @@ -2591,13 +2598,13 @@ pylint = ">=1.7" [[package]] name = "pymdown-extensions" -version = "10.12" +version = "10.14" description = "Extension pack for Python Markdown." optional = false python-versions = ">=3.8" files = [ - {file = "pymdown_extensions-10.12-py3-none-any.whl", hash = "sha256:49f81412242d3527b8b4967b990df395c89563043bc51a3d2d7d500e52123b77"}, - {file = "pymdown_extensions-10.12.tar.gz", hash = "sha256:b0ee1e0b2bef1071a47891ab17003bfe5bf824a398e13f49f8ed653b699369a7"}, + {file = "pymdown_extensions-10.14-py3-none-any.whl", hash = "sha256:202481f716cc8250e4be8fce997781ebf7917701b59652458ee47f2401f818b5"}, + {file = "pymdown_extensions-10.14.tar.gz", hash = "sha256:741bd7c4ff961ba40b7528d32284c53bc436b8b1645e8e37c3e57770b8700a34"}, ] [package.dependencies] @@ -2605,7 +2612,7 @@ markdown = ">=3.6" pyyaml = "*" [package.extras] -extra = ["pygments (>=2.12)"] +extra = ["pygments (>=2.19.1)"] [[package]] name = "python-crontab" @@ -3211,13 +3218,13 @@ files = [ [[package]] name = "smmap" -version = "5.0.1" +version = "5.0.2" description = "A pure Python implementation of a sliding window memory map manager" optional = false python-versions = ">=3.7" files = [ - {file = "smmap-5.0.1-py3-none-any.whl", hash = "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da"}, - {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"}, + {file = "smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e"}, + {file = "smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5"}, ] [[package]] @@ -3583,76 +3590,90 @@ test = ["pytest (>=6.0.0)", "setuptools (>=65)"] [[package]] name = "wrapt" -version = "1.17.0" +version = "1.17.2" description = "Module for decorators, wrappers and monkey patching." optional = false python-versions = ">=3.8" files = [ - {file = "wrapt-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a0c23b8319848426f305f9cb0c98a6e32ee68a36264f45948ccf8e7d2b941f8"}, - {file = "wrapt-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1ca5f060e205f72bec57faae5bd817a1560fcfc4af03f414b08fa29106b7e2d"}, - {file = "wrapt-1.17.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e185ec6060e301a7e5f8461c86fb3640a7beb1a0f0208ffde7a65ec4074931df"}, - {file = "wrapt-1.17.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb90765dd91aed05b53cd7a87bd7f5c188fcd95960914bae0d32c5e7f899719d"}, - {file = "wrapt-1.17.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:879591c2b5ab0a7184258274c42a126b74a2c3d5a329df16d69f9cee07bba6ea"}, - {file = "wrapt-1.17.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fce6fee67c318fdfb7f285c29a82d84782ae2579c0e1b385b7f36c6e8074fffb"}, - {file = "wrapt-1.17.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0698d3a86f68abc894d537887b9bbf84d29bcfbc759e23f4644be27acf6da301"}, - {file = "wrapt-1.17.0-cp310-cp310-win32.whl", hash = "sha256:69d093792dc34a9c4c8a70e4973a3361c7a7578e9cd86961b2bbf38ca71e4e22"}, - {file = "wrapt-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:f28b29dc158ca5d6ac396c8e0a2ef45c4e97bb7e65522bfc04c989e6fe814575"}, - {file = "wrapt-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:74bf625b1b4caaa7bad51d9003f8b07a468a704e0644a700e936c357c17dd45a"}, - {file = "wrapt-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f2a28eb35cf99d5f5bd12f5dd44a0f41d206db226535b37b0c60e9da162c3ed"}, - {file = "wrapt-1.17.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:81b1289e99cf4bad07c23393ab447e5e96db0ab50974a280f7954b071d41b489"}, - {file = "wrapt-1.17.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f2939cd4a2a52ca32bc0b359015718472d7f6de870760342e7ba295be9ebaf9"}, - {file = "wrapt-1.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6a9653131bda68a1f029c52157fd81e11f07d485df55410401f745007bd6d339"}, - {file = "wrapt-1.17.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4e4b4385363de9052dac1a67bfb535c376f3d19c238b5f36bddc95efae15e12d"}, - {file = "wrapt-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bdf62d25234290db1837875d4dceb2151e4ea7f9fff2ed41c0fde23ed542eb5b"}, - {file = "wrapt-1.17.0-cp311-cp311-win32.whl", hash = "sha256:5d8fd17635b262448ab8f99230fe4dac991af1dabdbb92f7a70a6afac8a7e346"}, - {file = "wrapt-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:92a3d214d5e53cb1db8b015f30d544bc9d3f7179a05feb8f16df713cecc2620a"}, - {file = "wrapt-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:89fc28495896097622c3fc238915c79365dd0ede02f9a82ce436b13bd0ab7569"}, - {file = "wrapt-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:875d240fdbdbe9e11f9831901fb8719da0bd4e6131f83aa9f69b96d18fae7504"}, - {file = "wrapt-1.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ed16d95fd142e9c72b6c10b06514ad30e846a0d0917ab406186541fe68b451"}, - {file = "wrapt-1.17.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18b956061b8db634120b58f668592a772e87e2e78bc1f6a906cfcaa0cc7991c1"}, - {file = "wrapt-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:daba396199399ccabafbfc509037ac635a6bc18510ad1add8fd16d4739cdd106"}, - {file = "wrapt-1.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4d63f4d446e10ad19ed01188d6c1e1bb134cde8c18b0aa2acfd973d41fcc5ada"}, - {file = "wrapt-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a5e7cc39a45fc430af1aefc4d77ee6bad72c5bcdb1322cfde852c15192b8bd4"}, - {file = "wrapt-1.17.0-cp312-cp312-win32.whl", hash = "sha256:0a0a1a1ec28b641f2a3a2c35cbe86c00051c04fffcfcc577ffcdd707df3f8635"}, - {file = "wrapt-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:3c34f6896a01b84bab196f7119770fd8466c8ae3dfa73c59c0bb281e7b588ce7"}, - {file = "wrapt-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:714c12485aa52efbc0fc0ade1e9ab3a70343db82627f90f2ecbc898fdf0bb181"}, - {file = "wrapt-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da427d311782324a376cacb47c1a4adc43f99fd9d996ffc1b3e8529c4074d393"}, - {file = "wrapt-1.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba1739fb38441a27a676f4de4123d3e858e494fac05868b7a281c0a383c098f4"}, - {file = "wrapt-1.17.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e711fc1acc7468463bc084d1b68561e40d1eaa135d8c509a65dd534403d83d7b"}, - {file = "wrapt-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:140ea00c87fafc42739bd74a94a5a9003f8e72c27c47cd4f61d8e05e6dec8721"}, - {file = "wrapt-1.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73a96fd11d2b2e77d623a7f26e004cc31f131a365add1ce1ce9a19e55a1eef90"}, - {file = "wrapt-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0b48554952f0f387984da81ccfa73b62e52817a4386d070c75e4db7d43a28c4a"}, - {file = "wrapt-1.17.0-cp313-cp313-win32.whl", hash = "sha256:498fec8da10e3e62edd1e7368f4b24aa362ac0ad931e678332d1b209aec93045"}, - {file = "wrapt-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd136bb85f4568fffca995bd3c8d52080b1e5b225dbf1c2b17b66b4c5fa02838"}, - {file = "wrapt-1.17.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:17fcf043d0b4724858f25b8826c36e08f9fb2e475410bece0ec44a22d533da9b"}, - {file = "wrapt-1.17.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4a557d97f12813dc5e18dad9fa765ae44ddd56a672bb5de4825527c847d6379"}, - {file = "wrapt-1.17.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0229b247b0fc7dee0d36176cbb79dbaf2a9eb7ecc50ec3121f40ef443155fb1d"}, - {file = "wrapt-1.17.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8425cfce27b8b20c9b89d77fb50e368d8306a90bf2b6eef2cdf5cd5083adf83f"}, - {file = "wrapt-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9c900108df470060174108012de06d45f514aa4ec21a191e7ab42988ff42a86c"}, - {file = "wrapt-1.17.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4e547b447073fc0dbfcbff15154c1be8823d10dab4ad401bdb1575e3fdedff1b"}, - {file = "wrapt-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:914f66f3b6fc7b915d46c1cc424bc2441841083de01b90f9e81109c9759e43ab"}, - {file = "wrapt-1.17.0-cp313-cp313t-win32.whl", hash = "sha256:a4192b45dff127c7d69b3bdfb4d3e47b64179a0b9900b6351859f3001397dabf"}, - {file = "wrapt-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:4f643df3d4419ea3f856c5c3f40fec1d65ea2e89ec812c83f7767c8730f9827a"}, - {file = "wrapt-1.17.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:69c40d4655e078ede067a7095544bcec5a963566e17503e75a3a3e0fe2803b13"}, - {file = "wrapt-1.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f495b6754358979379f84534f8dd7a43ff8cff2558dcdea4a148a6e713a758f"}, - {file = "wrapt-1.17.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:baa7ef4e0886a6f482e00d1d5bcd37c201b383f1d314643dfb0367169f94f04c"}, - {file = "wrapt-1.17.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8fc931382e56627ec4acb01e09ce66e5c03c384ca52606111cee50d931a342d"}, - {file = "wrapt-1.17.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8f8909cdb9f1b237786c09a810e24ee5e15ef17019f7cecb207ce205b9b5fcce"}, - {file = "wrapt-1.17.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ad47b095f0bdc5585bced35bd088cbfe4177236c7df9984b3cc46b391cc60627"}, - {file = "wrapt-1.17.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:948a9bd0fb2c5120457b07e59c8d7210cbc8703243225dbd78f4dfc13c8d2d1f"}, - {file = "wrapt-1.17.0-cp38-cp38-win32.whl", hash = "sha256:5ae271862b2142f4bc687bdbfcc942e2473a89999a54231aa1c2c676e28f29ea"}, - {file = "wrapt-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:f335579a1b485c834849e9075191c9898e0731af45705c2ebf70e0cd5d58beed"}, - {file = "wrapt-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d751300b94e35b6016d4b1e7d0e7bbc3b5e1751e2405ef908316c2a9024008a1"}, - {file = "wrapt-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7264cbb4a18dc4acfd73b63e4bcfec9c9802614572025bdd44d0721983fc1d9c"}, - {file = "wrapt-1.17.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33539c6f5b96cf0b1105a0ff4cf5db9332e773bb521cc804a90e58dc49b10578"}, - {file = "wrapt-1.17.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c30970bdee1cad6a8da2044febd824ef6dc4cc0b19e39af3085c763fdec7de33"}, - {file = "wrapt-1.17.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bc7f729a72b16ee21795a943f85c6244971724819819a41ddbaeb691b2dd85ad"}, - {file = "wrapt-1.17.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:6ff02a91c4fc9b6a94e1c9c20f62ea06a7e375f42fe57587f004d1078ac86ca9"}, - {file = "wrapt-1.17.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2dfb7cff84e72e7bf975b06b4989477873dcf160b2fd89959c629535df53d4e0"}, - {file = "wrapt-1.17.0-cp39-cp39-win32.whl", hash = "sha256:2399408ac33ffd5b200480ee858baa58d77dd30e0dd0cab6a8a9547135f30a88"}, - {file = "wrapt-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:4f763a29ee6a20c529496a20a7bcb16a73de27f5da6a843249c7047daf135977"}, - {file = "wrapt-1.17.0-py3-none-any.whl", hash = "sha256:d2c63b93548eda58abf5188e505ffed0229bf675f7c3090f8e36ad55b8cbc371"}, - {file = "wrapt-1.17.0.tar.gz", hash = "sha256:16187aa2317c731170a88ef35e8937ae0f533c402872c1ee5e6d079fcf320801"}, + {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984"}, + {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22"}, + {file = "wrapt-1.17.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80dd7db6a7cb57ffbc279c4394246414ec99537ae81ffd702443335a61dbf3a7"}, + {file = "wrapt-1.17.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a6e821770cf99cc586d33833b2ff32faebdbe886bd6322395606cf55153246c"}, + {file = "wrapt-1.17.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b60fb58b90c6d63779cb0c0c54eeb38941bae3ecf7a73c764c52c88c2dcb9d72"}, + {file = "wrapt-1.17.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b870b5df5b71d8c3359d21be8f0d6c485fa0ebdb6477dda51a1ea54a9b558061"}, + {file = "wrapt-1.17.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4011d137b9955791f9084749cba9a367c68d50ab8d11d64c50ba1688c9b457f2"}, + {file = "wrapt-1.17.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1473400e5b2733e58b396a04eb7f35f541e1fb976d0c0724d0223dd607e0f74c"}, + {file = "wrapt-1.17.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3cedbfa9c940fdad3e6e941db7138e26ce8aad38ab5fe9dcfadfed9db7a54e62"}, + {file = "wrapt-1.17.2-cp310-cp310-win32.whl", hash = "sha256:582530701bff1dec6779efa00c516496968edd851fba224fbd86e46cc6b73563"}, + {file = "wrapt-1.17.2-cp310-cp310-win_amd64.whl", hash = "sha256:58705da316756681ad3c9c73fd15499aa4d8c69f9fd38dc8a35e06c12468582f"}, + {file = "wrapt-1.17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ff04ef6eec3eee8a5efef2401495967a916feaa353643defcc03fc74fe213b58"}, + {file = "wrapt-1.17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4db983e7bca53819efdbd64590ee96c9213894272c776966ca6306b73e4affda"}, + {file = "wrapt-1.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9abc77a4ce4c6f2a3168ff34b1da9b0f311a8f1cfd694ec96b0603dff1c79438"}, + {file = "wrapt-1.17.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b929ac182f5ace000d459c59c2c9c33047e20e935f8e39371fa6e3b85d56f4a"}, + {file = "wrapt-1.17.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f09b286faeff3c750a879d336fb6d8713206fc97af3adc14def0cdd349df6000"}, + {file = "wrapt-1.17.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7ed2d9d039bd41e889f6fb9364554052ca21ce823580f6a07c4ec245c1f5d6"}, + {file = "wrapt-1.17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:129a150f5c445165ff941fc02ee27df65940fcb8a22a61828b1853c98763a64b"}, + {file = "wrapt-1.17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1fb5699e4464afe5c7e65fa51d4f99e0b2eadcc176e4aa33600a3df7801d6662"}, + {file = "wrapt-1.17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9a2bce789a5ea90e51a02dfcc39e31b7f1e662bc3317979aa7e5538e3a034f72"}, + {file = "wrapt-1.17.2-cp311-cp311-win32.whl", hash = "sha256:4afd5814270fdf6380616b321fd31435a462019d834f83c8611a0ce7484c7317"}, + {file = "wrapt-1.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:acc130bc0375999da18e3d19e5a86403667ac0c4042a094fefb7eec8ebac7cf3"}, + {file = "wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925"}, + {file = "wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392"}, + {file = "wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40"}, + {file = "wrapt-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d"}, + {file = "wrapt-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b"}, + {file = "wrapt-1.17.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98"}, + {file = "wrapt-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82"}, + {file = "wrapt-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae"}, + {file = "wrapt-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9"}, + {file = "wrapt-1.17.2-cp312-cp312-win32.whl", hash = "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9"}, + {file = "wrapt-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991"}, + {file = "wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125"}, + {file = "wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998"}, + {file = "wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5"}, + {file = "wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8"}, + {file = "wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6"}, + {file = "wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc"}, + {file = "wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2"}, + {file = "wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b"}, + {file = "wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504"}, + {file = "wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a"}, + {file = "wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845"}, + {file = "wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192"}, + {file = "wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b"}, + {file = "wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0"}, + {file = "wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306"}, + {file = "wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb"}, + {file = "wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681"}, + {file = "wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6"}, + {file = "wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6"}, + {file = "wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f"}, + {file = "wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555"}, + {file = "wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c"}, + {file = "wrapt-1.17.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5c803c401ea1c1c18de70a06a6f79fcc9c5acfc79133e9869e730ad7f8ad8ef9"}, + {file = "wrapt-1.17.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f917c1180fdb8623c2b75a99192f4025e412597c50b2ac870f156de8fb101119"}, + {file = "wrapt-1.17.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ecc840861360ba9d176d413a5489b9a0aff6d6303d7e733e2c4623cfa26904a6"}, + {file = "wrapt-1.17.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb87745b2e6dc56361bfde481d5a378dc314b252a98d7dd19a651a3fa58f24a9"}, + {file = "wrapt-1.17.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58455b79ec2661c3600e65c0a716955adc2410f7383755d537584b0de41b1d8a"}, + {file = "wrapt-1.17.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4e42a40a5e164cbfdb7b386c966a588b1047558a990981ace551ed7e12ca9c2"}, + {file = "wrapt-1.17.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:91bd7d1773e64019f9288b7a5101f3ae50d3d8e6b1de7edee9c2ccc1d32f0c0a"}, + {file = "wrapt-1.17.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:bb90fb8bda722a1b9d48ac1e6c38f923ea757b3baf8ebd0c82e09c5c1a0e7a04"}, + {file = "wrapt-1.17.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:08e7ce672e35efa54c5024936e559469436f8b8096253404faeb54d2a878416f"}, + {file = "wrapt-1.17.2-cp38-cp38-win32.whl", hash = "sha256:410a92fefd2e0e10d26210e1dfb4a876ddaf8439ef60d6434f21ef8d87efc5b7"}, + {file = "wrapt-1.17.2-cp38-cp38-win_amd64.whl", hash = "sha256:95c658736ec15602da0ed73f312d410117723914a5c91a14ee4cdd72f1d790b3"}, + {file = "wrapt-1.17.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99039fa9e6306880572915728d7f6c24a86ec57b0a83f6b2491e1d8ab0235b9a"}, + {file = "wrapt-1.17.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2696993ee1eebd20b8e4ee4356483c4cb696066ddc24bd70bcbb80fa56ff9061"}, + {file = "wrapt-1.17.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:612dff5db80beef9e649c6d803a8d50c409082f1fedc9dbcdfde2983b2025b82"}, + {file = "wrapt-1.17.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62c2caa1585c82b3f7a7ab56afef7b3602021d6da34fbc1cf234ff139fed3cd9"}, + {file = "wrapt-1.17.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c958bcfd59bacc2d0249dcfe575e71da54f9dcf4a8bdf89c4cb9a68a1170d73f"}, + {file = "wrapt-1.17.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc78a84e2dfbc27afe4b2bd7c80c8db9bca75cc5b85df52bfe634596a1da846b"}, + {file = "wrapt-1.17.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ba0f0eb61ef00ea10e00eb53a9129501f52385c44853dbd6c4ad3f403603083f"}, + {file = "wrapt-1.17.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1e1fe0e6ab7775fd842bc39e86f6dcfc4507ab0ffe206093e76d61cde37225c8"}, + {file = "wrapt-1.17.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c86563182421896d73858e08e1db93afdd2b947a70064b813d515d66549e15f9"}, + {file = "wrapt-1.17.2-cp39-cp39-win32.whl", hash = "sha256:f393cda562f79828f38a819f4788641ac7c4085f30f1ce1a68672baa686482bb"}, + {file = "wrapt-1.17.2-cp39-cp39-win_amd64.whl", hash = "sha256:36ccae62f64235cf8ddb682073a60519426fdd4725524ae38874adf72b5f2aeb"}, + {file = "wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8"}, + {file = "wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3"}, ] [[package]]