├── .flake8 ├── .github └── workflows │ ├── manifest-modified.yaml │ ├── pub-pypi.yml │ └── tests.yml ├── .gitignore ├── CODEOWNERS ├── LICENSE ├── MANIFEST.in ├── README.md ├── media └── demo.gif ├── netbox-plugin.yaml ├── netbox_floorplan ├── __init__.py ├── admin.py ├── api │ ├── __init__.py │ ├── serializers.py │ ├── urls.py │ └── views.py ├── filtersets.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_floorplan_measurement_unit_alter_floorplan_scale.py │ ├── 0003_floorplan_canvas.py │ ├── 0004_alter_floorplan_options.py │ ├── 0005_alter_floorplan_site.py │ ├── 0006_delete_floorplanobject.py │ ├── 0007_alter_floorplan_options_and_more.py │ ├── 0008_floorplanimage.py │ ├── 0009_floorplanimage_file.py │ ├── 0010_floorplanimage_external_url.py │ ├── 0011_floorplanimage_comments.py │ ├── 0012_alter_floorplan_options_and_more.py │ └── __init__.py ├── models.py ├── navigation.py ├── static │ └── netbox_floorplan │ │ ├── floorplan │ │ ├── edit.js │ │ ├── utils.js │ │ └── view.js │ │ └── vendors │ │ ├── fabric-js-6.0.2.js │ │ ├── htmx.min.js │ │ └── jquery-3.7.1.js ├── tables.py ├── templates │ └── netbox_floorplan │ │ ├── floorplan.html │ │ ├── floorplan_edit.html │ │ ├── floorplan_list.html │ │ ├── floorplan_ui_listview.html │ │ ├── floorplan_view.html │ │ ├── floorplanimage.html │ │ ├── floorplanimage_edit.html │ │ └── inc │ │ └── pro_tips.html ├── templatetags │ ├── __init__.py │ └── template_utils.py ├── urls.py ├── utils.py ├── version.py └── views.py ├── setup.cfg └── setup.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 140 3 | ignore = E126, E722 -------------------------------------------------------------------------------- /.github/workflows/manifest-modified.yaml: -------------------------------------------------------------------------------- 1 | name: NetBox plugin manifest modified 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | paths: 7 | - netbox-plugin.yaml 8 | 9 | concurrency: 10 | group: ${{ github.workflow }} 11 | cancel-in-progress: false 12 | 13 | jobs: 14 | manifest-modified: 15 | uses: netboxlabs/public-workflows/.github/workflows/reusable-plugin-manifest-modified.yml@release 16 | -------------------------------------------------------------------------------- /.github/workflows/pub-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distributions 📦 to PyPI and TestPyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build-n-publish: 9 | name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI 10 | runs-on: ubuntu-latest 11 | environment: release 12 | permissions: 13 | id-token: write 14 | contents: read 15 | steps: 16 | - uses: actions/checkout@main 17 | - name: Set up Python 3.12 18 | uses: actions/setup-python@v1 19 | with: 20 | python-version: 3.12 21 | - name: Install pypa/build 22 | run: >- 23 | python -m 24 | pip install 25 | build 26 | --user 27 | - name: Build a binary wheel and a source tarball 28 | run: >- 29 | python -m 30 | build 31 | --sdist 32 | --wheel 33 | --outdir dist/ 34 | . 35 | - name: Publish distribution 📦 to PyPI 36 | uses: pypa/gh-action-pypi-publish@release/v1 37 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | flake8-lint: 7 | runs-on: ubuntu-latest 8 | name: Lint 9 | steps: 10 | - name: Check out source repository 11 | uses: actions/checkout@v3 12 | - name: Set up Python environment 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: "3.12" 16 | - name: flake8 Lint 17 | uses: py-actions/flake8@v2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # poetry 100 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 101 | # This is especially recommended for binary packages to ensure reproducibility, and is more 102 | # commonly ignored for libraries. 103 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 104 | #poetry.lock 105 | 106 | # pdm 107 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 108 | #pdm.lock 109 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 110 | # in version control. 111 | # https://pdm.fming.dev/#use-with-ide 112 | .pdm.toml 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @tbotnz @cruse1977 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | recursive-include netbox_floorplan/templates * 3 | recursive-include netbox_floorplan/static * -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NetBox Floorplan Plugin 2 | 3 | Tests 4 | 5 | Originally Forked from https://github.com/tbotnz/netbox_floorplan 6 | 7 | ## Demo 8 | ![demo](/media/demo.gif) 9 | 10 | ## Summary 11 | A netbox plugin providing floorplan mapping capability for locations and sites 12 | 13 | - provides graphical ability to draw racks & unracked devices on a floorplan 14 | - support for metadata such as labels, areas, walls, coloring 15 | - floorplan object mapped to sites or locations and click through rack/devices 16 | - keyboard controls supported 17 | - export to svg 18 | 19 | ## Compatibility 20 | 21 | | NetBox Version | Plugin Version | 22 | |-------------|-----------| 23 | | 3.5 | >= 0.3.2 | 24 | | 3.6 | >= 0.3.2 | 25 | | 4.0.x | 0.4.1 | 26 | | 4.1.x | 0.5.0 | 27 | | 4.2.x | 0.6.0 | 28 | | 4.3.x | 0.7.0 | 29 | 30 | ## Installing 31 | 32 | The plugin is available as a Python package in pypi and can be installed with pip 33 | 34 | 35 | ``` 36 | sudo pip install netbox-floorplan-plugin 37 | ``` 38 | Enable the plugin in /opt/netbox/netbox/netbox/configuration.py: 39 | ``` 40 | PLUGINS = ['netbox_floorplan'] 41 | ``` 42 | Enable Migrations: 43 | ``` 44 | cd /opt/netbox 45 | sudo ./venv/bin/python3 netbox/manage.py makemigrations netbox_floorplan_plugin 46 | sudo ./venv/bin/python3 netbox/manage.py migrate 47 | sudo ./venv/bin/python3 netbox/manage.py collectstatic 48 | ``` 49 | 50 | Restart NetBox and add `netbox-floorplan-plugin` to your local_requirements.txt 51 | 52 | See [NetBox Documentation](https://docs.netbox.dev/en/stable/plugins/#installing-plugins) for details 53 | 54 | >[!IMPORTANT] 55 | >In order for racks to display properly, the rack type of the rack should be specified and a width/height set within the type. 56 | 57 | ## Mentions 58 | 59 | Forked from https://github.com/tbotnz/netbox_floorplan 60 | 61 | Special thanks to Ziply Fiber network automation team for helping originally helping to conceive this during the NANOG hackathon 62 | 63 | ## Release process 64 | 65 | Update `netbox_floorplan/version.py` with a new version number, create a new Github release with the same number, the pypi publish workflow will run. 66 | -------------------------------------------------------------------------------- /media/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbox-community/netbox-floorplan-plugin/68cef7fd35350a4fad427670285c62000dffc1e9/media/demo.gif -------------------------------------------------------------------------------- /netbox-plugin.yaml: -------------------------------------------------------------------------------- 1 | version: 0.1 2 | package_name: netbox-floorplan-plugin 3 | compatibility: 4 | - release: 0.7.0 5 | netbox_min: 4.3.0 6 | netbox_max: 4.3.99 7 | - release: 0.6.0 8 | netbox_min: 4.2.0 9 | netbox_max: 4.2.99 10 | - release: 0.5.0 11 | netbox_min: 4.1.0 12 | netbox_max: 4.1.11 13 | - release: 0.4.1 14 | netbox_min: 4.0.2 15 | netbox_max: 4.0.11 16 | - release: 0.4.0 17 | netbox_min: 4.0.2 18 | netbox_max: 4.0.10 19 | - release: 0.3.6 20 | netbox_min: 3.5.2 21 | netbox_max: 3.7.8 22 | - release: 0.3.3 23 | netbox_min: 3.7.2 24 | netbox_max: 3.7.5 25 | - release: 0.3.2 26 | netbox_min: 3.5.1 27 | netbox_max: 3.7.2 28 | -------------------------------------------------------------------------------- /netbox_floorplan/__init__.py: -------------------------------------------------------------------------------- 1 | from netbox.plugins import PluginConfig 2 | from .version import __version__ 3 | 4 | 5 | class FloorplanConfig(PluginConfig): 6 | 7 | name = "netbox_floorplan" 8 | verbose_name = "Netbox Floorplan" 9 | description = "" 10 | version = __version__ 11 | base_url = "floorplan" 12 | min_version = "4.3.0" 13 | max_version = "4.3.99" 14 | 15 | 16 | config = FloorplanConfig 17 | -------------------------------------------------------------------------------- /netbox_floorplan/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Floorplan 3 | 4 | 5 | @admin.register(Floorplan) 6 | class FloorplanAdmin(admin.ModelAdmin): 7 | list_display = ( 8 | "pk", 9 | ) 10 | -------------------------------------------------------------------------------- /netbox_floorplan/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbox-community/netbox-floorplan-plugin/68cef7fd35350a4fad427670285c62000dffc1e9/netbox_floorplan/api/__init__.py -------------------------------------------------------------------------------- /netbox_floorplan/api/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from netbox.api.serializers import NetBoxModelSerializer 3 | from ..models import Floorplan, FloorplanImage 4 | 5 | 6 | class FloorplanImageSerializer(NetBoxModelSerializer): 7 | url = serializers.HyperlinkedIdentityField( 8 | view_name='plugins-api:netbox_floorplan-api:floorplanimage-detail') 9 | 10 | class Meta: 11 | model = FloorplanImage 12 | fields = ['id', 'url', 'name', 'file', 'external_url', 'filename', 'comments', 'tags', 'custom_fields', 'created', 'last_updated'] 13 | brief_fields = ['id', 'url', 'name', 'file', 'filename', 'external_url'] 14 | 15 | 16 | class FloorplanSerializer(NetBoxModelSerializer): 17 | url = serializers.HyperlinkedIdentityField( 18 | view_name='plugins-api:netbox_floorplan-api:floorplan-detail') 19 | assigned_image = FloorplanImageSerializer(nested=True, required=False, allow_null=True) 20 | 21 | class Meta: 22 | model = Floorplan 23 | fields = ['id', 'url', 'site', 'location', 'assigned_image', 24 | 'width', 'height', 'tags', 'custom_fields', 'created', 25 | 'last_updated', 'canvas', 'measurement_unit'] 26 | -------------------------------------------------------------------------------- /netbox_floorplan/api/urls.py: -------------------------------------------------------------------------------- 1 | from netbox.api.routers import NetBoxRouter 2 | from . import views 3 | 4 | app_name = 'netbox_floorplan' 5 | 6 | router = NetBoxRouter() 7 | router.register('floorplans', views.FloorplanViewSet) 8 | router.register('floorplanimages', views.FloorplanImageViewSet) 9 | urlpatterns = router.urls 10 | -------------------------------------------------------------------------------- /netbox_floorplan/api/views.py: -------------------------------------------------------------------------------- 1 | from netbox.api.viewsets import NetBoxModelViewSet 2 | 3 | from .. import filtersets, models 4 | from .serializers import FloorplanSerializer, FloorplanImageSerializer 5 | 6 | 7 | class FloorplanViewSet(NetBoxModelViewSet): 8 | queryset = models.Floorplan.objects.all() 9 | serializer_class = FloorplanSerializer 10 | filterset_class = filtersets.FloorplanFilterSet 11 | 12 | 13 | class FloorplanImageViewSet(NetBoxModelViewSet): 14 | queryset = models.FloorplanImage.objects.prefetch_related('tags') 15 | serializer_class = FloorplanImageSerializer 16 | -------------------------------------------------------------------------------- /netbox_floorplan/filtersets.py: -------------------------------------------------------------------------------- 1 | from netbox.filtersets import NetBoxModelFilterSet 2 | from .models import Floorplan 3 | 4 | 5 | class FloorplanFilterSet(NetBoxModelFilterSet): 6 | class Meta: 7 | model = Floorplan 8 | fields = ['id', 'site', 'location'] 9 | 10 | def search(self, queryset, name, value): 11 | return queryset.filter(description__icontains=value) 12 | -------------------------------------------------------------------------------- /netbox_floorplan/forms.py: -------------------------------------------------------------------------------- 1 | from netbox.forms import NetBoxModelForm 2 | from .models import Floorplan, FloorplanImage 3 | from dcim.models import Rack, Device 4 | from utilities.forms.rendering import FieldSet 5 | from utilities.forms.fields import CommentField 6 | 7 | 8 | class FloorplanImageForm(NetBoxModelForm): 9 | 10 | comments = CommentField() 11 | 12 | fieldsets = ( 13 | FieldSet(('name', 'file', 'external_url', 'comments'), name='General'), 14 | FieldSet(('comments', 'tags'), name='') 15 | ) 16 | 17 | class Meta: 18 | model = FloorplanImage 19 | fields = [ 20 | 'name', 21 | 'file', 22 | 'external_url' 23 | ] 24 | 25 | 26 | class FloorplanForm(NetBoxModelForm): 27 | class Meta: 28 | model = Floorplan 29 | fields = ['site', 'location', 'assigned_image', 'width', 'height'] 30 | 31 | 32 | class FloorplanRackFilterForm(NetBoxModelForm): 33 | class Meta: 34 | model = Rack 35 | fields = ['name'] 36 | 37 | 38 | class FloorplanDeviceFilterForm(NetBoxModelForm): 39 | class Meta: 40 | model = Device 41 | fields = ['name'] 42 | -------------------------------------------------------------------------------- /netbox_floorplan/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.9 on 2023-06-10 22:50 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import netbox_floorplan.utils 6 | import taggit.managers 7 | import utilities.json 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | ('dcim', '0172_larger_power_draw_values'), 16 | ('extras', '0092_delete_jobresult'), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='Floorplan', 22 | fields=[ 23 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), 24 | ('created', models.DateTimeField(auto_now_add=True, null=True)), 25 | ('last_updated', models.DateTimeField(auto_now=True, null=True)), 26 | ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), 27 | ('background_image', models.ImageField(blank=True, null=True, upload_to=netbox_floorplan.utils.file_upload)), 28 | ('scale', models.DecimalField(blank=True, decimal_places=50, max_digits=100, null=True)), 29 | ('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.location')), 30 | ('site', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='dcim.site')), 31 | ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), 32 | ], 33 | options={ 34 | 'ordering': ('site', 'location', 'background_image', 'scale'), 35 | }, 36 | ), 37 | migrations.CreateModel( 38 | name='FloorplanObject', 39 | fields=[ 40 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), 41 | ('created', models.DateTimeField(auto_now_add=True, null=True)), 42 | ('last_updated', models.DateTimeField(auto_now=True, null=True)), 43 | ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), 44 | ('x_coordinate', models.DecimalField(decimal_places=50, max_digits=100)), 45 | ('y_coordinate', models.DecimalField(decimal_places=50, max_digits=100)), 46 | ('rotation', models.IntegerField(default=0)), 47 | ('floorplan', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='netbox_floorplan.floorplan')), 48 | ('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.location')), 49 | ('rack', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.rack')), 50 | ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), 51 | ], 52 | options={ 53 | 'ordering': ('floorplan', 'rack', 'location', 'x_coordinate', 'y_coordinate', 'rotation'), 54 | }, 55 | ), 56 | ] 57 | -------------------------------------------------------------------------------- /netbox_floorplan/migrations/0002_floorplan_measurement_unit_alter_floorplan_scale.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.9 on 2023-06-10 23:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_floorplan', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='floorplan', 15 | name='measurement_unit', 16 | field=models.CharField(default='m', max_length=2), 17 | ), 18 | migrations.AlterField( 19 | model_name='floorplan', 20 | name='scale', 21 | field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /netbox_floorplan/migrations/0003_floorplan_canvas.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.9 on 2023-06-11 16:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_floorplan', '0002_floorplan_measurement_unit_alter_floorplan_scale'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='floorplan', 15 | name='canvas', 16 | field=models.JSONField(default=dict), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /netbox_floorplan/migrations/0004_alter_floorplan_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.9 on 2023-07-08 09:42 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_floorplan', '0003_floorplan_canvas'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='floorplan', 15 | options={'ordering': ('site', 'location', 'background_image', 'scale', 'measurement_unit')}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /netbox_floorplan/migrations/0005_alter_floorplan_site.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.9 on 2023-07-18 10:58 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('dcim', '0172_larger_power_draw_values'), 11 | ('netbox_floorplan', '0004_alter_floorplan_options'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='floorplan', 17 | name='site', 18 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.site'), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /netbox_floorplan/migrations/0006_delete_floorplanobject.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.9 on 2023-07-18 11:44 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_floorplan', '0005_alter_floorplan_site'), 10 | ] 11 | 12 | operations = [ 13 | migrations.DeleteModel( 14 | name='FloorplanObject', 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /netbox_floorplan/migrations/0007_alter_floorplan_options_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.9 on 2023-07-18 11:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_floorplan', '0006_delete_floorplanobject'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='floorplan', 15 | options={'ordering': ('site', 'location', 'background_image', 'width', 'height', 'measurement_unit')}, 16 | ), 17 | migrations.RenameField( 18 | model_name='floorplan', 19 | old_name='scale', 20 | new_name='height', 21 | ), 22 | migrations.AddField( 23 | model_name='floorplan', 24 | name='width', 25 | field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /netbox_floorplan/migrations/0008_floorplanimage.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.7 on 2024-07-19 23:49 2 | 3 | import taggit.managers 4 | import utilities.json 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('extras', '0115_convert_dashboard_widgets'), 12 | ('netbox_floorplan', '0007_alter_floorplan_options_and_more'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='FloorplanImage', 18 | fields=[ 19 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), 20 | ('created', models.DateTimeField(auto_now_add=True, null=True)), 21 | ('last_updated', models.DateTimeField(auto_now=True, null=True)), 22 | ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), 23 | ('name', models.CharField(max_length=128)), 24 | ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), 25 | ], 26 | options={ 27 | 'ordering': ('name',), 28 | }, 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /netbox_floorplan/migrations/0009_floorplanimage_file.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.7 on 2024-07-20 19:02 2 | 3 | import netbox_floorplan.utils 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('netbox_floorplan', '0008_floorplanimage'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='floorplanimage', 16 | name='file', 17 | field=models.FileField(blank=True, upload_to=netbox_floorplan.utils.file_upload), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /netbox_floorplan/migrations/0010_floorplanimage_external_url.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.7 on 2024-07-20 19:57 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_floorplan', '0009_floorplanimage_file'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='floorplanimage', 15 | name='external_url', 16 | field=models.URLField(blank=True, max_length=255), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /netbox_floorplan/migrations/0011_floorplanimage_comments.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.7 on 2024-07-20 20:34 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_floorplan', '0010_floorplanimage_external_url'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='floorplanimage', 15 | name='comments', 16 | field=models.TextField(blank=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /netbox_floorplan/migrations/0012_alter_floorplan_options_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.7 on 2024-07-20 20:53 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('netbox_floorplan', '0011_floorplanimage_comments'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterModelOptions( 15 | name='floorplan', 16 | options={'ordering': ('site', 'location', 'assigned_image', 'width', 'height', 'measurement_unit')}, 17 | ), 18 | migrations.RemoveField( 19 | model_name='floorplan', 20 | name='background_image', 21 | ), 22 | migrations.AddField( 23 | model_name='floorplan', 24 | name='assigned_image', 25 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='netbox_floorplan.floorplanimage'), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /netbox_floorplan/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbox-community/netbox-floorplan-plugin/68cef7fd35350a4fad427670285c62000dffc1e9/netbox_floorplan/migrations/__init__.py -------------------------------------------------------------------------------- /netbox_floorplan/models.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.db import models 3 | from django.urls import reverse 4 | from netbox.models import NetBoxModel 5 | from dcim.models import Rack, Device 6 | from .utils import file_upload 7 | 8 | 9 | class FloorplanImage(NetBoxModel): 10 | """ 11 | A Floorplan Image is effectively a background image 12 | """ 13 | name = models.CharField( 14 | help_text='Can be used to quickly identify a particular image', 15 | max_length=128, 16 | blank=False, 17 | null=False 18 | ) 19 | 20 | file = models.FileField( 21 | upload_to=file_upload, 22 | blank=True 23 | ) 24 | 25 | external_url = models.URLField( 26 | blank=True, 27 | max_length=255 28 | ) 29 | 30 | comments = models.TextField( 31 | blank=True 32 | ) 33 | 34 | def get_absolute_url(self): 35 | return reverse('plugins:netbox_floorplan:floorplanimage', args=[self.pk]) 36 | 37 | def __str__(self): 38 | return f'{self.name}' 39 | 40 | class Meta: 41 | ordering = ('name',) 42 | 43 | @property 44 | def size(self): 45 | """ 46 | Wrapper around `document.size` to suppress an OSError in case the file is inaccessible. Also opportunistically 47 | catch other exceptions that we know other storage back-ends to throw. 48 | """ 49 | expected_exceptions = [OSError] 50 | 51 | try: 52 | from botocore.exceptions import ClientError 53 | expected_exceptions.append(ClientError) 54 | except ImportError: 55 | pass 56 | 57 | try: 58 | return self.file.size 59 | except NameError: 60 | return None 61 | 62 | @property 63 | def filename(self): 64 | filename = self.file.name.rsplit('/', 1)[-1] 65 | return filename 66 | 67 | def clean(self): 68 | super().clean() 69 | 70 | # Must have an uploaded document or an external URL. cannot have both 71 | if not self.file and self.external_url == '': 72 | raise ValidationError("A document must contain an uploaded file or an external URL.") 73 | if self.file and self.external_url: 74 | raise ValidationError("A document cannot contain both an uploaded file and an external URL.") 75 | 76 | def delete(self, *args, **kwargs): 77 | 78 | # Check if its a document or a URL 79 | if self.external_url == '': 80 | 81 | _name = self.file.name 82 | 83 | # Delete file from disk 84 | super().delete(*args, **kwargs) 85 | self.file.delete(save=False) 86 | 87 | # Restore the name of the document as it's re-used in the notifications later 88 | self.file.name = _name 89 | else: 90 | # Straight delete of external URL 91 | super().delete(*args, **kwargs) 92 | 93 | 94 | class Floorplan(NetBoxModel): 95 | 96 | site = models.ForeignKey( 97 | to='dcim.Site', 98 | blank=True, 99 | null=True, 100 | on_delete=models.PROTECT 101 | ) 102 | location = models.ForeignKey( 103 | to='dcim.Location', 104 | blank=True, 105 | null=True, 106 | on_delete=models.PROTECT 107 | ) 108 | 109 | assigned_image = models.ForeignKey( 110 | to='FloorplanImage', 111 | blank=True, 112 | null=True, 113 | on_delete=models.SET_NULL 114 | ) 115 | 116 | width = models.DecimalField( 117 | max_digits=10, 118 | decimal_places=2, 119 | blank=True, 120 | null=True 121 | ) 122 | 123 | height = models.DecimalField( 124 | max_digits=10, 125 | decimal_places=2, 126 | blank=True, 127 | null=True 128 | ) 129 | measurement_choices = [ 130 | ('ft', 'Feet'), 131 | ('m', 'Meters') 132 | ] 133 | measurement_unit = models.CharField( 134 | max_length=2, 135 | choices=measurement_choices, 136 | default='m' 137 | ) 138 | 139 | canvas = models.JSONField(default=dict) 140 | 141 | class Meta: 142 | ordering = ('site', 'location', 'assigned_image', 143 | 'width', 'height', 'measurement_unit') 144 | 145 | def __str__(self): 146 | if self.site: 147 | return f'{self.site.name} Floorplan' 148 | else: 149 | return f'{self.location.name} Floorplan' 150 | 151 | def get_absolute_url(self): 152 | return reverse('plugins:netbox_floorplan:floorplan_edit', args=[self.pk]) 153 | 154 | @property 155 | def record_type(self): 156 | if self.site: 157 | return "site" 158 | else: 159 | return "location" 160 | 161 | @property 162 | def mapped_racks(self): 163 | drawn_racks = [] 164 | if self.canvas: 165 | if self.canvas.get("objects"): 166 | for obj in self.canvas["objects"]: 167 | if obj.get("objects"): 168 | for subobj in obj["objects"]: 169 | if subobj.get("custom_meta"): 170 | if subobj["custom_meta"].get("object_type") == "rack": 171 | drawn_racks.append( 172 | int(subobj["custom_meta"]["object_id"])) 173 | return drawn_racks 174 | 175 | @property 176 | def mapped_devices(self): 177 | drawn_devices = [] 178 | if self.canvas: 179 | if self.canvas.get("objects"): 180 | for obj in self.canvas["objects"]: 181 | if obj.get("objects"): 182 | for subobj in obj["objects"]: 183 | if subobj.get("custom_meta"): 184 | if subobj["custom_meta"].get("object_type") == "device": 185 | drawn_devices.append( 186 | int(subobj["custom_meta"]["object_id"])) 187 | return drawn_devices 188 | 189 | def resync_canvas(self): 190 | changed = False 191 | if self.canvas: 192 | if self.canvas.get("objects"): 193 | for index, obj in enumerate(self.canvas["objects"]): 194 | if obj.get("custom_meta"): 195 | if obj["custom_meta"].get("object_type") == "rack": 196 | rack_id = int(obj["custom_meta"]["object_id"]) 197 | # if rack is not in the database, remove it from the canvas 198 | rack_qs = Rack.objects.filter(pk=rack_id) 199 | if not rack_qs.exists(): 200 | self.canvas["objects"].remove(obj) 201 | changed = True 202 | else: 203 | rack = rack_qs.first() 204 | self.canvas["objects"][index]["custom_meta"]["object_name"] = rack.name 205 | if obj.get("objects"): 206 | for subcounter, subobj in enumerate(obj["objects"]): 207 | if subobj.get("type") == "i-text": 208 | if subobj.get("custom_meta", {}).get("text_type") == "name": 209 | if subobj["text"] != f"{rack.name}": 210 | self.canvas["objects"][index]["objects"][ 211 | subcounter]["text"] = f"{rack.name}" 212 | changed = True 213 | if subobj.get("custom_meta", {}).get("text_type") == "status": 214 | if subobj["text"] != f"{rack.status}": 215 | self.canvas["objects"][index]["objects"][ 216 | subcounter]["text"] = f"{rack.status}" 217 | changed = True 218 | if obj["custom_meta"].get("object_type") == "device": 219 | device_id = int(obj["custom_meta"]["object_id"]) 220 | # if device is not in the database, remove it from the canvas 221 | device_qs = Device.objects.filter(pk=device_id) 222 | if not device_qs.exists(): 223 | self.canvas["objects"].remove(obj) 224 | changed = True 225 | else: 226 | device = device_qs.first() 227 | self.canvas["objects"][index]["custom_meta"]["object_name"] = device.name 228 | if obj.get("objects"): 229 | for subcounter, subobj in enumerate(obj["objects"]): 230 | if subobj.get("type") == "i-text": 231 | if subobj.get("custom_meta", {}).get("text_type") == "name": 232 | if subobj["text"] != f"{device.name}": 233 | self.canvas["objects"][index]["objects"][ 234 | subcounter]["text"] = f"{device.name}" 235 | changed = True 236 | if subobj.get("custom_meta", {}).get("text_type") == "status": 237 | if subobj["text"] != f"{device.status}": 238 | self.canvas["objects"][index]["objects"][ 239 | subcounter]["text"] = f"{device.status}" 240 | changed = True 241 | if changed: 242 | self.save() 243 | 244 | def save(self, *args, **kwargs): 245 | if self.site and self.location: 246 | raise ValueError( 247 | "Only one of site or location can be set for a floorplan") 248 | # ensure that the site or location is set 249 | if not self.site and not self.location: 250 | raise ValueError( 251 | "Either site or location must be set for a floorplan") 252 | super().save(*args, **kwargs) 253 | -------------------------------------------------------------------------------- /netbox_floorplan/navigation.py: -------------------------------------------------------------------------------- 1 | """ 2 | Define the plugin menu buttons & the plugin navigation bar enteries. 3 | """ 4 | 5 | from netbox.plugins import PluginMenuItem, PluginMenuButton 6 | 7 | 8 | # 9 | # Define plugin menu buttons 10 | # 11 | menu_buttons = ( 12 | PluginMenuItem( 13 | link="plugins:netbox_floorplan:floorplanimage_list", 14 | link_text="Floorplan Images", 15 | buttons=( 16 | PluginMenuButton( 17 | link='plugins:netbox_floorplan:floorplanimage_add', 18 | title='Add', 19 | icon_class='mdi mdi-plus-thick', 20 | ), 21 | ), 22 | ), 23 | 24 | ) 25 | 26 | 27 | menu_items = menu_buttons 28 | -------------------------------------------------------------------------------- /netbox_floorplan/static/netbox_floorplan/floorplan/edit.js: -------------------------------------------------------------------------------- 1 | // start initial ----------------------------------------------------------------------------- ! 2 | 3 | 4 | import { 5 | resize_canvas, 6 | export_svg, 7 | enable_button_selection, 8 | disable_button_selection, 9 | prevent_leaving_canvas, 10 | wheel_zoom, 11 | stop_pan, 12 | start_pan, 13 | move_pan, 14 | reset_zoom, 15 | init_floor_plan 16 | } from "/static/netbox_floorplan/floorplan/utils.js"; 17 | 18 | 19 | var csrf = document.getElementById('csrf').value; 20 | var obj_pk = document.getElementById('obj_pk').value; 21 | var obj_name = document.getElementById('obj_name').value; 22 | var record_type = document.getElementById('record_type').value; 23 | var site_id = document.getElementById('site_id').value; 24 | var location_id = document.getElementById('location_id').value; 25 | 26 | htmx.ajax('GET', `/plugins/floorplan/floorplans/racks/?floorplan_id=${obj_pk}`, { source: '#rack-card', target: '#rack-card', swap: 'innerHTML', trigger: 'load' }) 27 | htmx.ajax('GET', `/plugins/floorplan/floorplans/devices/?floorplan_id=${obj_pk}`, { source: '#unrack-card', target: '#unrack-card', swap: 'innerHTML', trigger: 'load' }) 28 | 29 | 30 | fabric.Object.prototype.set({ 31 | snapThreshold: 45, 32 | snapAngle: 45 33 | }); 34 | 35 | var current_zoom = 1; 36 | 37 | var canvas = new fabric.Canvas('canvas'), 38 | canvasWidth = document.getElementById('canvas').width, 39 | canvasHeight = document.getElementById('canvas').height; 40 | 41 | // end initial ----------------------------------------------------------------------------- ! 42 | 43 | 44 | // start motion events ----------------------------------------------------------------------------- ! 45 | 46 | canvas.on({ 47 | "selection:updated": enable_button_selection, 48 | "selection:created": enable_button_selection, 49 | "selection:cleared": disable_button_selection, 50 | }); 51 | 52 | canvas.on('object:moving', function (opt) { 53 | prevent_leaving_canvas(opt, canvas); 54 | }); 55 | 56 | // end motion events ----------------------------------------------------------------------------- ! 57 | 58 | // start grid ----------------------------------------------------------------------------- ! 59 | var grid = 8; 60 | 61 | canvas.on('object:moving', function (options) { 62 | options.target.set({ 63 | left: Math.round(options.target.left / grid) * grid, 64 | top: Math.round(options.target.top / grid) * grid 65 | }); 66 | }); 67 | 68 | // end grid ----------------------------------------------------------------------------- ! 69 | 70 | // start zoom, pan control & resizing ----------------------------------------------------------------------------- ! 71 | 72 | $(window).resize(resize_canvas(canvas, window)); 73 | 74 | canvas.on('mouse:wheel', function (opt) { 75 | wheel_zoom(opt, canvas); 76 | }); 77 | 78 | canvas.on('mouse:down', function (opt) { 79 | start_pan(opt, canvas); 80 | }); 81 | 82 | canvas.on('mouse:move', function (opt) { 83 | move_pan(opt, canvas); 84 | }); 85 | canvas.on('mouse:up', function (opt) { 86 | stop_pan(canvas); 87 | }); 88 | 89 | // start zoom, pan control & resizing ----------------------------------------------------------------------------- ! 90 | 91 | // start buttons ----------------------------------------------------------------------------- ! 92 | 93 | document.getElementById('reset_zoom').addEventListener('click', () => { 94 | reset_zoom(canvas); 95 | }); 96 | 97 | document.getElementById('export_svg').addEventListener('click', () => { 98 | export_svg(canvas); 99 | }); 100 | 101 | function add_wall() { 102 | var wall = new fabric.Rect({ 103 | top: 0, 104 | left: 0, 105 | width: 10, 106 | height: 500, 107 | //fill: '#6ea8fe', 108 | fill: 'red', 109 | opacity: 0.8, 110 | lockRotation: false, 111 | originX: "center", 112 | originY: "center", 113 | cornerSize: 15, 114 | hasRotatingPoint: true, 115 | perPixelTargetFind: true, 116 | minScaleLimit: 1, 117 | maxWidth: canvasWidth, 118 | maxHeight: canvasHeight, 119 | centeredRotation: true, 120 | angle: 90, 121 | custom_meta: { 122 | "object_type": "wall", 123 | }, 124 | }); 125 | var group = new fabric.Group([wall]); 126 | 127 | group.setControlsVisibility({ 128 | mt: true, 129 | mb: true, 130 | ml: true, 131 | mr: true, 132 | bl: false, 133 | br: false, 134 | tl: false, 135 | tr: false, 136 | }) 137 | 138 | canvas.add(group); 139 | canvas.centerObject(group); 140 | } 141 | window.add_wall = add_wall; 142 | 143 | function add_area() { 144 | var wall = new fabric.Rect({ 145 | top: 0, 146 | left: 0, 147 | width: 300, 148 | height: 300, 149 | fill: '#6ea8fe', 150 | opacity: 0.5, 151 | lockRotation: false, 152 | originX: "center", 153 | originY: "center", 154 | cornerSize: 15, 155 | hasRotatingPoint: true, 156 | perPixelTargetFind: true, 157 | minScaleLimit: 1, 158 | maxWidth: canvasWidth, 159 | maxHeight: canvasHeight, 160 | centeredRotation: true, 161 | angle: 90, 162 | custom_meta: { 163 | "object_type": "area", 164 | }, 165 | }); 166 | var group = new fabric.Group([wall]); 167 | 168 | group.setControlsVisibility({ 169 | mt: true, 170 | mb: true, 171 | ml: true, 172 | mr: true, 173 | bl: false, 174 | br: false, 175 | tl: false, 176 | tr: false, 177 | }) 178 | canvas.add(group); 179 | canvas.centerObject(group); 180 | canvas.requestRenderAll(); 181 | } 182 | window.add_area = add_area; 183 | 184 | /* 185 | * lock_floorplan_object: Toggle function to enable/disable movement and resize of objects 186 | * Uses object.custom_meta.object_type to determine which controls to enable/disable 187 | * for walls/area, mtr, mt, mb, ml, mr and movement/rotation are all enabled/disabled. 188 | * for racks, only mtr and movement/roatation are enabled/disabled. 189 | */ 190 | function lock_floorplan_object() { 191 | var object = canvas.getActiveObject(); 192 | if (object) { 193 | if (object.lockMovementX) { 194 | object.set({ 195 | 'lockMovementX': false, 196 | 'lockMovementY': false, 197 | 'lockRotation': false 198 | }); 199 | object.setControlsVisibility({ 200 | mtr: true, 201 | }); 202 | if ( object._objects[0].custom_meta.object_type === "wall" || 203 | object._objects[0].custom_meta.object_type === "area" ) { 204 | object.setControlsVisibility({ 205 | mt: true, 206 | mb: true, 207 | ml: true, 208 | mr: true, 209 | }); 210 | }; 211 | } else { 212 | object.set({ 213 | 'lockMovementX': true, 214 | 'lockMovementY': true, 215 | 'lockRotation': true 216 | }); 217 | object.setControlsVisibility({ 218 | mtr: false, 219 | }); 220 | if ( object._objects[0].custom_meta.object_type === "wall" || 221 | object._objects[0].custom_meta.object_type === "area" ) { 222 | object.setControlsVisibility({ 223 | mt: false, 224 | mb: false, 225 | ml: false, 226 | mr: false, 227 | }); 228 | }; 229 | }; 230 | }; 231 | canvas.renderAll(); 232 | return; 233 | } 234 | window.lock_floorplan_object = lock_floorplan_object; 235 | 236 | function bring_forward() { 237 | var object = canvas.getActiveObject(); 238 | if (object) { 239 | object.bringForward(); 240 | canvas.renderAll(); 241 | } 242 | } 243 | window.bring_forward = bring_forward; 244 | 245 | function send_back() { 246 | var object = canvas.getActiveObject(); 247 | if (object) { 248 | object.sendBackwards(); 249 | canvas.renderAll(); 250 | } 251 | } 252 | window.send_back = send_back; 253 | 254 | function set_dimensions() { 255 | $('#control_unit_modal').modal('show'); 256 | } 257 | function set_background() { 258 | $('#background_unit_modal').modal('show'); 259 | } 260 | 261 | window.set_background = set_background; 262 | window.set_dimensions = set_dimensions; 263 | 264 | function add_text() { 265 | var object = new fabric.IText("Label", { 266 | fontFamily: "Courier New", 267 | left: 150, 268 | top: 100, 269 | fontSize: 12, 270 | textAlign: "left", 271 | fill: "#fff" 272 | }); 273 | canvas.add(object); 274 | canvas.centerObject(object); 275 | } 276 | window.add_text = add_text; 277 | 278 | function add_floorplan_object(top, left, width, height, unit, fill, rotation, object_id, object_name, object_type, status, image) { 279 | var object_width; 280 | var object_height; 281 | if ( !width || !height || !unit ){ 282 | object_width = 60; 283 | object_height = 91; 284 | } else { 285 | var conversion_scale = 100; 286 | console.log("width: " + width) 287 | console.log("unit: " + unit) 288 | console.log("height: " + height) 289 | if (unit == "in") { 290 | var new_width = (width * 0.0254) * conversion_scale; 291 | var new_height = (height * 0.0254) * conversion_scale; 292 | } else { 293 | var new_width = (width / 1000) * conversion_scale; 294 | var new_height = (height / 1000) * conversion_scale; 295 | } 296 | 297 | object_width = parseFloat(new_width.toFixed(2)); 298 | console.log(object_width) 299 | object_height = parseFloat(new_height.toFixed(2)); 300 | console.log(object_height) 301 | } 302 | document.getElementById(`object_${object_type}_${object_id}`).remove(); 303 | /* if we have an image, we display the text below, otherwise we display the text within */ 304 | var rect, text_offset = 0; 305 | if (!image) { 306 | rect = new fabric.Rect({ 307 | top: top, 308 | name: "rectangle", 309 | left: left, 310 | width: object_width, 311 | height: object_height, 312 | fill: fill, 313 | opacity: 0.8, 314 | lockRotation: false, 315 | originX: "center", 316 | originY: "center", 317 | cornerSize: 15, 318 | hasRotatingPoint: true, 319 | perPixelTargetFind: true, 320 | minScaleLimit: 1, 321 | maxWidth: canvasWidth, 322 | maxHeight: canvasHeight, 323 | centeredRotation: true, 324 | custom_meta: { 325 | "object_type": object_type, 326 | "object_id": object_id, 327 | "object_name": object_name, 328 | "object_url": "/dcim/" + object_type + "s/" + object_id + "/", 329 | }, 330 | }); 331 | } else { 332 | object_height = object_width; 333 | text_offset = object_height/2 + 4; 334 | rect = new fabric.Image(null, { 335 | top: top, 336 | name: "rectangle", 337 | left: left, 338 | width: object_width, 339 | height: object_height, 340 | opacity: 1, 341 | lockRotation: false, 342 | originX: "center", 343 | originY: "center", 344 | cornerSize: 15, 345 | hasRotatingPoint: true, 346 | perPixelTargetFind: true, 347 | minScaleLimit: 1, 348 | maxWidth: canvasWidth, 349 | maxHeight: canvasHeight, 350 | centeredRotation: true, 351 | shadow: new fabric.Shadow({ 352 | color: "red", 353 | blur: 15, 354 | }), 355 | custom_meta: { 356 | "object_type": object_type, 357 | "object_id": object_id, 358 | "object_name": object_name, 359 | "object_url": "/dcim/" + object_type + "s/" + object_id + "/", 360 | }, 361 | }); 362 | rect.setSrc("/media/" + image, function(img){ 363 | img.scaleX = object_width / img.width; 364 | img.scaleY = object_height / img.height; 365 | canvas.renderAll(); 366 | }); 367 | } 368 | 369 | var text = new fabric.Textbox(object_name, { 370 | fontFamily: "Courier New", 371 | fontSize: 16, 372 | splitByGrapheme: text_offset? null : true, 373 | fill: "#FFFF", 374 | width: object_width, 375 | textAlign: "center", 376 | originX: "center", 377 | originY: "center", 378 | left: left, 379 | top: top + text_offset, 380 | excludeFromExport: false, 381 | includeDefaultValues: true, 382 | centeredRotation: true, 383 | stroke: "#000", 384 | strokeWidth: 2, 385 | paintFirst: 'stroke', 386 | custom_meta: { 387 | "text_type": "name", 388 | } 389 | }); 390 | 391 | var button = new fabric.IText(status, { 392 | fontFamily: "Courier New", 393 | fontSize: 13, 394 | fill: "#6ea8fe", 395 | borderColor: "6ea8fe", 396 | textAlign: "center", 397 | originX: "center", 398 | originY: "center", 399 | left: left, 400 | top: top + text_offset + 16, 401 | excludeFromExport: false, 402 | includeDefaultValues: true, 403 | centeredRotation: true, 404 | shadow: text_offset? new fabric.Shadow({ 405 | color: '#FFF', 406 | blur: 1 407 | }) : null, 408 | custom_meta: { 409 | "text_type": "status", 410 | } 411 | }); 412 | 413 | var group = new fabric.Group([rect, text, button]); 414 | group.custom_meta = { 415 | "object_type": object_type, 416 | "object_id": object_id, 417 | "object_name": object_name, 418 | "object_url": "/dcim/" + object_type + "s/" + object_id + "/", 419 | } 420 | group.setControlsVisibility({ 421 | mt: false, 422 | mb: false, 423 | ml: false, 424 | mr: false, 425 | bl: false, 426 | br: false, 427 | tl: false, 428 | tr: false, 429 | }) 430 | 431 | if (object_id) { 432 | group.set('id', object_id); 433 | } 434 | 435 | canvas.add(group); 436 | canvas.centerObject(group); 437 | //canvas.bringToFront(group); 438 | } 439 | window.add_floorplan_object = add_floorplan_object; 440 | 441 | function delete_floorplan_object() { 442 | var object = canvas.getActiveObject(); 443 | if (object) { 444 | canvas.remove(object); 445 | canvas.renderAll(); 446 | } 447 | save_floorplan(); 448 | setTimeout(() => { 449 | htmx.ajax('GET', `/plugins/floorplan/floorplans/racks/?floorplan_id=${obj_pk}`, { target: '#rack-card', swap: 'innerHTML' }); 450 | htmx.ajax('GET', `/plugins/floorplan/floorplans/devices/?floorplan_id=${obj_pk}`, { target: '#unrack-card', swap: 'innerHTML' }); 451 | }, 1500); 452 | }; 453 | window.delete_floorplan_object = delete_floorplan_object; 454 | 455 | function set_color(color) { 456 | var object = canvas.getActiveObject(); 457 | if (object) { 458 | if (object.type == "i-text") { 459 | object.set('fill', color); 460 | canvas.renderAll(); 461 | return; 462 | } 463 | object._objects[0].set('fill', color); 464 | canvas.renderAll(); 465 | return; 466 | } 467 | } 468 | window.set_color = set_color; 469 | 470 | function set_zoom(new_current_zoom) { 471 | current_zoom = new_current_zoom; 472 | canvas.setZoom(current_zoom); 473 | canvas.requestRenderAll() 474 | document.getElementById("zoom").value = current_zoom; 475 | } 476 | window.set_zoom = set_zoom; 477 | 478 | function center_pan_on_slected_object() { 479 | let pan_x = 0 480 | let pan_y = 0 481 | let object = canvas.getActiveObject() 482 | let obj_wdth = object.getScaledWidth() 483 | let obj_hgt = object.getScaledHeight() 484 | let rect_cooords = object.getBoundingRect(); 485 | let zoom_level = Math.min(canvas.width / rect_cooords.width, canvas.height / rect_cooords.height); 486 | 487 | canvas.setZoom(zoom_level * 0.7); 488 | let zoom = canvas.getZoom() 489 | pan_x = ((canvas.getWidth() / zoom / 2) - (object.aCoords.tl.x) - (obj_wdth / 2)) * zoom 490 | pan_y = ((canvas.getHeight() / zoom / 2) - (object.aCoords.tl.y) - (obj_hgt / 2)) * zoom 491 | pan_x = (canvas.getVpCenter().x - object.getCenterPoint().x) * zoom 492 | pan_y = ((canvas.getVpCenter().y - object.getCenterPoint().y) * zoom) 493 | canvas.relativePan({ x: pan_x, y: pan_y }) 494 | canvas.requestRenderAll() 495 | 496 | } 497 | window.center_pan_on_slected_object = center_pan_on_slected_object; 498 | 499 | // end buttons ----------------------------------------------------------------------------- ! 500 | 501 | // start set scale ----------------------------------------------------------------------------- ! 502 | 503 | function update_background() { 504 | var assigned_image = document.getElementById("id_assigned_image").value; 505 | if (assigned_image == "") { 506 | assigned_image = null; 507 | canvas.setBackgroundImage(null, canvas.renderAll.bind(canvas)); 508 | } 509 | var floor_json = canvas.toJSON(["id", "text", "_controlsVisibility", "custom_meta", "lockMovementY", "lockMovementX", "evented", "selectable"]); 510 | 511 | 512 | 513 | 514 | 515 | $.ajax({ 516 | type: "PATCH", 517 | url: `/api/plugins/floorplan/floorplans/${obj_pk}/`, 518 | dataType: "json", 519 | headers: { 520 | "X-CSRFToken": csrf, 521 | "Content-Type": "application/json" 522 | }, 523 | data: JSON.stringify({ 524 | "assigned_image": assigned_image, 525 | "canvas": floor_json 526 | }), 527 | error: function (err) { 528 | console.log(`Error: ${err}`); 529 | } 530 | }).done(function (floorplan) { 531 | if (floorplan.assigned_image != null) { 532 | var img_url = ""; 533 | if (floorplan.assigned_image.external_url != "") { 534 | img_url = floorplan.assigned_image.external_url; 535 | } else { 536 | img_url = floorplan.assigned_image.file; 537 | } 538 | 539 | var img = fabric.Image.fromURL(img_url, function(img) { 540 | 541 | 542 | var left = 0; 543 | var top = 0; 544 | var width = 0; 545 | var height = 0; 546 | canvas.getObjects().forEach(function (object) { 547 | if (object.custom_meta) { 548 | if (object.custom_meta.object_type == "floorplan_boundry") { 549 | left = object.left; 550 | top = object.top; 551 | width = object.width; 552 | height = object.height; 553 | } 554 | } 555 | }); 556 | // if we have a floorplan boundary, position the image in there 557 | if (height != 0 && width != 0) { 558 | let scaleRatioX = Math.max(width / img.width) 559 | let scaleRatioY = Math.max(height / img.height); 560 | canvas.setBackgroundImage(img, canvas.renderAll.bind(canvas), { 561 | scaleX: scaleRatioX, 562 | scaleY: scaleRatioY, 563 | left: left, 564 | top: top 565 | }); 566 | } 567 | else 568 | { 569 | let scaleRatio = Math.max(canvas.width / img.width, canvas.height / img.height); 570 | canvas.setBackgroundImage(img, canvas.renderAll.bind(canvas), { 571 | scaleX: scaleRatio, 572 | scaleY: scaleRatio, 573 | left: canvas.width / 2, 574 | top: canvas.height / 2, 575 | originX: 'middle', 576 | originY: 'middle' 577 | }); 578 | } 579 | }); 580 | 581 | } else { 582 | canvas.setBackgroundImage().renderAll(); 583 | } 584 | canvas.renderAll(); 585 | $('#background_unit_modal').modal('hide'); 586 | }); 587 | } 588 | 589 | window.update_background = update_background; 590 | 591 | function update_dimensions() { 592 | 593 | var width = document.getElementById("width_value").value; 594 | var height = document.getElementById("height_value").value; 595 | 596 | var measurement_unit = document.getElementById("measurement_unit").value; 597 | 598 | var conversion_scale = 100; 599 | if (measurement_unit == "ft") { 600 | var new_width = (width / 3.28) * conversion_scale; 601 | var new_height = (height / 3.28) * conversion_scale; 602 | } else { 603 | var new_width = width * conversion_scale; 604 | var new_height = height * conversion_scale; 605 | } 606 | 607 | var rounded_width = parseFloat(new_width.toFixed(2)); 608 | var rounded_height = parseFloat(new_height.toFixed(2)); 609 | 610 | var floor_json = canvas.toJSON(["id", "text", "_controlsVisibility", "custom_meta", "lockMovementY", "lockMovementX", "evented", "selectable"]); 611 | $.ajax({ 612 | type: "PATCH", 613 | url: `/api/plugins/floorplan/floorplans/${obj_pk}/`, 614 | dataType: "json", 615 | headers: { 616 | "X-CSRFToken": csrf, 617 | "Content-Type": "application/json" 618 | }, 619 | data: JSON.stringify({ 620 | "width": rounded_width, 621 | "height": rounded_height, 622 | "measurement_unit": measurement_unit, 623 | "canvas": floor_json, 624 | }), 625 | error: function (err) { 626 | console.log(`Error: ${err}`); 627 | } 628 | }).done(function () { 629 | 630 | // set the boundry variables for zoom controls 631 | var center_x = rounded_width / 2; 632 | var center_y = rounded_height / 2; 633 | 634 | var rect_left = center_x; 635 | var rect_top = center_y; 636 | var rect_bottom = rounded_height; 637 | 638 | var rect = new fabric.Rect({ 639 | top: rect_top, 640 | name: "rectangle", 641 | left: rect_left, 642 | width: rounded_width, 643 | height: rounded_height, 644 | fill: null, 645 | opacity: 1, 646 | stroke: "#6ea8fe", 647 | strokeWidth: 2, 648 | lockRotation: false, 649 | originX: "center", 650 | originY: "center", 651 | cornerSize: 15, 652 | hasRotatingPoint: true, 653 | perPixelTargetFind: true, 654 | minScaleLimit: 1, 655 | maxWidth: canvasWidth, 656 | maxHeight: canvasHeight, 657 | centeredRotation: true, 658 | }); 659 | 660 | 661 | var text = new fabric.IText(`${obj_name}`, { 662 | fontFamily: "Courier New", 663 | fontSize: 16, 664 | fill: "#FFFF", 665 | textAlign: "center", 666 | originX: "center", 667 | originY: "center", 668 | left: rect_left, 669 | top: rect_bottom - 40, 670 | excludeFromExport: false, 671 | includeDefaultValues: true, 672 | centeredRotation: true, 673 | }); 674 | 675 | var dimensions = new fabric.IText(`${width} ${measurement_unit} (width) x ${height} ${measurement_unit} (height)`, { 676 | fontFamily: "Courier New", 677 | fontSize: 8, 678 | fill: "#FFFF", 679 | textAlign: "center", 680 | originX: "center", 681 | originY: "center", 682 | left: rect_left, 683 | top: rect_bottom - 20, 684 | excludeFromExport: false, 685 | includeDefaultValues: true, 686 | centeredRotation: true, 687 | }); 688 | 689 | // check if the canvas already has a floorplan boundry 690 | var current_angle = 0; 691 | canvas.getObjects().forEach(function (object) { 692 | if (object.custom_meta) { 693 | if (object.custom_meta.object_type == "floorplan_boundry") { 694 | current_angle = object.angle; 695 | canvas.remove(object); 696 | } 697 | } 698 | }); 699 | 700 | var group = new fabric.Group([rect, text, dimensions]); 701 | group.angle = current_angle; 702 | group.lockMovementY = true; 703 | group.lockMovementX = true; 704 | group.selectable = false; 705 | group.evented = false; 706 | group.setControlsVisibility({ 707 | mt: false, 708 | mb: false, 709 | ml: false, 710 | mr: false, 711 | bl: false, 712 | br: false, 713 | tl: false, 714 | tr: false, 715 | }) 716 | group.set('custom_meta', { 717 | "object_type": "floorplan_boundry", 718 | }); 719 | canvas.add(group); 720 | //canvas.setDimensions({ width: rounded_width, height: rounded_height }, { cssOnly: true }); 721 | canvas.renderAll(); 722 | save_floorplan(); 723 | set_zoom(1); 724 | $('#control_unit_modal').modal('hide'); 725 | }); 726 | }; 727 | window.update_dimensions = update_dimensions; 728 | 729 | // end set scale ----------------------------------------------------------------------------- ! 730 | 731 | // start keyboard/mouse controls ----------------------------------------------------------------------------- ! 732 | 733 | function move_active_object(x, y) { 734 | var object = canvas.getActiveObject(); 735 | if (object) { 736 | object.set({ 737 | left: object.left + x, 738 | top: object.top + y 739 | }); 740 | canvas.renderAll(); 741 | } 742 | } 743 | 744 | function rotate_active_object(angle) { 745 | var object = canvas.getActiveObject(); 746 | if (object) { 747 | object.rotate(object.angle + angle); 748 | canvas.renderAll(); 749 | } 750 | } 751 | 752 | // key down events for object control 753 | document.addEventListener('keydown', function (e) { 754 | // delete key 755 | if (e.keyCode == 46) { 756 | delete_floorplan_object(); 757 | } 758 | // events for arrows to move active object 759 | if (e.keyCode == 37) { 760 | move_active_object(-5, 0); 761 | } else if (e.keyCode == 38) { 762 | move_active_object(0, -5); 763 | } else if (e.keyCode == 39) { 764 | move_active_object(5, 0); 765 | } else if (e.keyCode == 40) { 766 | move_active_object(0, 5); 767 | } 768 | // when shift and arrow is pressed, rotate active object 769 | if (e.shiftKey && e.keyCode == 37) { 770 | rotate_active_object(-45); 771 | } else if (e.shiftKey && e.keyCode == 39) { 772 | rotate_active_object(45); 773 | } 774 | }); 775 | 776 | 777 | // end keyboard/mouse controls ----------------------------------------------------------------------------- ! 778 | 779 | // start save floorplan ----------------------------------------------------------------------------- ! 780 | 781 | function save_floorplan() { 782 | var floor_json = canvas.toJSON(["id", "text", "_controlsVisibility", "custom_meta", "lockMovementY", "lockMovementX", "evented", "selectable"]); 783 | $.ajax({ 784 | type: "PATCH", 785 | url: `/api/plugins/floorplan/floorplans/${obj_pk}/`, 786 | dataType: "json", 787 | headers: { 788 | "X-CSRFToken": csrf, 789 | "Content-Type": "application/json" 790 | }, 791 | data: JSON.stringify({ 792 | "canvas": floor_json, 793 | }), 794 | error: function (err) { 795 | console.log(`Error: ${err}`); 796 | } 797 | }); 798 | } 799 | 800 | function save_and_redirect() { 801 | var floor_json = canvas.toJSON(["id", "text", "_controlsVisibility", "custom_meta", "lockMovementY", "lockMovementX", "evented", "selectable"]); 802 | $.ajax({ 803 | type: "PATCH", 804 | url: `/api/plugins/floorplan/floorplans/${obj_pk}/`, 805 | dataType: "json", 806 | headers: { 807 | "X-CSRFToken": csrf, 808 | "Content-Type": "application/json" 809 | }, 810 | data: JSON.stringify({ 811 | "canvas": floor_json, 812 | }), 813 | error: function (err) { 814 | console.log(`Error: ${err}`); 815 | } 816 | }).done(function () { 817 | if (record_type == "site") { 818 | window.location.href = `/dcim/sites/${site_id}/floorplans/`; 819 | } else { 820 | window.location.href = `/dcim/locations/${location_id}/floorplans/`; 821 | } 822 | }); 823 | } 824 | 825 | 826 | 827 | window.save_and_redirect = save_and_redirect; 828 | // end save floorplan ----------------------------------------------------------------------------- ! 829 | 830 | // start initialize load ----------------------------------------------------------------------------- ! 831 | document.addEventListener("DOMContentLoaded", init_floor_plan(obj_pk, canvas, "edit")); 832 | // end initialize load ----------------------------------------------------------------------------- ! 833 | -------------------------------------------------------------------------------- /netbox_floorplan/static/netbox_floorplan/floorplan/utils.js: -------------------------------------------------------------------------------- 1 | export { 2 | resize_canvas, 3 | export_svg, 4 | enable_button_selection, 5 | disable_button_selection, 6 | prevent_leaving_canvas, 7 | wheel_zoom, 8 | reset_zoom, 9 | stop_pan, 10 | start_pan, 11 | move_pan, 12 | init_floor_plan 13 | }; 14 | 15 | 16 | function resize_canvas(canvas, window) { 17 | var bob_width = $("#content-container").width(); 18 | var window_width = $(window).width(); 19 | window_width = Math.min(window_width, bob_width); 20 | var window_height = $(window).height(); 21 | var canvas_width = window_width; 22 | var canvas_height = window_height - 100; 23 | canvas.setWidth(canvas_width); 24 | canvas.setHeight(canvas_height); 25 | // canvas.backgroundImage.scaleToWidth(canvas_width); 26 | // canvas.backgroundImage.scaleToHeight(canvas_height); 27 | canvas.renderAll(); 28 | } 29 | 30 | function reset_zoom(canvas) { 31 | 32 | var objs = canvas.getObjects(); 33 | for (var i = 0; i < objs.length; i++) { 34 | if (objs[i].custom_meta) { 35 | if (objs[i].custom_meta.object_type == "floorplan_boundry") { 36 | canvas.setActiveObject(objs[i]); 37 | let pan_x = 0 38 | let pan_y = 0 39 | let object = canvas.getActiveObject() 40 | let obj_wdth = object.getScaledWidth() 41 | let obj_hgt = object.getScaledHeight() 42 | let rect_cooords = object.getBoundingRect(); 43 | let zoom_level = Math.min(canvas.width / rect_cooords.width, canvas.height / rect_cooords.height); 44 | 45 | canvas.setZoom(zoom_level * 0.7); 46 | let zoom = canvas.getZoom() 47 | pan_x = ((canvas.getWidth() / zoom / 2) - (object.aCoords.tl.x) - (obj_wdth / 2)) * zoom 48 | pan_y = ((canvas.getHeight() / zoom / 2) - (object.aCoords.tl.y) - (obj_hgt / 2)) * zoom 49 | pan_x = (canvas.getVpCenter().x - object.getCenterPoint().x) * zoom 50 | pan_y = ((canvas.getVpCenter().y - object.getCenterPoint().y) * zoom) 51 | canvas.relativePan({ x: pan_x, y: pan_y }) 52 | canvas.requestRenderAll() 53 | canvas.discardActiveObject(); 54 | } 55 | } 56 | } 57 | } 58 | 59 | function export_svg(canvas) { 60 | var filedata = canvas.toSVG(); 61 | var locfile = new Blob([filedata], { type: "image/svg+xml;charset=utf-8" }); 62 | var locfilesrc = URL.createObjectURL(locfile); 63 | var link = document.createElement('a'); 64 | link.style.display = 'none'; 65 | link.href = locfilesrc; 66 | link.download = "floorplan.svg"; 67 | link.click(); 68 | } 69 | 70 | function enable_button_selection() { 71 | document.getElementById("selected_color").value = "#000000"; 72 | $(".tools").removeClass("disabled"); 73 | } 74 | 75 | function disable_button_selection() { 76 | // set color to default 77 | document.getElementById("selected_color").value = "#000000"; 78 | $(".tools").addClass("disabled"); 79 | } 80 | 81 | function prevent_leaving_canvas(e, canvas) { 82 | var obj = e.target; 83 | obj.setCoords(); 84 | var current_zoom = obj.canvas.getZoom(); 85 | if (obj.getScaledHeight() > obj.canvas.height || obj.getScaledWidth() > obj.canvas.width) { 86 | return; 87 | } 88 | if (obj.getBoundingRect().top < 0 || obj.getBoundingRect().left < 0) { 89 | obj.top = Math.max(obj.top * current_zoom, obj.top * current_zoom - obj.getBoundingRect().top) / current_zoom; 90 | obj.left = Math.max(obj.left * current_zoom, obj.left * current_zoom - obj.getBoundingRect().left) / current_zoom; 91 | } 92 | if (obj.getBoundingRect().top + obj.getBoundingRect().height > obj.canvas.height || obj.getBoundingRect().left + obj.getBoundingRect().width > obj.canvas.width) { 93 | obj.top = Math.min(obj.top * current_zoom, obj.canvas.height - obj.getBoundingRect().height + obj.top * current_zoom - obj.getBoundingRect().top) / current_zoom; 94 | obj.left = Math.min(obj.left * current_zoom, obj.canvas.width - obj.getBoundingRect().width + obj.left * current_zoom - obj.getBoundingRect().left) / current_zoom; 95 | } 96 | }; 97 | 98 | 99 | function wheel_zoom(opt, canvas) { 100 | var delta = opt.e.deltaY; 101 | var zoom = canvas.getZoom(); 102 | zoom *= 0.999 ** delta; 103 | if (zoom > 20) zoom = 20; 104 | if (zoom < 0.01) zoom = 0.01; 105 | canvas.zoomToPoint({ x: opt.e.offsetX, y: opt.e.offsetY }, zoom); 106 | opt.e.preventDefault(); 107 | opt.e.stopPropagation(); 108 | } 109 | 110 | function stop_pan(canvas) { 111 | canvas.setViewportTransform(canvas.viewportTransform); 112 | canvas.isDragging = false; 113 | canvas.selection = true; 114 | } 115 | 116 | function start_pan(opt, canvas) { 117 | var evt = opt.e; 118 | if (evt.altKey === true) { 119 | canvas.isDragging = true; 120 | canvas.selection = false; 121 | canvas.lastPosX = evt.clientX; 122 | canvas.lastPosY = evt.clientY; 123 | } 124 | } 125 | 126 | function move_pan(opt, canvas) { 127 | if (canvas.isDragging) { 128 | var e = opt.e; 129 | var vpt = canvas.viewportTransform; 130 | vpt[4] += e.clientX - canvas.lastPosX; 131 | vpt[5] += e.clientY - canvas.lastPosY; 132 | canvas.requestRenderAll(); 133 | canvas.lastPosX = e.clientX; 134 | canvas.lastPosY = e.clientY; 135 | } 136 | } 137 | 138 | 139 | 140 | 141 | function init_floor_plan(floorplan_id, canvas, mode) { 142 | 143 | if (floorplan_id === undefined || floorplan_id === null || floorplan_id === "") { 144 | return; 145 | } 146 | 147 | var target_image = 0; 148 | const floorplan_call = $.get(`/api/plugins/floorplan/floorplans/?id=${floorplan_id}`); 149 | floorplan_call.done(function (floorplan) { 150 | floorplan.results.forEach((floorplan) => { 151 | target_image = floorplan.assigned_image 152 | canvas.loadFromJSON(JSON.stringify(floorplan.canvas), canvas.renderAll.bind(canvas), function (o, object) { 153 | if (mode == "readonly") { 154 | object.set('selectable', false); 155 | } 156 | if (floorplan.assigned_image != null) { 157 | var img_url = ""; 158 | if (floorplan.assigned_image.external_url != "") { 159 | img_url = floorplan.assigned_image.external_url; 160 | } else { 161 | img_url = floorplan.assigned_image.file; 162 | } 163 | 164 | 165 | var img = fabric.Image.fromURL(img_url, function(img) { 166 | var left = 0; 167 | var top = 0; 168 | var width = 0; 169 | var height = 0; 170 | canvas.getObjects().forEach(function (object) { 171 | if (object.custom_meta) { 172 | if (object.custom_meta.object_type == "floorplan_boundry") { 173 | left = object.left; 174 | top = object.top; 175 | width = object.width; 176 | height = object.height; 177 | } 178 | } 179 | }); 180 | // if we have a floorplan boundary, position the image in there 181 | if (height != 0 && width != 0) { 182 | let scaleRatioX = Math.max(width / img.width) 183 | let scaleRatioY = Math.max(height / img.height); 184 | canvas.setBackgroundImage(img, canvas.renderAll.bind(canvas), { 185 | scaleX: scaleRatioX, 186 | scaleY: scaleRatioY, 187 | left: left, 188 | top: top 189 | }); 190 | } 191 | else 192 | { 193 | let scaleRatio = Math.max(canvas.width / img.width, canvas.height / img.height); 194 | canvas.setBackgroundImage(img, canvas.renderAll.bind(canvas), { 195 | scaleX: scaleRatio, 196 | scaleY: scaleRatio, 197 | left: canvas.width / 2, 198 | top: canvas.height / 2, 199 | originX: 'middle', 200 | originY: 'middle' 201 | }); 202 | } 203 | }); 204 | 205 | 206 | } else { 207 | canvas.setBackgroundImage().renderAll(); 208 | } 209 | canvas.renderAll(); 210 | }); 211 | }); 212 | reset_zoom(canvas); 213 | resize_canvas(canvas, window); 214 | }).fail(function (jq_xhr, text_status, error_thrown) { 215 | console.log(`error: ${error_thrown} - ${text_status}`); 216 | }); 217 | }; -------------------------------------------------------------------------------- /netbox_floorplan/static/netbox_floorplan/floorplan/view.js: -------------------------------------------------------------------------------- 1 | import { 2 | resize_canvas, 3 | export_svg, 4 | wheel_zoom, 5 | stop_pan, 6 | move_pan, 7 | start_pan, 8 | reset_zoom, 9 | init_floor_plan 10 | } from "/static/netbox_floorplan/floorplan/utils.js"; 11 | 12 | var canvas = new fabric.Canvas('canvas'); 13 | 14 | canvas.on('mouse:over', function (options) { 15 | if (options.target) { 16 | if (options.target.hasOwnProperty("custom_meta")) { 17 | options.target.hoverCursor = "pointer"; 18 | } 19 | 20 | } 21 | }); 22 | 23 | canvas.on('mouse:down', function (options) { 24 | if (options.target) { 25 | if (options.target.hasOwnProperty("custom_meta")) { 26 | window.location.href = options.target.custom_meta.object_url; 27 | } 28 | } 29 | }); 30 | 31 | // start zoom, pan control & resizing ----------------------------------------------------------------------------- ! 32 | 33 | $(window).resize(resize_canvas(canvas, window)); 34 | 35 | canvas.on('mouse:wheel', function (opt) { 36 | wheel_zoom(opt, canvas); 37 | }); 38 | 39 | canvas.on('mouse:down', function (opt) { 40 | start_pan(opt, canvas); 41 | }); 42 | 43 | canvas.on('mouse:move', function (opt) { 44 | move_pan(opt, canvas); 45 | }); 46 | canvas.on('mouse:up', function (opt) { 47 | stop_pan(canvas); 48 | }); 49 | 50 | // end start zoom, pan control & resizing ----------------------------------------------------------------------------- ! 51 | 52 | 53 | document.getElementById('export_svg').addEventListener('click', () => { 54 | export_svg(canvas); 55 | }); 56 | 57 | 58 | let floorplan_id = document.getElementById('floorplan_id').value; 59 | document.addEventListener("DOMContentLoaded", init_floor_plan(floorplan_id, canvas, "readonly")); 60 | -------------------------------------------------------------------------------- /netbox_floorplan/static/netbox_floorplan/vendors/htmx.min.js: -------------------------------------------------------------------------------- 1 | (function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Q={onLoad:F,process:zt,on:de,off:ge,trigger:ce,ajax:Nr,find:C,findAll:f,closest:v,values:function(e,t){var r=dr(e,t||"post");return r.values},remove:_,addClass:z,removeClass:n,toggleClass:$,takeClass:W,defineExtension:Ur,removeExtension:Br,logAll:V,logNone:j,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null},parseInterval:d,_:t,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Q.config.wsBinaryType;return t},version:"1.9.12"};var r={addTriggerHandler:Lt,bodyContains:se,canAccessLocalStorage:U,findThisElement:xe,filterValues:yr,hasAttribute:o,getAttributeValue:te,getClosestAttributeValue:ne,getClosestMatch:c,getExpressionVars:Hr,getHeaders:xr,getInputValues:dr,getInternalData:ae,getSwapSpecification:wr,getTriggerSpecs:it,getTarget:ye,makeFragment:l,mergeObjects:le,makeSettleInfo:T,oobSwap:Ee,querySelectorExt:ue,selectAndSwap:je,settleImmediately:nr,shouldCancel:ut,triggerEvent:ce,triggerErrorEvent:fe,withExtensions:R};var w=["get","post","put","delete","patch"];var i=w.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");var S=e("head"),q=e("title"),H=e("svg",true);function e(e,t){return new RegExp("<"+e+"(\\s[^>]*>|>)([\\s\\S]*?)<\\/"+e+">",!!t?"gim":"im")}function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){return e.parentElement}function re(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function L(e,t,r){var n=te(t,r);var i=te(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function ne(t,r){var n=null;c(t,function(e){return n=L(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function A(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function s(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=re().createDocumentFragment()}return i}function N(e){return/",0);var a=i.querySelector("template").content;if(Q.config.allowScriptTags){oe(a.querySelectorAll("script"),function(e){if(Q.config.inlineScriptNonce){e.nonce=Q.config.inlineScriptNonce}e.htmxExecuted=navigator.userAgent.indexOf("Firefox")===-1})}else{oe(a.querySelectorAll("script"),function(e){_(e)})}return a}switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return s(""+n+"
",1);case"col":return s(""+n+"
",2);case"tr":return s(""+n+"
",2);case"td":case"th":return s(""+n+"
",3);case"script":case"style":return s("
"+n+"
",1);default:return s(n,0)}}function ie(e){if(e){e()}}function I(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return I(e,"Function")}function P(e){return I(e,"Object")}function ae(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function M(e){var t=[];if(e){for(var r=0;r=0}function se(e){if(e.getRootNode&&e.getRootNode()instanceof window.ShadowRoot){return re().body.contains(e.getRootNode().host)}else{return re().body.contains(e)}}function D(e){return e.trim().split(/\s+/)}function le(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function E(e){try{return JSON.parse(e)}catch(e){b(e);return null}}function U(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function B(t){try{var e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function t(e){return Tr(re().body,function(){return eval(e)})}function F(t){var e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function j(){Q.logger=null}function C(e,t){if(t){return e.querySelector(t)}else{return C(re(),e)}}function f(e,t){if(t){return e.querySelectorAll(t)}else{return f(re(),e)}}function _(e,t){e=p(e);if(t){setTimeout(function(){_(e);e=null},t)}else{e.parentElement.removeChild(e)}}function z(e,t,r){e=p(e);if(r){setTimeout(function(){z(e,t);e=null},r)}else{e.classList&&e.classList.add(t)}}function n(e,t,r){e=p(e);if(r){setTimeout(function(){n(e,t);e=null},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function $(e,t){e=p(e);e.classList.toggle(t)}function W(e,t){e=p(e);oe(e.parentElement.children,function(e){n(e,t)});z(e,t)}function v(e,t){e=p(e);if(e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&u(e));return null}}function g(e,t){return e.substring(0,t.length)===t}function G(e,t){return e.substring(e.length-t.length)===t}function J(e){var t=e.trim();if(g(t,"<")&&G(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function Z(e,t){if(t.indexOf("closest ")===0){return[v(e,J(t.substr(8)))]}else if(t.indexOf("find ")===0){return[C(e,J(t.substr(5)))]}else if(t==="next"){return[e.nextElementSibling]}else if(t.indexOf("next ")===0){return[K(e,J(t.substr(5)))]}else if(t==="previous"){return[e.previousElementSibling]}else if(t.indexOf("previous ")===0){return[Y(e,J(t.substr(9)))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else{return re().querySelectorAll(J(t))}}var K=function(e,t){var r=re().querySelectorAll(t);for(var n=0;n=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function ue(e,t){if(t){return Z(e,t)[0]}else{return Z(re().body,e)[0]}}function p(e){if(I(e,"String")){return C(e)}else{return e}}function ve(e,t,r){if(k(t)){return{target:re().body,event:e,listener:t}}else{return{target:p(e),event:t,listener:r}}}function de(t,r,n){jr(function(){var e=ve(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=k(r);return e?r:n}function ge(t,r,n){jr(function(){var e=ve(t,r,n);e.target.removeEventListener(e.event,e.listener)});return k(r)?r:n}var pe=re().createElement("output");function me(e,t){var r=ne(e,t);if(r){if(r==="this"){return[xe(e,t)]}else{var n=Z(e,r);if(n.length===0){b('The selector "'+r+'" on '+t+" returned no matches!");return[pe]}else{return n}}}}function xe(e,t){return c(e,function(e){return te(e,t)!=null})}function ye(e){var t=ne(e,"hx-target");if(t){if(t==="this"){return xe(e,"hx-target")}else{return ue(e,t)}}else{var r=ae(e);if(r.boosted){return re().body}else{return e}}}function be(e){var t=Q.config.attributesToSettle;for(var r=0;r0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=re().querySelectorAll(t);if(r){oe(r,function(e){var t;var r=i.cloneNode(true);t=re().createDocumentFragment();t.appendChild(r);if(!Se(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!ce(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){Fe(o,e,e,t,a)}oe(a.elts,function(e){ce(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);fe(re().body,"htmx:oobErrorNoTarget",{content:i})}return e}function Ce(e,t,r){var n=ne(e,"hx-select-oob");if(n){var i=n.split(",");for(var a=0;a0){var r=t.replace("'","\\'");var n=e.tagName.replace(":","\\:");var i=o.querySelector(n+"[id='"+r+"']");if(i&&i!==o){var a=e.cloneNode();we(e,i);s.tasks.push(function(){we(e,a)})}}})}function Oe(e){return function(){n(e,Q.config.addedClass);zt(e);Nt(e);qe(e);ce(e,"htmx:load")}}function qe(e){var t="[autofocus]";var r=h(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function a(e,t,r,n){Te(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;z(i,Q.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(Oe(i))}}}function He(e,t){var r=0;while(r-1){var t=e.replace(H,"");var r=t.match(q);if(r){return r[2]}}}function je(e,t,r,n,i,a){i.title=Ve(n);var o=l(n);if(o){Ce(r,o,i);o=Be(r,o,a);Re(o);return Fe(e,r,t,o,i)}}function _e(e,t,r){var n=e.getResponseHeader(t);if(n.indexOf("{")===0){var i=E(n);for(var a in i){if(i.hasOwnProperty(a)){var o=i[a];if(!P(o)){o={value:o}}ce(r,a,o)}}}else{var s=n.split(",");for(var l=0;l0){var o=t[0];if(o==="]"){n--;if(n===0){if(a===null){i=i+"true"}t.shift();i+=")})";try{var s=Tr(e,function(){return Function(i)()},function(){return true});s.source=i;return s}catch(e){fe(re().body,"htmx:syntax:error",{error:e,source:i});return null}}}else if(o==="["){n++}if(Qe(o,a,r)){i+="(("+r+"."+o+") ? ("+r+"."+o+") : (window."+o+"))"}else{i=i+o}a=t.shift()}}}function y(e,t){var r="";while(e.length>0&&!t.test(e[0])){r+=e.shift()}return r}function tt(e){var t;if(e.length>0&&Ze.test(e[0])){e.shift();t=y(e,Ke).trim();e.shift()}else{t=y(e,x)}return t}var rt="input, textarea, select";function nt(e,t,r){var n=[];var i=Ye(t);do{y(i,Je);var a=i.length;var o=y(i,/[,\[\s]/);if(o!==""){if(o==="every"){var s={trigger:"every"};y(i,Je);s.pollInterval=d(y(i,/[,\[\s]/));y(i,Je);var l=et(e,i,"event");if(l){s.eventFilter=l}n.push(s)}else if(o.indexOf("sse:")===0){n.push({trigger:"sse",sseEvent:o.substr(4)})}else{var u={trigger:o};var l=et(e,i,"event");if(l){u.eventFilter=l}while(i.length>0&&i[0]!==","){y(i,Je);var f=i.shift();if(f==="changed"){u.changed=true}else if(f==="once"){u.once=true}else if(f==="consume"){u.consume=true}else if(f==="delay"&&i[0]===":"){i.shift();u.delay=d(y(i,x))}else if(f==="from"&&i[0]===":"){i.shift();if(Ze.test(i[0])){var c=tt(i)}else{var c=y(i,x);if(c==="closest"||c==="find"||c==="next"||c==="previous"){i.shift();var h=tt(i);if(h.length>0){c+=" "+h}}}u.from=c}else if(f==="target"&&i[0]===":"){i.shift();u.target=tt(i)}else if(f==="throttle"&&i[0]===":"){i.shift();u.throttle=d(y(i,x))}else if(f==="queue"&&i[0]===":"){i.shift();u.queue=y(i,x)}else if(f==="root"&&i[0]===":"){i.shift();u[f]=tt(i)}else if(f==="threshold"&&i[0]===":"){i.shift();u[f]=y(i,x)}else{fe(e,"htmx:syntax:error",{token:i.shift()})}}n.push(u)}}if(i.length===a){fe(e,"htmx:syntax:error",{token:i.shift()})}y(i,Je)}while(i[0]===","&&i.shift());if(r){r[t]=n}return n}function it(e){var t=te(e,"hx-trigger");var r=[];if(t){var n=Q.config.triggerSpecsCache;r=n&&n[t]||nt(e,t,n)}if(r.length>0){return r}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,rt)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function at(e){ae(e).cancelled=true}function ot(e,t,r){var n=ae(e);n.timeout=setTimeout(function(){if(se(e)&&n.cancelled!==true){if(!ct(r,e,Wt("hx:poll:trigger",{triggerSpec:r,target:e}))){t(e)}ot(e,t,r)}},r.pollInterval)}function st(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function lt(t,r,e){if(t.tagName==="A"&&st(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){r.boosted=true;var n,i;if(t.tagName==="A"){n="get";i=ee(t,"href")}else{var a=ee(t,"method");n=a?a.toLowerCase():"get";if(n==="get"){}i=ee(t,"action")}e.forEach(function(e){ht(t,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(n,i,e,t)},r,e,true)})}}function ut(e,t){if(e.type==="submit"||e.type==="click"){if(t.tagName==="FORM"){return true}if(h(t,'input[type="submit"], button')&&v(t,"form")!==null){return true}if(t.tagName==="A"&&t.href&&(t.getAttribute("href")==="#"||t.getAttribute("href").indexOf("#")!==0)){return true}}return false}function ft(e,t){return ae(e).boosted&&e.tagName==="A"&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function ct(e,t,r){var n=e.eventFilter;if(n){try{return n.call(t,r)!==true}catch(e){fe(re().body,"htmx:eventFilter:error",{error:e,source:n.source});return true}}return false}function ht(a,o,e,s,l){var u=ae(a);var t;if(s.from){t=Z(a,s.from)}else{t=[a]}if(s.changed){t.forEach(function(e){var t=ae(e);t.lastValue=e.value})}oe(t,function(n){var i=function(e){if(!se(a)){n.removeEventListener(s.trigger,i);return}if(ft(a,e)){return}if(l||ut(e,a)){e.preventDefault()}if(ct(s,a,e)){return}var t=ae(e);t.triggerSpec=s;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(a)<0){t.handledFor.push(a);if(s.consume){e.stopPropagation()}if(s.target&&e.target){if(!h(e.target,s.target)){return}}if(s.once){if(u.triggeredOnce){return}else{u.triggeredOnce=true}}if(s.changed){var r=ae(n);if(r.lastValue===n.value){return}r.lastValue=n.value}if(u.delayed){clearTimeout(u.delayed)}if(u.throttle){return}if(s.throttle>0){if(!u.throttle){o(a,e);u.throttle=setTimeout(function(){u.throttle=null},s.throttle)}}else if(s.delay>0){u.delayed=setTimeout(function(){o(a,e)},s.delay)}else{ce(a,"htmx:trigger");o(a,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:s.trigger,listener:i,on:n});n.addEventListener(s.trigger,i)})}var vt=false;var dt=null;function gt(){if(!dt){dt=function(){vt=true};window.addEventListener("scroll",dt);setInterval(function(){if(vt){vt=false;oe(re().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),function(e){pt(e)})}},200)}}function pt(t){if(!o(t,"data-hx-revealed")&&X(t)){t.setAttribute("data-hx-revealed","true");var e=ae(t);if(e.initHash){ce(t,"revealed")}else{t.addEventListener("htmx:afterProcessNode",function(e){ce(t,"revealed")},{once:true})}}}function mt(e,t,r){var n=D(r);for(var i=0;i=0){var t=wt(n);setTimeout(function(){xt(s,r,n+1)},t)}};t.onopen=function(e){n=0};ae(s).webSocket=t;t.addEventListener("message",function(e){if(yt(s)){return}var t=e.data;R(s,function(e){t=e.transformResponse(t,null,s)});var r=T(s);var n=l(t);var i=M(n.children);for(var a=0;a0){ce(u,"htmx:validation:halted",i);return}t.send(JSON.stringify(l));if(ut(e,u)){e.preventDefault()}})}else{fe(u,"htmx:noWebSocketSourceError")}}function wt(e){var t=Q.config.wsReconnectDelay;if(typeof t==="function"){return t(e)}if(t==="full-jitter"){var r=Math.min(e,6);var n=1e3*Math.pow(2,r);return n*Math.random()}b('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function St(e,t,r){var n=D(r);for(var i=0;i0){setTimeout(i,n)}else{i()}}function Ht(t,i,e){var a=false;oe(w,function(r){if(o(t,"hx-"+r)){var n=te(t,"hx-"+r);a=true;i.path=n;i.verb=r;e.forEach(function(e){Lt(t,e,i,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(r,n,e,t)})})}});return a}function Lt(n,e,t,r){if(e.sseEvent){Rt(n,r,e.sseEvent)}else if(e.trigger==="revealed"){gt();ht(n,r,t,e);pt(n)}else if(e.trigger==="intersect"){var i={};if(e.root){i.root=ue(n,e.root)}if(e.threshold){i.threshold=parseFloat(e.threshold)}var a=new IntersectionObserver(function(e){for(var t=0;t0){t.polling=true;ot(n,r,e)}else{ht(n,r,t,e)}}function At(e){if(!e.htmxExecuted&&Q.config.allowScriptTags&&(e.type==="text/javascript"||e.type==="module"||e.type==="")){var t=re().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}var r=e.parentElement;try{r.insertBefore(t,e)}catch(e){b(e)}finally{if(e.parentElement){e.parentElement.removeChild(e)}}}}function Nt(e){if(h(e,"script")){At(e)}oe(f(e,"script"),function(e){At(e)})}function It(e){var t=e.attributes;if(!t){return false}for(var r=0;r0){var o=n.shift();var s=o.match(/^\s*([a-zA-Z:\-\.]+:)(.*)/);if(a===0&&s){o.split(":");i=s[1].slice(0,-1);r[i]=s[2]}else{r[i]+=o}a+=Bt(o)}for(var l in r){Ft(e,l,r[l])}}}function jt(e){Ae(e);for(var t=0;tQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(re().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Yt(e){if(!U()){return null}e=B(e);var t=E(localStorage.getItem("htmx-history-cache"))||[];for(var r=0;r=200&&this.status<400){ce(re().body,"htmx:historyCacheMissLoad",o);var e=l(this.response);e=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;var t=Zt();var r=T(t);var n=Ve(this.response);if(n){var i=C("title");if(i){i.innerHTML=n}else{window.document.title=n}}Ue(t,e,r);nr(r.tasks);Jt=a;ce(re().body,"htmx:historyRestore",{path:a,cacheMiss:true,serverResponse:this.response})}else{fe(re().body,"htmx:historyCacheMissLoadError",o)}};e.send()}function ar(e){er();e=e||location.pathname+location.search;var t=Yt(e);if(t){var r=l(t.content);var n=Zt();var i=T(n);Ue(n,r,i);nr(i.tasks);document.title=t.title;setTimeout(function(){window.scrollTo(0,t.scroll)},0);Jt=e;ce(re().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{ir(e)}}}function or(e){var t=me(e,"hx-indicator");if(t==null){t=[e]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.classList["add"].call(e.classList,Q.config.requestClass)});return t}function sr(e){var t=me(e,"hx-disabled-elt");if(t==null){t=[]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","")});return t}function lr(e,t){oe(e,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList["remove"].call(e.classList,Q.config.requestClass)}});oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled")}})}function ur(e,t){for(var r=0;r=0}function wr(e,t){var r=t?t:ne(e,"hx-swap");var n={swapStyle:ae(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ae(e).boosted&&!br(e)){n["show"]="top"}if(r){var i=D(r);if(i.length>0){for(var a=0;a0?l.join(":"):null;n["scroll"]=u;n["scrollTarget"]=f}else if(o.indexOf("show:")===0){var c=o.substr(5);var l=c.split(":");var h=l.pop();var f=l.length>0?l.join(":"):null;n["show"]=h;n["showTarget"]=f}else if(o.indexOf("focus-scroll:")===0){var v=o.substr("focus-scroll:".length);n["focusScroll"]=v=="true"}else if(a==0){n["swapStyle"]=o}else{b("Unknown modifier in hx-swap: "+o)}}}}return n}function Sr(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function Er(t,r,n){var i=null;R(r,function(e){if(i==null){i=e.encodeParameters(t,n,r)}});if(i!=null){return i}else{if(Sr(r)){return mr(n)}else{return pr(n)}}}function T(e){return{tasks:[],elts:[e]}}function Cr(e,t){var r=e[0];var n=e[e.length-1];if(t.scroll){var i=null;if(t.scrollTarget){i=ue(r,t.scrollTarget)}if(t.scroll==="top"&&(r||i)){i=i||r;i.scrollTop=0}if(t.scroll==="bottom"&&(n||i)){i=i||n;i.scrollTop=i.scrollHeight}}if(t.show){var i=null;if(t.showTarget){var a=t.showTarget;if(t.showTarget==="window"){a="body"}i=ue(r,a)}if(t.show==="top"&&(r||i)){i=i||r;i.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(n||i)){i=i||n;i.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Rr(e,t,r,n){if(n==null){n={}}if(e==null){return n}var i=te(e,t);if(i){var a=i.trim();var o=r;if(a==="unset"){return null}if(a.indexOf("javascript:")===0){a=a.substr(11);o=true}else if(a.indexOf("js:")===0){a=a.substr(3);o=true}if(a.indexOf("{")!==0){a="{"+a+"}"}var s;if(o){s=Tr(e,function(){return Function("return ("+a+")")()},{})}else{s=E(a)}for(var l in s){if(s.hasOwnProperty(l)){if(n[l]==null){n[l]=s[l]}}}}return Rr(u(e),t,r,n)}function Tr(e,t,r){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return r}}function Or(e,t){return Rr(e,"hx-vars",true,t)}function qr(e,t){return Rr(e,"hx-vals",false,t)}function Hr(e){return le(Or(e),qr(e))}function Lr(t,r,n){if(n!==null){try{t.setRequestHeader(r,n)}catch(e){t.setRequestHeader(r,encodeURIComponent(n));t.setRequestHeader(r+"-URI-AutoEncoded","true")}}}function Ar(t){if(t.responseURL&&typeof URL!=="undefined"){try{var e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(re().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function O(e,t){return t.test(e.getAllResponseHeaders())}function Nr(e,t,r){e=e.toLowerCase();if(r){if(r instanceof Element||I(r,"String")){return he(e,t,null,null,{targetOverride:p(r),returnPromise:true})}else{return he(e,t,p(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:p(r.target),swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(e,t,null,null,{returnPromise:true})}}function Ir(e){var t=[];while(e){t.push(e);e=e.parentElement}return t}function kr(e,t,r){var n;var i;if(typeof URL==="function"){i=new URL(t,document.location.href);var a=document.location.origin;n=a===i.origin}else{i=t;n=g(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!n){return false}}return ce(e,"htmx:validateUrl",le({url:i,sameHost:n},r))}function he(t,r,n,i,a,e){var o=null;var s=null;a=a!=null?a:{};if(a.returnPromise&&typeof Promise!=="undefined"){var l=new Promise(function(e,t){o=e;s=t})}if(n==null){n=re().body}var M=a.handler||Mr;var X=a.select||null;if(!se(n)){ie(o);return l}var u=a.targetOverride||ye(n);if(u==null||u==pe){fe(n,"htmx:targetError",{target:te(n,"hx-target")});ie(s);return l}var f=ae(n);var c=f.lastButtonClicked;if(c){var h=ee(c,"formaction");if(h!=null){r=h}var v=ee(c,"formmethod");if(v!=null){if(v.toLowerCase()!=="dialog"){t=v}}}var d=ne(n,"hx-confirm");if(e===undefined){var D=function(e){return he(t,r,n,i,a,!!e)};var U={target:u,elt:n,path:r,verb:t,triggeringEvent:i,etc:a,issueRequest:D,question:d};if(ce(n,"htmx:confirm",U)===false){ie(o);return l}}var g=n;var p=ne(n,"hx-sync");var m=null;var x=false;if(p){var B=p.split(":");var F=B[0].trim();if(F==="this"){g=xe(n,"hx-sync")}else{g=ue(n,F)}p=(B[1]||"drop").trim();f=ae(g);if(p==="drop"&&f.xhr&&f.abortable!==true){ie(o);return l}else if(p==="abort"){if(f.xhr){ie(o);return l}else{x=true}}else if(p==="replace"){ce(g,"htmx:abort")}else if(p.indexOf("queue")===0){var V=p.split(" ");m=(V[1]||"last").trim()}}if(f.xhr){if(f.abortable){ce(g,"htmx:abort")}else{if(m==null){if(i){var y=ae(i);if(y&&y.triggerSpec&&y.triggerSpec.queue){m=y.triggerSpec.queue}}if(m==null){m="last"}}if(f.queuedRequests==null){f.queuedRequests=[]}if(m==="first"&&f.queuedRequests.length===0){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="all"){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="last"){f.queuedRequests=[];f.queuedRequests.push(function(){he(t,r,n,i,a)})}ie(o);return l}}var b=new XMLHttpRequest;f.xhr=b;f.abortable=x;var w=function(){f.xhr=null;f.abortable=false;if(f.queuedRequests!=null&&f.queuedRequests.length>0){var e=f.queuedRequests.shift();e()}};var j=ne(n,"hx-prompt");if(j){var S=prompt(j);if(S===null||!ce(n,"htmx:prompt",{prompt:S,target:u})){ie(o);w();return l}}if(d&&!e){if(!confirm(d)){ie(o);w();return l}}var E=xr(n,u,S);if(t!=="get"&&!Sr(n)){E["Content-Type"]="application/x-www-form-urlencoded"}if(a.headers){E=le(E,a.headers)}var _=dr(n,t);var C=_.errors;var R=_.values;if(a.values){R=le(R,a.values)}var z=Hr(n);var $=le(R,z);var T=yr($,n);if(Q.config.getCacheBusterParam&&t==="get"){T["org.htmx.cache-buster"]=ee(u,"id")||"true"}if(r==null||r===""){r=re().location.href}var O=Rr(n,"hx-request");var W=ae(n).boosted;var q=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;var H={boosted:W,useUrlParams:q,parameters:T,unfilteredParameters:$,headers:E,target:u,verb:t,errors:C,withCredentials:a.credentials||O.credentials||Q.config.withCredentials,timeout:a.timeout||O.timeout||Q.config.timeout,path:r,triggeringEvent:i};if(!ce(n,"htmx:configRequest",H)){ie(o);w();return l}r=H.path;t=H.verb;E=H.headers;T=H.parameters;C=H.errors;q=H.useUrlParams;if(C&&C.length>0){ce(n,"htmx:validation:halted",H);ie(o);w();return l}var G=r.split("#");var J=G[0];var L=G[1];var A=r;if(q){A=J;var Z=Object.keys(T).length!==0;if(Z){if(A.indexOf("?")<0){A+="?"}else{A+="&"}A+=pr(T);if(L){A+="#"+L}}}if(!kr(n,A,H)){fe(n,"htmx:invalidPath",H);ie(s);return l}b.open(t.toUpperCase(),A,true);b.overrideMimeType("text/html");b.withCredentials=H.withCredentials;b.timeout=H.timeout;if(O.noHeaders){}else{for(var N in E){if(E.hasOwnProperty(N)){var K=E[N];Lr(b,N,K)}}}var I={xhr:b,target:u,requestConfig:H,etc:a,boosted:W,select:X,pathInfo:{requestPath:r,finalRequestPath:A,anchor:L}};b.onload=function(){try{var e=Ir(n);I.pathInfo.responsePath=Ar(b);M(n,I);lr(k,P);ce(n,"htmx:afterRequest",I);ce(n,"htmx:afterOnLoad",I);if(!se(n)){var t=null;while(e.length>0&&t==null){var r=e.shift();if(se(r)){t=r}}if(t){ce(t,"htmx:afterRequest",I);ce(t,"htmx:afterOnLoad",I)}}ie(o);w()}catch(e){fe(n,"htmx:onLoadError",le({error:e},I));throw e}};b.onerror=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendError",I);ie(s);w()};b.onabort=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendAbort",I);ie(s);w()};b.ontimeout=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:timeout",I);ie(s);w()};if(!ce(n,"htmx:beforeRequest",I)){ie(o);w();return l}var k=or(n);var P=sr(n);oe(["loadstart","loadend","progress","abort"],function(t){oe([b,b.upload],function(e){e.addEventListener(t,function(e){ce(n,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ce(n,"htmx:beforeSend",I);var Y=q?null:Er(b,n,T);b.send(Y);return l}function Pr(e,t){var r=t.xhr;var n=null;var i=null;if(O(r,/HX-Push:/i)){n=r.getResponseHeader("HX-Push");i="push"}else if(O(r,/HX-Push-Url:/i)){n=r.getResponseHeader("HX-Push-Url");i="push"}else if(O(r,/HX-Replace-Url:/i)){n=r.getResponseHeader("HX-Replace-Url");i="replace"}if(n){if(n==="false"){return{}}else{return{type:i,path:n}}}var a=t.pathInfo.finalRequestPath;var o=t.pathInfo.responsePath;var s=ne(e,"hx-push-url");var l=ne(e,"hx-replace-url");var u=ae(e).boosted;var f=null;var c=null;if(s){f="push";c=s}else if(l){f="replace";c=l}else if(u){f="push";c=o||a}if(c){if(c==="false"){return{}}if(c==="true"){c=o||a}if(t.pathInfo.anchor&&c.indexOf("#")===-1){c=c+"#"+t.pathInfo.anchor}return{type:f,path:c}}else{return{}}}function Mr(l,u){var f=u.xhr;var c=u.target;var e=u.etc;var t=u.requestConfig;var h=u.select;if(!ce(l,"htmx:beforeOnLoad",u))return;if(O(f,/HX-Trigger:/i)){_e(f,"HX-Trigger",l)}if(O(f,/HX-Location:/i)){er();var r=f.getResponseHeader("HX-Location");var v;if(r.indexOf("{")===0){v=E(r);r=v["path"];delete v["path"]}Nr("GET",r,v).then(function(){tr(r)});return}var n=O(f,/HX-Refresh:/i)&&"true"===f.getResponseHeader("HX-Refresh");if(O(f,/HX-Redirect:/i)){location.href=f.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){location.reload();return}if(O(f,/HX-Retarget:/i)){if(f.getResponseHeader("HX-Retarget")==="this"){u.target=l}else{u.target=ue(l,f.getResponseHeader("HX-Retarget"))}}var d=Pr(l,u);var i=f.status>=200&&f.status<400&&f.status!==204;var g=f.response;var a=f.status>=400;var p=Q.config.ignoreTitle;var o=le({shouldSwap:i,serverResponse:g,isError:a,ignoreTitle:p},u);if(!ce(c,"htmx:beforeSwap",o))return;c=o.target;g=o.serverResponse;a=o.isError;p=o.ignoreTitle;u.target=c;u.failed=a;u.successful=!a;if(o.shouldSwap){if(f.status===286){at(l)}R(l,function(e){g=e.transformResponse(g,f,l)});if(d.type){er()}var s=e.swapOverride;if(O(f,/HX-Reswap:/i)){s=f.getResponseHeader("HX-Reswap")}var v=wr(l,s);if(v.hasOwnProperty("ignoreTitle")){p=v.ignoreTitle}c.classList.add(Q.config.swappingClass);var m=null;var x=null;var y=function(){try{var e=document.activeElement;var t={};try{t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null}}catch(e){}var r;if(h){r=h}if(O(f,/HX-Reselect:/i)){r=f.getResponseHeader("HX-Reselect")}if(d.type){ce(re().body,"htmx:beforeHistoryUpdate",le({history:d},u));if(d.type==="push"){tr(d.path);ce(re().body,"htmx:pushedIntoHistory",{path:d.path})}else{rr(d.path);ce(re().body,"htmx:replacedInHistory",{path:d.path})}}var n=T(c);je(v.swapStyle,c,l,g,n,r);if(t.elt&&!se(t.elt)&&ee(t.elt,"id")){var i=document.getElementById(ee(t.elt,"id"));var a={preventScroll:v.focusScroll!==undefined?!v.focusScroll:!Q.config.defaultFocusScroll};if(i){if(t.start&&i.setSelectionRange){try{i.setSelectionRange(t.start,t.end)}catch(e){}}i.focus(a)}}c.classList.remove(Q.config.swappingClass);oe(n.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}ce(e,"htmx:afterSwap",u)});if(O(f,/HX-Trigger-After-Swap:/i)){var o=l;if(!se(l)){o=re().body}_e(f,"HX-Trigger-After-Swap",o)}var s=function(){oe(n.tasks,function(e){e.call()});oe(n.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}ce(e,"htmx:afterSettle",u)});if(u.pathInfo.anchor){var e=re().getElementById(u.pathInfo.anchor);if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}if(n.title&&!p){var t=C("title");if(t){t.innerHTML=n.title}else{window.document.title=n.title}}Cr(n.elts,v);if(O(f,/HX-Trigger-After-Settle:/i)){var r=l;if(!se(l)){r=re().body}_e(f,"HX-Trigger-After-Settle",r)}ie(m)};if(v.settleDelay>0){setTimeout(s,v.settleDelay)}else{s()}}catch(e){fe(l,"htmx:swapError",u);ie(x);throw e}};var b=Q.config.globalViewTransitions;if(v.hasOwnProperty("transition")){b=v.transition}if(b&&ce(l,"htmx:beforeTransition",u)&&typeof Promise!=="undefined"&&document.startViewTransition){var w=new Promise(function(e,t){m=e;x=t});var S=y;y=function(){document.startViewTransition(function(){S();return w})}}if(v.swapDelay>0){setTimeout(y,v.swapDelay)}else{y()}}if(a){fe(l,"htmx:responseError",le({error:"Response Status Error Code "+f.status+" from "+u.pathInfo.requestPath},u))}}var Xr={};function Dr(){return{init:function(e){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,r){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,r,n){return false},encodeParameters:function(e,t,r){return null}}}function Ur(e,t){if(t.init){t.init(r)}Xr[e]=le(Dr(),t)}function Br(e){delete Xr[e]}function Fr(e,r,n){if(e==undefined){return r}if(r==undefined){r=[]}if(n==undefined){n=[]}var t=te(e,"hx-ext");if(t){oe(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){n.push(e.slice(7));return}if(n.indexOf(e)<0){var t=Xr[e];if(t&&r.indexOf(t)<0){r.push(t)}}})}return Fr(u(e),r,n)}var Vr=false;re().addEventListener("DOMContentLoaded",function(){Vr=true});function jr(e){if(Vr||re().readyState==="complete"){e()}else{re().addEventListener("DOMContentLoaded",e)}}function _r(){if(Q.config.includeIndicatorStyles!==false){re().head.insertAdjacentHTML("beforeend","")}}function zr(){var e=re().querySelector('meta[name="htmx-config"]');if(e){return E(e.content)}else{return null}}function $r(){var e=zr();if(e){Q.config=le(Q.config,e)}}jr(function(){$r();_r();var e=re().body;zt(e);var t=re().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){var t=e.target;var r=ae(t);if(r&&r.xhr){r.xhr.abort()}});const r=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){ar();oe(t,function(e){ce(e,"htmx:restored",{document:re(),triggerEvent:ce})})}else{if(r){r(e)}}};setTimeout(function(){ce(e,"htmx:load",{});e=null},0)});return Q}()}); -------------------------------------------------------------------------------- /netbox_floorplan/tables.py: -------------------------------------------------------------------------------- 1 | import django_tables2 as tables 2 | 3 | from netbox.tables import NetBoxTable 4 | from .models import Floorplan, FloorplanImage 5 | 6 | from dcim.models import Rack 7 | 8 | 9 | class FloorplanImageTable(NetBoxTable): 10 | name = tables.Column( 11 | linkify=True, 12 | ) 13 | 14 | class Meta(NetBoxTable.Meta): 15 | model = FloorplanImage 16 | fields = ( 17 | 'pk', 18 | 'id', 19 | 'name', 20 | 'file' 21 | ) 22 | 23 | 24 | class FloorplanTable(NetBoxTable): 25 | 26 | class Meta(NetBoxTable.Meta): 27 | model = Floorplan 28 | fields = ('pk', 'site', 'location', 29 | 'assigned_image', 'width', 'height') 30 | default_columns = ('pk', 'site', 'location', 31 | 'assigned_image', 'width', 'height') 32 | 33 | 34 | class FloorplanRackTable(NetBoxTable): 35 | 36 | name = tables.LinkColumn() 37 | 38 | actions = tables.TemplateColumn(template_code=""" 39 | Add Rack 40 | 41 | """) 42 | 43 | class Meta(NetBoxTable.Meta): 44 | model = Rack 45 | fields = ('pk', 'name', 'u_height') 46 | default_columns = ('pk', 'name', 'u_height') 47 | row_attrs = { 48 | 'id': lambda record: 'object_rack_{}'.format(record.pk), 49 | } 50 | 51 | 52 | class FloorplanDeviceTable(NetBoxTable): 53 | 54 | name = tables.LinkColumn() 55 | 56 | actions = tables.TemplateColumn(template_code=""" 57 | Add Device 58 | 59 | """) 60 | 61 | class Meta(NetBoxTable.Meta): 62 | model = Rack 63 | fields = ('pk', 'name', 'device_type') 64 | default_columns = ('pk', 'name', 'device_type') 65 | row_attrs = { 66 | 'id': lambda record: 'object_device_{}'.format(record.pk), 67 | } 68 | -------------------------------------------------------------------------------- /netbox_floorplan/templates/netbox_floorplan/floorplan.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | {% load render_table from django_tables2 %} 3 | 4 | 5 | 6 | {% block content %} 7 |
8 |
9 |
10 |
Fabric
11 |
12 |
13 | {% render_table table 'inc/table.html' %} 14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
Free Fabric Ports
22 |
23 | 24 | 25 | {% for host, obj in object.get_free_leaf_port_count.items %} 26 | 27 | 28 | 29 | 30 | {% endfor %} 31 |
HostFree Ports
{{ host }}{{ obj }}
32 |
33 |
34 |
35 | 36 | 37 | {% endblock content %} -------------------------------------------------------------------------------- /netbox_floorplan/templates/netbox_floorplan/floorplan_edit.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/_base.html' %} 2 | {% load static %} 3 | {% load form_helpers %} 4 | {% load helpers %} 5 | {% load django_htmx %} 6 | 7 | {% block head %} 8 | {{ block.super }} 9 | 10 | 11 | 12 | 13 | {% endblock head %} 14 | 15 | {% block title %} 16 | {{ obj }} 17 | {% endblock title %} 18 | 19 | {% block tabs %} 20 | 28 | {% endblock tabs %} 29 | 30 | {% block content %} 31 | {% load template_utils %} 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
Controls
51 |
52 | {% include 'netbox_floorplan/inc/pro_tips.html' %} 53 |
54 |
55 |
56 | 57 |
58 | Set Dimensions 60 | 61 |
62 |
63 |
64 | 65 |
66 | Set/Edit Background 68 | 69 |
70 |
71 |
72 | 73 |
74 | 87 |
88 |
90 |
91 |
93 |
94 |
95 |
96 |
98 |
99 |
101 |
102 |
103 |
104 |
105 |
106 | 107 |
108 | Add 109 | Wall 110 | 111 | Add 112 | Area 113 | 114 | 115 | Add label 116 | 117 | Lock/Unlock Object 118 | 119 | 121 | Delete 122 | 123 | 124 | 125 |
126 | 133 |
134 |
135 | 136 |
137 | Forward 139 | 140 | 142 | Backwards 143 | 144 |
145 |
146 |
147 | 148 |
149 | 151 | 152 | Reset Zoom 153 | 154 | 156 | Center 157 | 158 |
159 |
160 |
161 | 162 |
163 | 164 | Save 165 | 166 | 167 | Export SVG 168 | 169 |
170 |
171 |
172 |
173 |
174 | 175 |
176 |
177 |
178 |
179 | 180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 | 190 | 228 | 229 | 252 | 253 | 254 | 255 | {% endblock content %} -------------------------------------------------------------------------------- /netbox_floorplan/templates/netbox_floorplan/floorplan_list.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends 'generic/object.html' %} 3 | {% load render_table from django_tables2 %} 4 | {% load django_htmx %} 5 | 6 | {% block content %} 7 |
8 |
9 |
10 | 11 |
Floorplans
12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 |
Name{{ object.name }}
20 |
21 |
22 | {% include 'inc/panels/custom_fields.html' %} 23 |
24 |
25 | {% include 'inc/panels/tags.html' %} 26 | {% include 'inc/panels/comments.html' %} 27 |
28 |
29 | {% endblock content %} 30 | 31 | -------------------------------------------------------------------------------- /netbox_floorplan/templates/netbox_floorplan/floorplan_ui_listview.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/_base.html' %} 2 | {% load helpers %} 3 | {% load plugins %} 4 | {% load render_table from django_tables2 %} 5 | {% load static %} 6 | {% load i18n %} 7 | 8 | {% block content %} 9 | {% render_table table 'inc/table.html' %} 10 | {% endblock %} -------------------------------------------------------------------------------- /netbox_floorplan/templates/netbox_floorplan/floorplan_view.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | {% load helpers %} 3 | {% load plugins %} 4 | {% load tz %} 5 | {% load static %} 6 | {% load django_htmx %} 7 | {% block head %} 8 | {{ block.super }} 9 | 10 | 11 | 12 | 13 | {% endblock head %} 14 | 15 | 16 | {% block breadcrumbs %} 17 | {{ block.super }} 18 | {% if object.region %} 19 | {% for region in object.region.get_ancestors %} 20 | 21 | {% endfor %} 22 | {% elif object.group %} 23 | {% for group in object.group.get_ancestors %} 24 | 25 | {% endfor %} 26 | 28 | {% endif %} 29 | {% endblock %} 30 | 31 | {% block content %} 32 | 45 | 46 |
47 |
48 |
49 |
50 |
51 |
52 | {% if floorplan is none %} 53 | 55 | 56 | Add Floorplan 57 | 58 | {% else %} 59 | 60 | 61 | Export SVG 62 | 63 | 64 | 65 | Edit Floorplan 66 | 67 | 70 | 71 | Delete Floorplan 72 | 73 | {% endif %} 74 |
75 |
76 |
77 |
78 |
79 | 80 |
81 |
82 |
83 | 84 |
85 |
86 |
87 |
88 | {% include 'netbox_floorplan/inc/pro_tips.html' %} 89 |
90 |
91 |
92 |
93 | 94 | 95 | 96 | {% endblock content %} -------------------------------------------------------------------------------- /netbox_floorplan/templates/netbox_floorplan/floorplanimage.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | {% load helpers %} 3 | {% load humanize %} 4 | {% load plugins %} 5 | 6 | 7 | {% block content %} 8 |
9 |
10 |
11 |
Asset
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {% if object.external_url %} 22 | 23 | 24 | 25 | 26 | {% else %} 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {% endif %} 36 |
id{{ object.id }}
Name{{ object.name|placeholder }}
External URL{{ object.external_url }}
Filename{{ object.filename }}
Size{{ object.size|filesizeformat }}
37 |
38 | {% include 'inc/panels/custom_fields.html' %} 39 |
40 | {% include 'inc/panels/tags.html' %} 41 | {% include 'inc/panels/comments.html' %} 42 |
43 | 44 |
45 |
46 | {% plugin_full_width_page object %} 47 |
48 |
49 | {% endblock content %} -------------------------------------------------------------------------------- /netbox_floorplan/templates/netbox_floorplan/floorplanimage_edit.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object_edit.html' %} 2 | {% load static %} 3 | {% load form_helpers %} 4 | {% load helpers %} 5 | 6 | {% block form %} 7 |
8 | {% render_field form.name %} 9 |
10 |
11 |
12 | 24 |
25 |
26 |
27 |
28 | {% render_field form.file %} 29 |
30 |
31 | {% render_field form.external_url %} 32 |
33 |
34 | 35 |
36 |
37 |
Comments
38 |
39 | {% render_field form.comments %} 40 |
41 | 42 | {% render_field form.tags %} 43 |
44 |
45 | {% endblock %} 46 | -------------------------------------------------------------------------------- /netbox_floorplan/templates/netbox_floorplan/inc/pro_tips.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | 8 |

9 |
10 |
11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 24 | 25 | 26 | 27 | 30 | 31 | 32 | 33 | 36 | 37 | 38 | 39 | 42 | 43 | 44 | 45 | 48 | 49 | 50 | 51 |
ActionDescription
Pan
22 | ALT + LEFT MOUSE CLICK + DRAG 23 |
Pans window
Zoom in/out
28 | MOUSE WHEEL IN/OUT 29 |
Zooms in/out
Move Object (Down/Up/Left/Right)
34 | (Down/Up/Left/Right) Arrow 35 |
Moves Selected in the given direction
Rotate Object (Down/Up/Left/Right)
40 | SHIFT + (Down/Up/Left/Right) Arrow 41 |
Rotates Selected in the given direction
Delete Object
46 | DEL 47 |
Deletes Selected Object
52 |
53 |
54 |
55 |
-------------------------------------------------------------------------------- /netbox_floorplan/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbox-community/netbox-floorplan-plugin/68cef7fd35350a4fad427670285c62000dffc1e9/netbox_floorplan/templatetags/__init__.py -------------------------------------------------------------------------------- /netbox_floorplan/templatetags/template_utils.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | @register.simple_tag() 7 | def denormalize_measurement(unit, value): 8 | # print(unit, value) 9 | 10 | if unit == 'ft': 11 | return round( 12 | (round(float(value), 2) * 3.28 / 100), 13 | 2 14 | ) 15 | else: 16 | return round( 17 | (float(value) / 100), 18 | 2 19 | ) 20 | -------------------------------------------------------------------------------- /netbox_floorplan/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | from . import models, views 3 | from netbox.views.generic import ObjectChangeLogView 4 | from utilities.urls import get_model_urls 5 | 6 | urlpatterns = ( 7 | path('floorplans/', views.FloorplanListView.as_view(), name='floorplan_list'), 8 | path('floorplans/racks/', views.FloorplanRackListView.as_view(), name='floorplan_rack_list'), 9 | path('floorplans/devices/', views.FloorplanDeviceListView.as_view(), name='floorplan_device_list'), 10 | path('floorplans/add/', views.FloorplanAddView.as_view(), name='floorplan_add'), 11 | path('floorplans//edit/', views.FloorplanMapEditView.as_view(), name='floorplan_edit'), 12 | path('floorplans//delete/', views.FloorplanDeleteView.as_view(), name='floorplan_delete'), 13 | path('floorplans//changelog/', ObjectChangeLogView.as_view(), name='floorplan_changelog', kwargs={'model': models.Floorplan}), 14 | 15 | # Community 16 | path( 17 | "floorplans/floorplanimages/", 18 | include(get_model_urls("netbox_floorplan", "floorplanimage", detail=False)), 19 | ), 20 | path( 21 | "floorplans/floorplanimages//", 22 | include(get_model_urls("netbox_floorplan", "floorplanimage")), 23 | ), 24 | ) 25 | -------------------------------------------------------------------------------- /netbox_floorplan/utils.py: -------------------------------------------------------------------------------- 1 | def file_upload(instance, filename): 2 | """ 3 | Return a path for uploading image attchments. 4 | Adapted from netbox/extras/utils.py 5 | """ 6 | path = 'netbox-floorplan/' 7 | 8 | return f'{path}{filename}' 9 | -------------------------------------------------------------------------------- /netbox_floorplan/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.7.0" 2 | -------------------------------------------------------------------------------- /netbox_floorplan/views.py: -------------------------------------------------------------------------------- 1 | from netbox.views import generic 2 | from . import forms, models, tables 3 | from dcim.models import Site, Rack, Device, Location 4 | from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin 5 | from django.views import View 6 | from django.shortcuts import render, redirect 7 | from django.db.models import Q 8 | 9 | 10 | from utilities.views import ViewTab, register_model_view 11 | 12 | 13 | @register_model_view(Site, name='floorplans') 14 | class FloorplanSiteTabView(generic.ObjectView): 15 | queryset = Site.objects.all() 16 | 17 | tab = ViewTab( 18 | label='Floor Plan', 19 | hide_if_empty=False, 20 | permission="netbox_floorplan.view_floorplan", 21 | ) 22 | template_name = "netbox_floorplan/floorplan_view.html" 23 | 24 | def get_extra_context(self, request, instance): 25 | floorplan_qs = models.Floorplan.objects.filter( 26 | site=instance.id).first() 27 | if floorplan_qs: 28 | floorplan_qs.resync_canvas() 29 | return {"floorplan": floorplan_qs, "record_type": "site"} 30 | else: 31 | return {"floorplan": None, "record_type": "site"} 32 | 33 | 34 | @register_model_view(Location, name='floorplans') 35 | class FloorplanLocationTabView(generic.ObjectView): 36 | queryset = Location.objects.all() 37 | 38 | tab = ViewTab( 39 | label="Floor Plan", 40 | hide_if_empty=False, 41 | permission="netbox_floorplan.view_floorplan", 42 | ) 43 | template_name = "netbox_floorplan/floorplan_view.html" 44 | 45 | def get_extra_context(self, request, instance): 46 | floorplan_qs = models.Floorplan.objects.filter( 47 | location=instance.id).first() 48 | if floorplan_qs: 49 | floorplan_qs.resync_canvas() 50 | return {"floorplan": floorplan_qs, "record_type": "location"} 51 | else: 52 | return {"floorplan": None, "record_type": "location"} 53 | 54 | 55 | class FloorplanListView(generic.ObjectListView): 56 | queryset = models.Floorplan.objects.all() 57 | table = tables.FloorplanTable 58 | template_name = "netbox_floorplan/floorplan_ui_listview.html" 59 | 60 | 61 | class FloorplanAddView(PermissionRequiredMixin, View): 62 | permission_required = "netbox_floorplan.add_floorplan" 63 | 64 | def get(self, request): 65 | if request.GET.get("site"): 66 | id = request.GET.get("site") 67 | instance = models.Floorplan(site=Site.objects.get(id=id)) 68 | instance.save() 69 | return redirect("plugins:netbox_floorplan:floorplan_edit", pk=instance.id) 70 | elif request.GET.get("location"): 71 | id = request.GET.get("location") 72 | instance = models.Floorplan( 73 | location=Location.objects.get(id=id)) 74 | instance.save() 75 | return redirect("plugins:netbox_floorplan:floorplan_edit", pk=instance.id) 76 | 77 | 78 | class FloorplanDeleteView(generic.ObjectDeleteView): 79 | queryset = models.Floorplan.objects.all() 80 | 81 | 82 | class FloorplanMapEditView(LoginRequiredMixin, View): 83 | permission_required = "netbox_floorplan.edit_floorplan" 84 | 85 | def get(self, request, pk): 86 | fp = models.Floorplan.objects.get(pk=pk) 87 | fp.resync_canvas() 88 | site = None 89 | location = None 90 | if fp.record_type == "site": 91 | site = Site.objects.get(id=fp.site.id) 92 | else: 93 | location = Location.objects.get(id=fp.location.id) 94 | racklist = Rack.objects.filter(site=site) 95 | form = forms.FloorplanRackFilterForm 96 | form2 = forms.FloorplanForm 97 | return render(request, "netbox_floorplan/floorplan_edit.html", { 98 | "form": form, 99 | "form2": form2, 100 | "site": site, 101 | "location": location, 102 | "racklist": racklist, 103 | "obj": fp, 104 | "record_type": fp.record_type 105 | }) 106 | 107 | 108 | class FloorplanRackListView(generic.ObjectListView): 109 | queryset = Rack.objects.all() 110 | table = tables.FloorplanRackTable 111 | 112 | def get(self, request): 113 | fp_id = request.GET["floorplan_id"] 114 | fp_instance = models.Floorplan.objects.get(pk=fp_id) 115 | if fp_instance.record_type == "site": 116 | self.queryset = Rack.objects.all().filter(~Q(id__in=fp_instance.mapped_racks)).filter( 117 | site=fp_instance.site.id).order_by("name") 118 | else: 119 | self.queryset = Rack.objects.all().filter(~Q(id__in=fp_instance.mapped_racks)).filter( 120 | location=fp_instance.location.id).order_by("name") 121 | return super().get(request) 122 | 123 | 124 | class FloorplanDeviceListView(generic.ObjectListView): 125 | queryset = Device.objects.all() 126 | table = tables.FloorplanDeviceTable 127 | 128 | def get(self, request): 129 | fp_id = request.GET["floorplan_id"] 130 | fp_instance = models.Floorplan.objects.get(pk=fp_id) 131 | if fp_instance.record_type == "site": 132 | self.queryset = Device.objects.all().filter(~Q(id__in=fp_instance.mapped_devices)).filter( 133 | site=fp_instance.site.id, rack=None).order_by("name") 134 | else: 135 | self.queryset = Device.objects.all().filter(~Q(id__in=fp_instance.mapped_devices)).filter( 136 | location=fp_instance.location.id, rack=None).order_by("name") 137 | return super().get(request) 138 | 139 | 140 | @register_model_view(models.FloorplanImage) 141 | class FloorplanImageView(generic.ObjectView): 142 | queryset = models.FloorplanImage.objects.all() 143 | 144 | 145 | @register_model_view(models.FloorplanImage, "list", path="", detail=False) 146 | class FloorplanImageListView(generic.ObjectListView): 147 | queryset = models.FloorplanImage.objects.all() 148 | table = tables.FloorplanImageTable 149 | 150 | 151 | @register_model_view(models.FloorplanImage, "add", detail=False) 152 | @register_model_view(models.FloorplanImage, "edit") 153 | class FloorplanImageEditView(generic.ObjectEditView): 154 | queryset = models.FloorplanImage.objects.all() 155 | form = forms.FloorplanImageForm 156 | template_name = 'netbox_floorplan/floorplanimage_edit.html' 157 | 158 | 159 | @register_model_view(models.FloorplanImage, "delete") 160 | class FloorplanImageDeleteView(generic.ObjectDeleteView): 161 | queryset = models.FloorplanImage.objects.all() 162 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore=E501,W504 3 | max-line-length=320 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import os.path 3 | 4 | from setuptools import find_packages, setup 5 | 6 | 7 | with open("README.md", "r") as fh: 8 | long_description = fh.read() 9 | 10 | 11 | def read(rel_path): 12 | here = os.path.abspath(os.path.dirname(__file__)) 13 | with codecs.open(os.path.join(here, rel_path), 'r') as fp: 14 | return fp.read() 15 | 16 | 17 | def get_version(rel_path): 18 | for line in read(rel_path).splitlines(): 19 | if line.startswith('__version__'): 20 | delim = '"' if '"' in line else "'" 21 | return line.split(delim)[1] 22 | else: 23 | raise RuntimeError("Unable to find version string.") 24 | 25 | 26 | setup( 27 | name="netbox-floorplan-plugin", 28 | version=get_version('netbox_floorplan/version.py'), 29 | author="Tony Nealon", 30 | author_email="tony@worksystems.co.nz", 31 | description="Netbox Plugin to support graphical floorplans", 32 | long_description=long_description, 33 | long_description_content_type="text/markdown", 34 | url="https://github.com/netbox-community/netbox-floorplan-plugin.git", 35 | license="LGPLv3+", 36 | install_requires=[], 37 | packages=find_packages(), 38 | include_package_data=True, 39 | zip_safe=False, 40 | min_version="4.3.0", 41 | max_version="4.3.99" 42 | ) 43 | --------------------------------------------------------------------------------