[
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: [openwisp]\npatreon: # Replace with a single Patreon username\nopen_collective: # Replace with a single Open Collective username\nko_fi: # Replace with a single Ko-fi username\ntidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel\ncommunity_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry\nliberapay: # Replace with a single Liberapay username\nissuehunt: # Replace with a single IssueHunt username\nlfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry\npolar: # Replace with a single Polar username\nbuy_me_a_coffee: # Replace with a single Buy Me a Coffee username\nthanks_dev: # Replace with a single thanks.dev username\ncustom: [\"https://openwisp.org/sponsorship/\"]\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Open a bug report\ntitle: \"[bug] \"\nlabels: bug\nassignees: \"\"\n---\n\n**Describe the bug**\nA clear and concise description of the bug or unexpected behavior.\n\n**Steps To Reproduce**\nSteps to reproduce the behavior:\n\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**System Informatioon:**\n\n- OS: [e.g. Ubuntu 24.04 LTS]\n- Python Version: [e.g. Python 3.11.2]\n- Django Version: [e.g. Django 4.2.5]\n- Browser and Browser Version (if applicable): [e.g. Chromium v126.0.6478.126]\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: \"[feature] \"\nlabels: enhancement\nassignees: \"\"\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/question.md",
    "content": "---\nname: Question\nabout: Please use the Discussion Forum to ask questions\ntitle: \"[question] \"\nlabels: question\nassignees: \"\"\n---\n\nPlease use the [Discussion Forum](https://github.com/openwisp/django-loci/discussions) to ask questions.\n\nWe will take care of moving the discussion to a more relevant repository if needed.\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where the package manifests are located.\n# Please see the documentation for all configuration options:\n# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file\n\nversion: 2\nupdates:\n  - package-ecosystem: \"pip\" # See documentation for possible values\n    directory: \"/\" # Location of package manifests\n    schedule:\n      interval: \"monthly\"\n    commit-message:\n      prefix: \"[deps] \"\n  - package-ecosystem: \"github-actions\" # Check for GitHub Actions updates\n    directory: \"/\" # The root directory where the Ansible role is located\n    schedule:\n      interval: \"monthly\" # Check for updates weekly\n    commit-message:\n      prefix: \"[ci] \"\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "## Checklist\n\n- [ ] I have read the [OpenWISP Contributing Guidelines](http://openwisp.io/docs/developer/contributing.html).\n- [ ] I have manually tested the changes proposed in this pull request.\n- [ ] I have written new test cases for new code and/or updated existing tests for changes to existing code.\n- [ ] I have updated the documentation.\n\n## Reference to Existing Issue\n\nCloses #<issue-number>.\n\nPlease [open a new issue](https://github.com/openwisp/django-loci/issues/new/choose) if there isn't an existing issue yet.\n\n## Description of Changes\n\nPlease describe these changes.\n\n## Screenshot\n\nPlease include any relevant screenshots.\n"
  },
  {
    "path": ".github/workflows/backport.yml",
    "content": "name: Backport fixes to stable branch\n\non:\n  push:\n    branches:\n      - master\n      - main\n  issue_comment:\n    types: [created]\n\nconcurrency:\n  group: backport-${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: false\n\npermissions:\n  contents: write\n  pull-requests: write\n\njobs:\n  backport-on-push:\n    if: github.event_name == 'push'\n    uses: openwisp/openwisp-utils/.github/workflows/reusable-backport.yml@master\n    with:\n      commit_sha: ${{ github.sha }}\n    secrets:\n      app_id: ${{ secrets.OPENWISP_BOT_APP_ID }}\n      private_key: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }}\n\n  backport-on-comment:\n    if: >\n      github.event_name == 'issue_comment' &&\n      github.event.issue.pull_request &&\n      github.event.issue.pull_request.merged_at != null &&\n      github.event.issue.state == 'closed' &&\n      contains(fromJSON('[\"MEMBER\", \"OWNER\"]'), github.event.comment.author_association) &&\n      startsWith(github.event.comment.body, '/backport')\n    uses: openwisp/openwisp-utils/.github/workflows/reusable-backport.yml@master\n    with:\n      pr_number: ${{ github.event.issue.number }}\n      comment_body: ${{ github.event.comment.body }}\n    secrets:\n      app_id: ${{ secrets.OPENWISP_BOT_APP_ID }}\n      private_key: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }}\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: Django Loci Build\n\non:\n  push:\n    branches:\n      - master\n      - \"1.2\"\n  pull_request:\n    branches:\n      - master\n      - \"1.2\"\n\njobs:\n  build:\n    name: Python==${{ matrix.python-version }} | ${{ matrix.django-version }}\n    runs-on: ubuntu-24.04\n\n    services:\n      redis:\n        image: redis\n        ports:\n          - 6379:6379\n\n    strategy:\n      fail-fast: false\n      matrix:\n        python-version:\n          - \"3.10\"\n          - \"3.11\"\n          - \"3.12\"\n          - \"3.13\"\n        django-version:\n          - django~=4.2.0\n          - django~=5.0.0\n          - django~=5.1.0\n          - django~=5.2.0\n        exclude:\n          # Python 3.13 supported only in Django >=5.1.3\n          - python-version: \"3.13\"\n            django-version: django~=4.2.0\n          - python-version: \"3.13\"\n            django-version: django~=5.0.0\n\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          ref: ${{ github.event.pull_request.head.sha }}\n\n      - name: Cache APT packages\n        uses: actions/cache@v5\n        with:\n          path: /var/cache/apt/archives\n          key: apt-${{ runner.os }}-${{ hashFiles('.github/workflows/ci.yml') }}\n          restore-keys: |\n            apt-${{ runner.os }}-\n\n      - name: Disable man page auto-update\n        run: |\n          echo 'set man-db/auto-update false' | sudo debconf-communicate >/dev/null\n          sudo dpkg-reconfigure man-db\n\n      - name: Set up Python ${{ matrix.python-version }}\n        uses: actions/setup-python@v6\n        with:\n          python-version: ${{ matrix.python-version }}\n          cache: \"pip\"\n          cache-dependency-path: |\n            **/requirements*.txt\n\n      - name: Install Dependencies\n        id: deps\n        run: |\n          sudo apt update\n          sudo apt-get -qq -y install \\\n               sqlite3 \\\n               libsqlite3-dev \\\n               libsqlite3-mod-spatialite \\\n               gdal-bin\n          pip install -U -r requirements-test.txt\n          sudo npm install -g prettier\n          pip install -U -e .\n          pip install -U ${{ matrix.django-version }}\n\n      - name: QA checks\n        run: ./run-qa-checks\n\n      - name: Tests\n        if: ${{ !cancelled() && steps.deps.conclusion == 'success' }}\n        run: ./runtests\n        env:\n          SELENIUM_HEADLESS: 1\n          GECKO_LOG: 1\n\n      - name: Show gecko web driver log on failures\n        if: ${{ failure() }}\n        run: |\n          [ -f geckodriver.log ] && cat geckodriver.log \\\n            || echo \"No gecko web driver log to show\"\n\n      - name: Upload Coverage\n        if: ${{ success() }}\n        uses: coverallsapp/github-action@v2\n        with:\n          parallel: true\n          format: cobertura\n          flag-name: python-${{ matrix.python-version }}-${{ matrix.django-version }}\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n          fail-on-error: false\n\n  coveralls:\n    needs: build\n    runs-on: ubuntu-latest\n    steps:\n      - name: Coveralls Finished\n        uses: coverallsapp/github-action@v2\n        with:\n          parallel-finished: true\n          fail-on-error: false\n"
  },
  {
    "path": ".github/workflows/pypi.yml",
    "content": "name: Publish Python Package to Pypi.org\n\non:\n  release:\n    types: [published]\n\npermissions:\n  id-token: write\n\njobs:\n  pypi-publish:\n    name: Release Python Package on Pypi.org\n    runs-on: ubuntu-latest\n    environment:\n      name: pypi\n      url: https://pypi.org/p/django-loci\n    permissions:\n      id-token: write\n    steps:\n      - uses: actions/checkout@v6\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: \"3.10\"\n      - name: Install dependencies\n        run: |\n          pip install -U pip\n          pip install build\n      - name: Build package\n        run: python -m build\n      - name: Publish package distributions to PyPI\n        uses: pypa/gh-action-pypi-publish@v1.14.0\n"
  },
  {
    "path": ".github/workflows/version-branch.yml",
    "content": "name: Replicate Commits to Version Branch\n\non:\n  push:\n    branches:\n      - master\n\njobs:\n  version-branch:\n    uses: openwisp/openwisp-utils/.github/workflows/reusable-version-branch.yml@master\n    with:\n      module_name: django_loci\n"
  },
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n__pycache__/\n.pytest_cache/\n*.py[cod]\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nenv/\nvenv/\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n/lib/\nlib64/\nparts/\nsdist/\nvar/\n*.egg-info/\n.installed.cfg\n*.egg\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.coverage*\n.cache\nnosetests.xml\ncoverage.xml\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\ntarget/\n\n# editors\n*.komodoproject\n.vscode/\n\n# other\n*.DS_Store*\n*~\n._*\nlocal_settings.py\n*.db\n*.tar.gz\n"
  },
  {
    "path": ".prettierignore",
    "content": "*.min.js\n*.min.css\n"
  },
  {
    "path": "CHANGES.rst",
    "content": "Changelog\n=========\n\nVersion 1.3.0 [unreleased]\n--------------------------\n\nWork in progress.\n\nVersion 1.2.2 [2026-04-16]\n--------------------------\n\nBugfixes\n~~~~~~~~\n\n- Updated higher bound of pillow range due to CVE `#215\n  <https://github.com/openwisp/django-loci/issues/215>`_\n\nVersion 1.2.1 [2026-03-27]\n--------------------------\n\nBugfixes\n~~~~~~~~\n\n- Fixed the width of Leaflet control labels in the map UI `#200\n  <https://github.com/openwisp/django-loci/issues/200>`_.\n- Fixed an issue preventing creation of mobile locations via the Django\n  admin `#207 <https://github.com/openwisp/django-loci/issues/207>`_.\n- Prevented JavaScript errors on pages where the map is not available or\n  the user does not have permission to view it.\n- Restored the default Django admin form-row label width to avoid layout\n  issues.\n\nVersion 1.2.0 [2025-10-23]\n--------------------------\n\nChanges\n~~~~~~~\n\nDependencies\n++++++++++++\n\n- Bumped ``django-leaflet~=0.32.0``.\n- Bumped ``openwisp-utils~=1.2.0``.\n- Bumped ``Pillow~=11.3.0``.\n- Added support for Django ``5.x``.\n- Added support for Python ``3.11``, ``3.12``, and ``3.13``.\n- Dropped support for Django ``3.2`` and ``4.1``.\n- Dropped support for Python ``3.8``.\n\nBugfixes\n~~~~~~~~\n\n- Added address field to real-time location updates `#169\n  <https://github.com/openwisp/django-loci/issues/169>`_.\n\nVersion 1.1.4 [2025-08-01]\n--------------------------\n\nBugfixes\n~~~~~~~~\n\n- Fixed ``test_add_outdoor_with_floorplan`` which was causing test\n  failures in downstream projects.\n- Refactored formset handling for outdoor locations in the admin\n  interface: moved the logic rejecting floorplans for outdoor locations to\n  ``AbstractLocationAdmin`` to improve reusability and extendability.\n\nVersion 1.1.3 [2025-07-31]\n--------------------------\n\nBugfixes\n~~~~~~~~\n\n- Fixed `loading of map in ObjectLocation admin\n  <https://github.com/openwisp/django-loci/issues/95>`_ when the user only\n  has view permissions.\n- `Fixed error when changing a location from indoor to outdoor\n  <https://github.com/openwisp/django-loci/issues/156>`_. Changing the\n  location type from indoor to outdoor will delete related floorplans.\n  Added confirmation dialog to prevent accidental deletion of floorplans.\n- Avoided underlining Leaflet controls in the admin interface.\n- Fixed import of ``FileSystemStorage`` for compatibility with different\n  Django versions.\n- Fixed `JavaScript SyntaxError: redeclaration of const withForms\n  <https://github.com/makinacorpus/django-leaflet/issues/389>`_ by\n  overriding ``leaflet.draw.i18n.js`` in django-loci and wrapping the\n  ``withForms`` declaration in a block scope.\n\nVersion 1.1.2 [2025-01-27]\n--------------------------\n\n- Refactored code to move logic to helper methods in\n  AbstractObjectLocationForm\n\nVersion 1.1.1 [2024-11-20]\n--------------------------\n\n- [deps] Updated django-leaflet to ~=0.31.0.\n\nVersion 1.1.0 [2024-08-16]\n--------------------------\n\nChanges\n~~~~~~~\n\n- Use ``settings.DEFAULT_STORAGE_CLASS`` as base for OverwriteStorage,\n  adapting the storage backend to project settings.\n\n**Dependencies:**\n\n- Bumped ``django-leaflet~=0.30.1``\n- Bumped ``Pillow~=10.4.0``\n- Bumped ``geopy~=2.4.1``\n- Bumped ``openwisp-utils~=1.1.0``\n- Added support for Python ``3.10``.\n- Added support for Django ``4.2``.\n- Dropped support for Python ``3.7``.\n- Dropped support for Django ``3.0.x``, ``3.1.x`` and ``4.0.x``.\n\nBugfixes\n~~~~~~~~\n\n- Fixed an issue with deleting ``FloorPlan.image`` by using the\n  appropriate storage backend method.\n- Resolved a bug causing outdoor locations to incorrectly appear in the\n  location list when creating floorplans.\n\nVersion 1.0.1 [2022-04-20]\n--------------------------\n\nBugfixes\n~~~~~~~~\n\n- Updated Pillow to ~=9.1.0 to fix a security CVE\n- Fixed channels deprecation warning\n\nVersion 1.0.0 [2022-02-25]\n--------------------------\n\nChanges\n~~~~~~~\n\n- Converted geocoding test to check `#90\n  <https://github.com/openwisp/django-loci/issues/90>`_\n- Use ``ReconnectingWebsocket`` to websocket connection `#101\n  <https://github.com/openwisp/django-loci/issues/101>`_\n- Dropped support for Python ``3.6``\n- Added support for Python ``3.8`` and ``3.9``\n- Added support for Django ``3.2.x`` and ``4.0.x``\n- Migrated to ``channels~=3.0.4``\n- Bumped ``Pillow~=9.0.0``\n- Bumped ``geopy~=2.2.0``\n- Bumped ``openwisp-utils~=1.0.0``\n- Set lowest django version supported to ``django~=3.0.0``\n\nVersion 0.4.3 [2021-06-29]\n--------------------------\n\n- The dependency on the Pillow library was updated to a recent version\n  which was patched for security vulnerabilities\n- Several other dependencies and test dependencies were updated\n  (django-leaflet, geopy, pytest-django, pytest-asyncio, pytest-cov,\n  responses, openwisp-utils)\n\nVersion 0.4.2 [2021-03-16]\n--------------------------\n\n- Fixed broken UI in inline geo selection flow caused by a JS change in\n  django (`issue #85\n  <https://github.com/openwisp/django-loci/issues/85>`_)\n\nVersion 0.4.1 [2021-02-24]\n--------------------------\n\nBugfixes\n~~~~~~~~\n\n- Fixed the ``DJANGO_LOCI_GEOCODE_STRICT_TEST`` setting, which internally\n  was using a different name, therefore the documented setting was not\n  working\n\nVersion 0.4.0 [2020-11-19]\n--------------------------\n\nFeatures\n~~~~~~~~\n\n- [ux] Automatically fetch map coordinates from address field and vice\n  versa + configurable geocoding\n\nChanges\n~~~~~~~\n\n- [deps] Increased Pillow range to allow new 8.0.0 version\n- [deps] Updated openwisp-utils version range to support 0.6 and 0.7\n\nBugfixes\n~~~~~~~~\n\n- [fix] Fixed integrity error in ``floorplan.floor`` when\n  ``is_mobile=True``\n- [fix] Fixed corner case involving restoring ``is_mobile=False``\n\nVersion 0.3.4 [2020-08-16]\n--------------------------\n\n- [deps] Added support for django 3.1\n- [deps] Updated to openwisp-utils 0.6\n\nVersion 0.3.3 [2020-07-25]\n--------------------------\n\n- [fix] Fixed websocket connect error for location change view\n- [deps] Added support for Pillow~=7.2.0 & openwisp-utils~=0.5.1 and\n  dropped their lower versions\n- [deps] Added support for django-leaflet version 0.28\n\nVersion 0.3.2 [2020-07-01]\n--------------------------\n\n- [fix] Fixed bug in floorplan fields\n- [fix] Fixed bug which caused geographic map to disappears on narrow\n  screens\n- [fix] Fixed bug in JS logic\n- [change] Allow to create an indoor location without specifying indoor\n  coordinates\n\nVersion 0.3.1 [2020-01-21]\n--------------------------\n\n- Added support to django 3.0, dropped support for django versions older\n  than 2.2\n- [admin] Fixed UX issue with ``is_mobile`` checkbox\n\nVersion 0.3.0 [2020-01-13]\n--------------------------\n\n- Upgraded django-channels to version 2\n- Upgraded dependencies (django, django-leaflet, Pillow)\n- Geometry shouldn't be allowed to be None if not mobile\n- Fixed admin fields hidden by mistake in case of validation errors\n- Fixed type ``KeyError`` exception during form validation\n\nVersion 0.2.1 [2018-09-02]\n--------------------------\n\n- [tests] Removed duplication of definition of floorplan test file\n\nVersion 0.2.0 [2018-02-19]\n--------------------------\n\n- [requirements] Added support for django 2.0\n\nVersion 0.1.1 [2017-12-06]\n--------------------------\n\n- [admin] Reusable foreign_key_raw_id template\n- [js] Added client side validation for indoor position\n- [js] Do not reset indoor form on first load\n- [websockets] Do not attempt connection in location add page\n- [websockets] Automatically determine ws protocol\n\nVersion 0.1.0 [2017-12-02]\n--------------------------\n\n- first release\n"
  },
  {
    "path": "CONTRIBUTING.rst",
    "content": "Please refer to the `OpenWISP Contribution Guidelines\n<https://openwisp.io/docs/stable/developer/contributing.html>`_.\n"
  },
  {
    "path": "LICENSE",
    "content": "Copyright (c) 2017, Federico Capoano\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n* Redistributions of source code must retain the above copyright notice, this\n  list of conditions and the following disclaimer.\n\n* Redistributions in binary form must reproduce the above copyright notice,\n  this list of conditions and the following disclaimer in the documentation\n  and/or other materials provided with the distribution.\n\n* Neither the name of django-loci, openwisp nor the names of its\n  contributors may be used to endorse or promote products derived from\n  this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n"
  },
  {
    "path": "MANIFEST.in",
    "content": "include LICENSE\ninclude README.rst\ninclude CHANGES.rst\ninclude requirements.txt\nrecursive-include django_loci *\nrecursive-exclude * *.pyc\nrecursive-exclude * *.swp\nrecursive-exclude * __pycache__\nrecursive-exclude * *.db\nrecursive-exclude * local_settings.py\n"
  },
  {
    "path": "README.rst",
    "content": "django-loci\n===========\n\n.. image:: https://github.com/openwisp/django-loci/actions/workflows/ci.yml/badge.svg\n    :target: https://github.com/openwisp/django-loci/actions/workflows/ci.yml\n    :alt: CI build status\n\n.. image:: https://coveralls.io/repos/openwisp/django-loci/badge.svg\n    :target: https://coveralls.io/r/openwisp/django-loci\n\n.. image:: https://img.shields.io/librariesio/release/github/openwisp/django-loci\n    :target: https://libraries.io/github/openwisp/django-loci#repository_dependencies\n    :alt: Dependency monitoring\n\n.. image:: https://img.shields.io/gitter/room/nwjs/nw.js.svg?style=flat-square\n    :target: https://gitter.im/openwisp/general\n\n.. image:: https://badge.fury.io/py/django-loci.svg\n    :target: http://badge.fury.io/py/django-loci\n\n.. image:: https://pepy.tech/badge/django-loci\n    :target: https://pepy.tech/project/django-loci\n    :alt: downloads\n\n.. image:: https://img.shields.io/badge/code%20style-black-000000.svg\n    :target: https://pypi.org/project/black/\n    :alt: code style: black\n\n----\n\nReusable django-app for storing GIS and indoor coordinates of objects.\n\n.. image:: https://raw.githubusercontent.com/openwisp/django-loci/master/docs/indoor.png\n    :target: https://raw.githubusercontent.com/openwisp/django-loci/master/docs/indoor.png\n    :alt: Indoor coordinates\n\n.. image:: https://raw.githubusercontent.com/openwisp/django-loci/master/docs/map.png\n    :target: https://raw.githubusercontent.com/openwisp/django-loci/master/docs/map.png\n    :alt: Map coordinates\n\n.. image:: https://raw.githubusercontent.com/openwisp/django-loci/master/docs/mobile.png\n    :target: https://raw.githubusercontent.com/openwisp/django-loci/master/docs/mobile.png\n    :alt: Mobile coordinates\n\n----\n\n.. contents:: **Table of Contents**:\n    :backlinks: none\n    :depth: 3\n\n----\n\nDependencies\n------------\n\n- Python >= 3.10\n- GeoDjango (`see GeoDjango Install Instructions\n  <https://docs.djangoproject.com/en/dev/ref/contrib/gis/install/#requirements>`_)\n- One of the databases supported by GeoDjango\n\nCompatibility Table\n-------------------\n\n=========== ==============\ndjango-loci Python version\n0.2         2.7 or >=3.4\n0.3 - 0.4   >=3.6\n1.0         >=3.7\n1.1         >=3.8\ndev         >=3.10\n=========== ==============\n\nInstall stable version from pypi\n--------------------------------\n\nInstall from pypi:\n\n.. code-block:: shell\n\n    pip install django-loci\n\nInstall development version\n---------------------------\n\nFirst of all, install the dependencies of `GeoDjango\n<https://docs.djangoproject.com/en/4.2/ref/contrib/gis/>`_:\n\n- `Geospatial libraries\n  <https://docs.djangoproject.com/en/4.2/ref/contrib/gis/install/geolibs/>`_\n- `Spatial database\n  <https://docs.djangoproject.com/en/4.2/ref/contrib/gis/install/spatialite/>`_,\n  for development we use Spatialite, a spatial extension of `sqlite\n  <https://www.sqlite.org/index.html>`_\n\nInstall tarball:\n\n.. code-block:: shell\n\n    pip install https://github.com/openwisp/django-loci/tarball/master\n\nAlternatively you can install via pip using git:\n\n.. code-block:: shell\n\n    pip install -e git+git://github.com/openwisp/django-loci#egg=django_loci\n\nIf you want to contribute, install your cloned fork:\n\n.. code-block:: shell\n\n    git clone git@github.com:<your_fork>/django-loci.git\n    cd django_loci\n    python setup.py develop\n\nSetup (integrate in an existing django project)\n-----------------------------------------------\n\nFirst of all, set up your database engine to `one of the spatial databases\nsuppported by GeoDjango\n<https://docs.djangoproject.com/en/4.2/ref/contrib/gis/db-api/#spatial-backends>`_.\n\nAdd ``django_loci`` and its dependencies to ``INSTALLED_APPS`` in the\nfollowing order:\n\n.. code-block:: python\n\n    INSTALLED_APPS = [\n        # ...\n        \"django.contrib.gis\",\n        \"django_loci\",\n        \"django.contrib.admin\",\n        \"leaflet\",\n        \"channels\",\n        # ...\n    ]\n\nConfigure ``CHANNEL_LAYERS`` according to your needs, a sample\nconfiguration can be:\n\n.. code-block:: python\n\n    ASGI_APPLICATION = \"django_loci.channels.asgi.channel_routing\"\n    CHANNEL_LAYERS = {\n        \"default\": {\n            \"BACKEND\": \"channels_redis.core.RedisChannelLayer\",\n            \"CONFIG\": {\n                \"hosts\": [(\"127.0.0.1\", 6379)],\n            },\n        },\n    }\n\nNow run migrations:\n\n.. code-block:: shell\n\n    ./manage.py migrate\n\nTroubleshooting\n---------------\n\nCommon issues and solutions when installing GeoDjango.\n\nUnable to load the SpatiaLite library extension\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nIf you get the following exception:\n\n::\n\n    django.core.exceptions.ImproperlyConfigured: Unable to load the SpatiaLite library extension\n\nYou need to specify the ``SPATIALITE_LIBRARY_PATH`` in your\n``settings.py`` as explained in the `django documentation regarding how to\ninstall and configure spatialte\n<https://docs.djangoproject.com/en/4.2/ref/contrib/gis/install/spatialite/>`_.\n\nIssues with other geospatial libraries\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nPlease refer to the `geodjango documentation on troubleshooting issues\nrelated to geospatial libraries\n<https://docs.djangoproject.com/en/4.2/ref/contrib/gis/install/#library-environment-settings>`_.\n\nSettings\n--------\n\n``LOCI_FLOORPLAN_STORAGE``\n~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n============ ========================================\n**type**:    ``str``\n**default**: ``django_loci.storage.OverwriteStorage``\n============ ========================================\n\nThe django file storage class used for uploading floorplan images.\n\nThe filestorage can be changed to a different one as long as it has an\n``upload_to`` class method which will be passed to\n``FloorPlan.image.upload_to``.\n\nTo understand the details of this statement, take a look at the code of\n`django_loci.storage.OverwriteStorage\n<https://github.com/openwisp/django-loci/blob/master/django_loci/storage.py>`_.\n\n``DJANGO_LOCI_GEOCODER``\n~~~~~~~~~~~~~~~~~~~~~~~~\n\n============ ==========\n**type**:    ``str``\n**default**: ``ArcGIS``\n============ ==========\n\nService used for geocoding and reverse geocoding.\n\nSupported geolocation services:\n\n- ``ArcGIS``\n- ``Nominatim``\n- ``GoogleV3`` (Google Maps v3)\n\n``DJANGO_LOCI_GEOCODE_FAILURE_DELAY``\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n============ =======\n**type**:    ``int``\n**default**: ``1``\n============ =======\n\nAmount of seconds between geocoding retry API calls when geocoding\nrequests fail.\n\n``DJANGO_LOCI_GEOCODE_RETRIES``\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n============ =======\n**type**:    ``int``\n**default**: ``3``\n============ =======\n\nAmount of retry API calls when geocoding requests fail.\n\n``DJANGO_LOCI_GEOCODE_API_KEY``\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n============ ========\n**type**:    ``str``\n**default**: ``None``\n============ ========\n\nAPI key if required (eg: Google Maps).\n\nSystem Checks\n-------------\n\n``geocoding``\n~~~~~~~~~~~~~\n\nUse to check if geocoding is working as expected or not.\n\nRun this checks with:\n\n::\n\n    ./manage.py check --deploy --tag geocoding\n\nExtending django-loci\n---------------------\n\n*django-loci* provides a set of models and admin classes which can be\nimported, extended and reused by third party apps.\n\nTo extend *django-loci*, **you MUST NOT** add it to\n``settings.INSTALLED_APPS``, but you must create your own app (which goes\ninto ``settings.INSTALLED_APPS``), import the base classes of django-loci\nand add your customizations.\n\nExtending models\n~~~~~~~~~~~~~~~~\n\nThis example provides an example of how to extend the base models of\n*django-loci* by adding a relation to another django model named\n`Organization`.\n\n.. code-block:: python\n\n    # models.py of your app\n    from django.db import models\n    from django_loci.base.models import (\n        AbstractFloorPlan,\n        AbstractLocation,\n        AbstractObjectLocation,\n    )\n\n    # the model ``organizations.Organization`` is omitted for brevity\n    # if you are curious to see a real implementation, check out django-organizations\n\n\n    class OrganizationMixin(models.Model):\n        organization = models.ForeignKey(\"organizations.Organization\")\n\n        class Meta:\n            abstract = True\n\n\n    class Location(OrganizationMixin, AbstractLocation):\n        class Meta(AbstractLocation.Meta):\n            abstract = False\n\n        def clean(self):\n            # your own validation logic here...\n            pass\n\n\n    class FloorPlan(OrganizationMixin, AbstractFloorPlan):\n        location = models.ForeignKey(Location)\n\n        class Meta(AbstractFloorPlan.Meta):\n            abstract = False\n\n        def clean(self):\n            # your own validation logic here...\n            pass\n\n\n    class ObjectLocation(OrganizationMixin, AbstractObjectLocation):\n        location = models.ForeignKey(Location, models.PROTECT, blank=True, null=True)\n        floorplan = models.ForeignKey(FloorPlan, models.PROTECT, blank=True, null=True)\n\n        class Meta(AbstractObjectLocation.Meta):\n            abstract = False\n\n        def clean(self):\n            # your own validation logic here...\n            pass\n\nExtending the admin\n~~~~~~~~~~~~~~~~~~~\n\nFollowing the previous `Organization` example, you can avoid duplicating\nthe admin code by importing the base admin classes and registering your\nmodels with them.\n\nBut first you have to change a few settings in your ``settings.py``, these\nare needed in order to load the admin templates and static files of\n*django-loci* even if it's not listed in ``settings.INSTALLED_APPS``.\n\nAdd ``django.forms`` to ``INSTALLED_APPS``, now it should look like the\nfollowing:\n\n.. code-block:: python\n\n    INSTALLED_APPS = [\n        # ...\n        \"django.contrib.gis\",\n        \"django_loci\",\n        \"django.contrib.admin\",\n        #      ↓\n        \"django.forms\",  # <-- add this\n        #      ↑\n        \"leaflet\",\n        \"channels\",\n        # ...\n    ]\n\nNow add ``EXTENDED_APPS`` after ``INSTALLED_APPS``:\n\n.. code-block:: python\n\n    INSTALLED_APPS = [\n        # ...\n    ]\n\n    EXTENDED_APPS = (\"django_loci\",)\n\nAdd ``openwisp_utils.staticfiles.DependencyFinder`` to\n``STATICFILES_FINDERS``:\n\n.. code-block:: python\n\n    STATICFILES_FINDERS = [\n        \"django.contrib.staticfiles.finders.FileSystemFinder\",\n        \"django.contrib.staticfiles.finders.AppDirectoriesFinder\",\n        \"openwisp_utils.staticfiles.DependencyFinder\",\n    ]\n\nAdd ``openwisp_utils.loaders.DependencyLoader`` to ``TEMPLATES``:\n\n.. code-block:: python\n\n    TEMPLATES = [\n        {\n            \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n            \"DIRS\": [],\n            \"OPTIONS\": {\n                \"loaders\": [\n                    \"django.template.loaders.filesystem.Loader\",\n                    \"django.template.loaders.app_directories.Loader\",\n                    # add the following line\n                    \"openwisp_utils.loaders.DependencyLoader\",\n                ],\n                \"context_processors\": [\n                    \"django.template.context_processors.debug\",\n                    \"django.template.context_processors.request\",\n                    \"django.contrib.auth.context_processors.auth\",\n                    \"django.contrib.messages.context_processors.messages\",\n                ],\n            },\n        }\n    ]\n\nLast step, add ``FORM_RENDERER``:\n\n.. code-block:: python\n\n    FORM_RENDERER = \"django.forms.renderers.TemplatesSetting\"\n\nThen you can go ahead and create your ``admin.py`` file following the\nexample below:\n\n.. code-block:: python\n\n    # admin.py of your app\n    from django.contrib import admin\n\n    from django_loci.base.admin import (\n        AbstractFloorPlanAdmin,\n        AbstractFloorPlanForm,\n        AbstractFloorPlanInline,\n        AbstractLocationAdmin,\n        AbstractLocationForm,\n        AbstractObjectLocationForm,\n        AbstractObjectLocationInline,\n    )\n    from django_loci.models import FloorPlan, Location, ObjectLocation\n\n\n    class FloorPlanForm(AbstractFloorPlanForm):\n        class Meta(AbstractFloorPlanForm.Meta):\n            model = FloorPlan\n\n\n    class FloorPlanAdmin(AbstractFloorPlanAdmin):\n        form = FloorPlanForm\n\n\n    class LocationForm(AbstractLocationForm):\n        class Meta(AbstractLocationForm.Meta):\n            model = Location\n\n\n    class FloorPlanInline(AbstractFloorPlanInline):\n        form = FloorPlanForm\n        model = FloorPlan\n\n\n    class LocationAdmin(AbstractLocationAdmin):\n        form = LocationForm\n        inlines = [FloorPlanInline]\n\n\n    class ObjectLocationForm(AbstractObjectLocationForm):\n        class Meta(AbstractObjectLocationForm.Meta):\n            model = ObjectLocation\n\n\n    class ObjectLocationInline(AbstractObjectLocationInline):\n        model = ObjectLocation\n        form = ObjectLocationForm\n\n\n    admin.site.register(FloorPlan, FloorPlanAdmin)\n    admin.site.register(Location, LocationAdmin)\n\nExtending channel consumers\n~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nExtend the channel consumer of django-loci in this way:\n\n.. code-block:: python\n\n    from django_loci.channels.base import BaseLocationBroadcast\n    from ..models import Location  # your own location model\n\n\n    class LocationBroadcast(BaseLocationBroadcast):\n        model = Location\n\nExtend the broadcast consumer for all locations:\n\n.. code-block:: python\n\n    from django_loci.channels.base import BaseCommonLocationBroadcast\n    from ..models import Location  # your own location model\n\n\n    class CommonLocationBroadcast(BaseCommonLocationBroadcast):\n        model = Location\n\nExtending AppConfig\n~~~~~~~~~~~~~~~~~~~\n\nYou may want to reuse the ``AppConfig`` class of *django-loci* too:\n\n.. code-block:: python\n\n    from django_loci.apps import LociConfig\n\n\n    class MyConfig(LociConfig):\n        name = \"myapp\"\n        verbose_name = _(\"My custom app\")\n\n        def __setmodels__(self):\n            from .models import Location\n\n            self.location_model = Location\n\nInstalling for development\n--------------------------\n\nInstall sqlite:\n\n.. code-block:: shell\n\n    sudo apt-get install sqlite3 libsqlite3-dev libsqlite3-mod-spatialite gdal-bin\n\nInstall your forked repo:\n\n.. code-block:: shell\n\n    git clone git://github.com/<your_fork>/django-loci\n    cd django-loci/\n    python setup.py develop\n\nInstall test requirements:\n\n.. code-block:: shell\n\n    pip install -r requirements-test.txt\n\nLaunch Redis:\n\n.. code-block:: shell\n\n    docker compose up -d redis\n\nCreate database:\n\n.. code-block:: shell\n\n    cd tests/\n    ./manage.py migrate\n    ./manage.py createsuperuser\n\nLaunch development server and SMTP debugging server:\n\n.. code-block:: shell\n\n    ./manage.py runserver\n\nYou can access the admin interface at http://127.0.0.1:8000/admin/.\n\nRun tests with (make sure you have the `selenium dependencies\n<https://openwisp.io/docs/dev/utils/developer/test-utilities.html#openwisp-utils-tests-seleniumtestmixin>`_\ninstalled locally first):\n\n.. code-block:: shell\n\n    ./runtests\n\nContributing\n------------\n\nPlease refer to the `OpenWISP Contribution Guidelines\n<https://openwisp.io/docs/stable/developer/contributing.html>`_.\n\nQuestions\n---------\n\nSee `Github Discussions\n<https://github.com/openwisp/django-loci/discussions>`_.\n\nChangelog\n---------\n\nSee `CHANGES\n<https://github.com/openwisp/django-loci/blob/master/CHANGES.rst>`_.\n\nLicense\n-------\n\nSee `LICENSE\n<https://github.com/openwisp/django-loci/blob/master/LICENSE>`_.\n"
  },
  {
    "path": "conftest.py",
    "content": "import pytest\n\n\n@pytest.fixture(scope=\"session\")\ndef django_db_modify_db_settings():\n    \"\"\"used to speed up pytest with django\"\"\"\n    pass\n"
  },
  {
    "path": "django_loci/__init__.py",
    "content": "VERSION = (1, 3, 0, \"alpha\")\n__version__ = VERSION  # alias\n\n\ndef get_version():\n    version = \"%s.%s\" % (VERSION[0], VERSION[1])\n    if VERSION[2]:\n        version = \"%s.%s\" % (version, VERSION[2])\n    if VERSION[3:] == (\"alpha\", 0):\n        version = \"%s pre-alpha\" % version\n    else:\n        if VERSION[3] != \"final\":\n            try:\n                rev = VERSION[4]\n            except IndexError:\n                rev = 0\n            version = \"%s%s%s\" % (version, VERSION[3][0:1], rev)\n    return version\n"
  },
  {
    "path": "django_loci/admin.py",
    "content": "from django.contrib import admin\n\nfrom .base.admin import (\n    AbstractFloorPlanAdmin,\n    AbstractFloorPlanForm,\n    AbstractFloorPlanInline,\n    AbstractLocationAdmin,\n    AbstractLocationForm,\n    AbstractObjectLocationForm,\n    AbstractObjectLocationInline,\n)\nfrom .models import FloorPlan, Location, ObjectLocation\n\n\nclass FloorPlanForm(AbstractFloorPlanForm):\n    class Meta(AbstractFloorPlanForm.Meta):\n        model = FloorPlan\n\n\nclass FloorPlanAdmin(AbstractFloorPlanAdmin):\n    form = FloorPlanForm\n\n\nclass LocationForm(AbstractLocationForm):\n    class Meta(AbstractLocationForm.Meta):\n        model = Location\n\n\nclass FloorPlanInline(AbstractFloorPlanInline):\n    form = FloorPlanForm\n    model = FloorPlan\n\n\nclass LocationAdmin(AbstractLocationAdmin):\n    form = LocationForm\n    inlines = [FloorPlanInline]\n\n\nclass ObjectLocationForm(AbstractObjectLocationForm):\n    class Meta(AbstractObjectLocationForm.Meta):\n        model = ObjectLocation\n\n\nclass ObjectLocationInline(AbstractObjectLocationInline):\n    model = ObjectLocation\n    form = ObjectLocationForm\n\n\nadmin.site.register(FloorPlan, FloorPlanAdmin)\nadmin.site.register(Location, LocationAdmin)\n"
  },
  {
    "path": "django_loci/apps.py",
    "content": "import logging\n\nfrom django.apps import AppConfig\nfrom django.conf import settings\nfrom django.core.checks import Warning, register\nfrom django.utils.translation import gettext_lazy as _\n\nfrom .base.geocoding_views import geocode\nfrom .channels.receivers import load_location_receivers\n\nlogger = logging.getLogger(__name__)\n\n\n@register(\"geocoding\", deploy=True)\ndef test_geocoding(app_configs=None, **kwargs):\n    warnings = []\n    # do not run check during development, testing or if feature is disabled\n    if not settings.DEBUG or not getattr(settings, \"TESTING\", False):\n        location = geocode(\"Red Square\")\n        if not location:\n            warnings.append(\n                Warning(\n                    \"Geocoding service is experiencing issues or is not properly configured\"\n                )\n            )\n    return warnings\n\n\nclass LociConfig(AppConfig):\n    name = \"django_loci\"\n    verbose_name = _(\"django-loci\")\n    default_auto_field = \"django.db.models.AutoField\"\n\n    def __setmodels__(self):\n        \"\"\"\n        this method can be overridden in 3rd party apps\n        \"\"\"\n        from .models import Location\n\n        self.location_model = Location\n\n    def ready(self):\n        import leaflet\n\n        leaflet.app_settings[\"NO_GLOBALS\"] = False\n        self.__setmodels__()\n        self._load_receivers()\n\n    def _load_receivers(self):\n        load_location_receivers(sender=self.location_model)\n"
  },
  {
    "path": "django_loci/base/__init__.py",
    "content": ""
  },
  {
    "path": "django_loci/base/admin.py",
    "content": "import json\nfrom functools import partialmethod\n\nfrom django import forms\nfrom django.contrib import admin\nfrom django.contrib.admin import widgets\nfrom django.contrib.admin.sites import site\nfrom django.contrib.contenttypes.admin import GenericStackedInline\nfrom django.core.exceptions import ValidationError\nfrom django.http import JsonResponse\nfrom django.shortcuts import get_object_or_404\nfrom django.urls import path\nfrom django.utils.functional import cached_property\nfrom django.utils.translation import gettext_lazy as _\nfrom leaflet.admin import LeafletGeoAdmin\n\nfrom openwisp_utils.admin import TimeReadonlyAdminMixin\n\nfrom ..base.geocoding_views import geocode_view, reverse_geocode_view\nfrom ..fields import GeometryField\nfrom ..widgets import FloorPlanWidget, ImageWidget\nfrom .models import AbstractFloorPlan, AbstractLocation\n\n\nclass ReadOnlyMixin:\n    \"\"\"Mixin for forms to handle field widgets for view-only users.\"\"\"\n\n    def set_readonly_attribute(self, user, fields):\n        \"\"\"\n        This method sets the read_only attribute on widget for the fields\n        which are required to be rendered as it is to view-only users. This is\n        done as 'AdminReadonlyField' renders the widget if 'read_only' is set on\n        the field's widget. Also the required field must be present in self.fields\n        \"\"\"\n        app_label = self.Meta.model._meta.app_label\n        model_name = self.Meta.model._meta.model_name\n        if (\n            user\n            and user.has_perm(f\"{app_label}.view_{model_name}\")\n            and not user.has_perm(f\"{app_label}.change_{model_name}\")\n        ):\n            for field in fields:\n                if field in self.fields:\n                    setattr(self.fields[field].widget, \"read_only\", True)\n            # Return 'True' to allow any further handling for view-only users\n            return True\n        return False\n\n\nclass AbstractFloorPlanForm(ReadOnlyMixin, forms.ModelForm):\n    # define the image field to add it in self.fields\n    # to render it for view-only\n    image = forms.ImageField(\n        widget=ImageWidget(),\n        help_text=AbstractFloorPlan._meta.get_field(\"image\").help_text,\n    )\n\n    class Meta:\n        exclude = tuple()\n\n    class Media:\n        css = {\"all\": (\"django-loci/css/loci.css\",)}\n\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, **kwargs)\n        if getattr(self, \"_user\", None):\n            self.set_readonly_attribute(self._user, [\"image\"])\n            # user is set on Form class which gets instantiated for each request\n            del self.__class__._user\n\n\nclass LocationRawIdWidget(widgets.ForeignKeyRawIdWidget):\n    \"\"\"\n    When selecting a location object\n    via a popup window in the floorplan\n    admin add view, display only indoor locations\n    \"\"\"\n\n    def url_parameters(self):\n        url_params = super().url_parameters()\n        url_params[\"type__exact\"] = \"indoor\"\n        return url_params\n\n\nclass AbstractFloorPlanAdmin(TimeReadonlyAdminMixin, admin.ModelAdmin):\n    list_display = [\"__str__\", \"location\", \"floor\", \"created\", \"modified\"]\n    list_select_related = [\"location\"]\n    search_fields = [\"location__name\"]\n    raw_id_fields = [\"location\"]\n    save_on_top = True\n\n    def get_form(self, request, obj=None, **kwargs):\n        form = super(AbstractFloorPlanAdmin, self).get_form(request, obj, **kwargs)\n        permissions = self.get_model_perms(request)\n        # location field is not in base_fields if user has only view-only permission\n        if permissions[\"add\"] and permissions[\"change\"] and permissions[\"delete\"]:\n            form.base_fields[\"location\"].widget = LocationRawIdWidget(\n                rel=self.model._meta.get_field(\"location\").remote_field, admin_site=site\n            )\n        # pass user to form for handling permissions for readonly view\n        form._user = request.user\n        return form\n\n\nclass AbstractLocationForm(ReadOnlyMixin, forms.ModelForm):\n    # define the geometry field to add it in self.fields\n    # to render it for view-only\n    geometry = GeometryField(required=False)\n\n    class Meta:\n        exclude = tuple()\n\n    class Media:\n        js = (\n            \"admin/js/jquery.init.js\",\n            \"django-loci/js/loci.js\",\n            \"django-loci/js/floorplan-inlines.js\",\n            \"django-loci/js/vendor/reconnecting-websocket.min.js\",\n        )\n        css = {\"all\": (\"django-loci/css/loci.css\",)}\n\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, **kwargs)\n        if getattr(self, \"_user\", None):\n            self.set_readonly_attribute(self._user, [\"geometry\"])\n            # user is set on Form class which gets instantiated for each request\n            del self.__class__._user\n\n\nclass AbstractFloorPlanInline(TimeReadonlyAdminMixin, admin.StackedInline):\n    extra = 0\n    ordering = (\"floor\",)\n\n\nclass AbstractLocationAdmin(TimeReadonlyAdminMixin, LeafletGeoAdmin):\n    list_display = [\"name\", \"short_type\", \"is_mobile\", \"created\", \"modified\"]\n    search_fields = [\"name\", \"address\"]\n    list_filter = [\"type\", \"is_mobile\"]\n    save_on_top = True\n\n    # This allows apps which extend django-loci to load this template with less hacks\n    change_form_template = \"admin/django_loci/location_change_form.html\"\n\n    # override get_form method to pass user to form\n    # for handling permissions for readonly view\n    def get_form(self, request, obj=None, **kwargs):\n        form = super().get_form(request, obj, **kwargs)\n        form._user = request.user\n        return form\n\n    def get_urls(self):\n        # hardcoding django_loci as the prefix for the\n        # view names makes it much easier to extend\n        # without having to change templates\n        app_label = \"django_loci\"\n        return [\n            path(\n                \"<uuid:pk>/json/\",\n                self.admin_site.admin_view(self.json_view),\n                name=\"{0}_location_json\".format(app_label),\n            ),\n            path(\n                \"<uuid:pk>/floorplans/json/\",\n                self.admin_site.admin_view(self.floorplans_json_view),\n                name=\"{0}_location_floorplans_json\".format(app_label),\n            ),\n            path(\n                \"geocode/\",\n                self.admin_site.admin_view(geocode_view),\n                name=\"{0}_location_geocode_api\".format(app_label),\n            ),\n            path(\n                \"reverse-geocode/\",\n                self.admin_site.admin_view(reverse_geocode_view),\n                name=\"{0}_location_reverse_geocode_api\".format(app_label),\n            ),\n        ] + super().get_urls()\n\n    def json_view(self, request, pk):\n        instance = get_object_or_404(self.model, pk=pk)\n        return JsonResponse(\n            {\n                \"name\": instance.name,\n                \"type\": instance.type,\n                \"is_mobile\": instance.is_mobile,\n                \"address\": instance.address,\n                \"geometry\": (\n                    json.loads(instance.geometry.json) if instance.geometry else None\n                ),\n            }\n        )\n\n    def floorplans_json_view(self, request, pk):\n        instance = get_object_or_404(self.model, pk=pk)\n        choices = []\n        for floorplan in instance.floorplan_set.all():\n            choices.append(\n                {\n                    \"id\": floorplan.pk,\n                    \"str\": str(floorplan),\n                    \"floor\": floorplan.floor,\n                    \"image\": floorplan.image.url,\n                    \"image_width\": floorplan.image.width,\n                    \"image_height\": floorplan.image.height,\n                }\n            )\n        return JsonResponse({\"choices\": choices})\n\n    def get_formset_kwargs(self, request, obj, inline, prefix):\n        formset_kwargs = super().get_formset_kwargs(request, obj, inline, prefix)\n        # manually set TOTAL_FORMS to 0 if the type is outdoor to avoid floorplan form creation\n        if request.method == \"POST\" and formset_kwargs[\"data\"][\"type\"] == \"outdoor\":\n            formset_kwargs[\"data\"][\"floorplan_set-TOTAL_FORMS\"] = \"0\"\n        return formset_kwargs\n\n\nclass UnvalidatedChoiceField(forms.ChoiceField):\n    \"\"\"\n    skips ChoiceField validation to allow custom options\n    \"\"\"\n\n    def validate(self, value):\n        super(forms.ChoiceField, self).validate(value)\n\n\n_get_field = AbstractLocation._meta.get_field\n\n\nclass AbstractObjectLocationForm(ReadOnlyMixin, forms.ModelForm):\n    FORM_CHOICES = (\n        (\"\", _(\"--- Please select an option ---\")),\n        (\"new\", _(\"New\")),\n        (\"existing\", _(\"Existing\")),\n    )\n    LOCATION_TYPES = (\n        FORM_CHOICES[0],\n        AbstractLocation.LOCATION_TYPES[0],\n        AbstractLocation.LOCATION_TYPES[1],\n    )\n    location_selection = forms.ChoiceField(choices=FORM_CHOICES, required=False)\n    name = forms.CharField(\n        label=_(\"Location name\"),\n        max_length=75,\n        required=False,\n        help_text=_get_field(\"name\").help_text,\n    )\n    address = forms.CharField(max_length=128, required=False)\n    type = forms.ChoiceField(\n        choices=LOCATION_TYPES, required=True, help_text=_get_field(\"type\").help_text\n    )\n    is_mobile = forms.BooleanField(\n        label=_get_field(\"is_mobile\").verbose_name,\n        help_text=_get_field(\"is_mobile\").help_text,\n        required=False,\n    )\n    geometry = GeometryField(required=False)\n    floorplan_selection = forms.ChoiceField(required=False, choices=FORM_CHOICES)\n    floorplan = UnvalidatedChoiceField(\n        choices=((None, FORM_CHOICES[0][1]),), required=False\n    )\n    floor = forms.IntegerField(required=False)\n    image = forms.ImageField(\n        required=False,\n        widget=ImageWidget(thumbnail=False),\n        help_text=_(\"floor plan image\"),\n    )\n    indoor = forms.CharField(\n        max_length=64,\n        required=False,\n        label=_(\"indoor position\"),\n        widget=FloorPlanWidget,\n    )\n\n    class Meta:\n        exclude = tuple()\n\n    class Media:\n        js = (\n            \"admin/js/jquery.init.js\",\n            \"django-loci/js/loci.js\",\n            \"django-loci/js/floorplan-widget.js\",\n            \"django-loci/js/vendor/reconnecting-websocket.min.js\",\n        )\n        css = {\n            \"all\": (\"django-loci/css/loci.css\", \"django-loci/css/floorplan-widget.css\")\n        }\n\n    def __init__(self, *args, **kwargs):\n        # user is passed via partialmethod in ObjectLocationInline\n        user = kwargs.pop(\"user\", None)\n        super().__init__(*args, **kwargs)\n        # set initial values for custom fields\n        initial = {}\n        location = self._get_initial_location()\n        floorplan = self._get_initial_floorplan()\n        if location:\n            initial.update(\n                {\n                    \"location_selection\": \"existing\",\n                    \"type\": location.type,\n                    \"is_mobile\": location.is_mobile,\n                    \"name\": location.name,\n                    \"address\": location.address,\n                    \"geometry\": location.geometry,\n                }\n            )\n        if floorplan:\n            initial.update(\n                {\n                    \"floorplan_selection\": \"existing\",\n                    \"floorplan\": floorplan.pk,\n                    \"floor\": floorplan.floor,\n                    \"image\": floorplan.image,\n                }\n            )\n            floorplan_choices = self.fields[\"floorplan\"].choices\n            self.fields[\"floorplan\"].choices = floorplan_choices + [\n                (floorplan.pk, floorplan)\n            ]\n\n        if self.set_readonly_attribute(user, [\"geometry\", \"image\", \"indoor\"]):\n            # For view only permissions, 'AdminReadonlyField' reads from instance\n            for field, value in initial.items():\n                if field != \"floorplan\":\n                    setattr(self.instance, field, value)\n                else:\n                    setattr(self.instance.floorplan, \"pk\", value)\n            # Added id to indoor widget to display indoor position\n            self.fields[\"indoor\"].widget.attrs.update({\"id\": \"id_indoor\"})\n        self.initial.update(initial)\n\n    def _get_initial_location(self):\n        return self.instance.location\n\n    def _get_initial_floorplan(self):\n        return self.instance.floorplan\n\n    @cached_property\n    def floorplan_model(self):\n        return self.Meta.model.floorplan.field.remote_field.model\n\n    @cached_property\n    def location_model(self):\n        return self.Meta.model.location.field.remote_field.model\n\n    def clean_floorplan(self):\n        floorplan_model = self.floorplan_model\n        type_ = self.cleaned_data.get(\"type\")\n        floorplan_selection = self.cleaned_data.get(\"floorplan_selection\")\n        if type_ != \"indoor\" or floorplan_selection == \"new\" or not floorplan_selection:\n            return None\n        pk = self.cleaned_data[\"floorplan\"]\n        if not pk:\n            raise ValidationError(_(\"No floorplan selected\"))\n        try:\n            fl = floorplan_model.objects.get(pk=pk)\n        except floorplan_model.DoesNotExist:\n            raise ValidationError(_(\"Selected floorplan does not exist\"))\n        if fl.location != self.cleaned_data[\"location\"]:\n            raise ValidationError(\n                _(\"This floorplan is associated to a different location\")\n            )\n        return fl\n\n    def clean(self):\n        data = self.cleaned_data\n        type_ = data.get(\"type\")\n        is_mobile = data[\"is_mobile\"]\n        msg = _(\"this field is required for locations of type %(type)s\")\n        fields = []\n        if not is_mobile and type_ in [\"outdoor\", \"indoor\"]:\n            fields += [\"location_selection\", \"name\", \"address\", \"geometry\"]\n        # sync location, clean indoor field basis type\n        if location := data.get(\"location\"):\n            location.type = type_\n            data[\"indoor\"] = None if type_ != \"indoor\" else data.get(\"indoor\")\n        if type_ == \"indoor\":\n            if data.get(\"floorplan_selection\") == \"existing\":\n                fields.append(\"floorplan\")\n            if data.get(\"image\"):\n                fields += [\"floor\", \"indoor\"]\n        elif is_mobile and not data.get(\"location\"):\n            data[\"name\"] = \"\"\n            data[\"address\"] = \"\"\n            data[\"geometry\"] = \"\"\n            data[\"location_selection\"] = \"new\"\n        for field in fields:\n            if field in data and data[field] in [None, \"\"]:\n                params = {\"type\": type_}\n                err = ValidationError(msg, params=params)\n                self.add_error(field, err)\n\n    def _get_location_instance(self):\n        data = self.cleaned_data\n        location = data.get(\"location\") or self.location_model()\n        location.type = data.get(\"type\") or location.type\n        location.is_mobile = data.get(\"is_mobile\", location.is_mobile)\n        location.name = data.get(\"name\") or location.name\n        location.address = data.get(\"address\") or location.address\n        location.geometry = data.get(\"geometry\") or location.geometry\n        return location\n\n    def _get_floorplan_instance(self):\n        data = self.cleaned_data\n        instance = self.instance\n        floorplan = data.get(\"floorplan\") or self.floorplan_model()\n        floorplan.location = instance.location\n        floorplan.floor = data.get(\"floor\")\n        # the image path is updated only during creation\n        # or if the image has been actually changed\n        if data.get(\"image\") and self.initial.get(\"image\") != data.get(\"image\"):\n            floorplan.image = data[\"image\"]\n        return floorplan\n\n    def save(self, commit=True):\n        instance = self.instance\n        data = self.cleaned_data\n        # create or update location\n        instance.location = self._get_location_instance()\n        # set name of mobile locations automatically\n        if data[\"is_mobile\"] and not instance.location.name:\n            instance.location.name = str(self.instance.content_object)\n        instance.location.save()\n        # create or update floorplan\n        floorplan = self._get_floorplan_instance()\n        if data[\"type\"] == \"indoor\" and floorplan.image:\n            instance.floorplan = floorplan\n            instance.floorplan.save()\n        # call super\n        return super().save(commit=True)\n\n\nclass ObjectLocationMixin(TimeReadonlyAdminMixin):\n    \"\"\"\n    Base ObjectLocationInline logic, can be imported and\n    mixed in with different inline classes (stacked, tabular).\n    If you need the generic inline look below.\n    \"\"\"\n\n    verbose_name = _(\"geographic information\")\n    verbose_name_plural = verbose_name\n    raw_id_fields = (\"location\",)\n    max_num = 1\n    extra = 1\n    template = \"admin/django_loci/location_inline.html\"\n    fieldsets = (\n        (None, {\"fields\": (\"location_selection\",)}),\n        (\n            \"Geographic coordinates\",\n            {\n                \"classes\": (\"loci\", \"coords\"),\n                \"fields\": (\n                    \"location\",\n                    \"type\",\n                    \"is_mobile\",\n                    \"name\",\n                    \"address\",\n                    \"geometry\",\n                ),\n            },\n        ),\n        (\n            \"Indoor coordinates\",\n            {\n                \"classes\": (\"indoor\", \"coords\"),\n                \"fields\": (\n                    \"floorplan_selection\",\n                    \"floorplan\",\n                    \"floor\",\n                    \"image\",\n                    \"indoor\",\n                ),\n            },\n        ),\n    )\n\n    # override get_formset method to pass user to form\n    def get_formset(self, request, obj=..., **kwargs):\n        formset = super().get_formset(request, obj, **kwargs)\n        formset._construct_form = partialmethod(\n            formset._construct_form, user=request.user\n        )\n        return formset\n\n\nclass AbstractObjectLocationInline(ObjectLocationMixin, GenericStackedInline):\n    \"\"\"\n    Generic Inline + ObjectLocationMixin\n    \"\"\"\n"
  },
  {
    "path": "django_loci/base/geocoding_views.py",
    "content": "from django.http import JsonResponse\nfrom django.utils.module_loading import import_string\nfrom geopy.extra.rate_limiter import RateLimiter\n\nfrom ..settings import (\n    DJANGO_LOCI_GEOCODE_API_KEY,\n    DJANGO_LOCI_GEOCODE_FAILURE_DELAY,\n    DJANGO_LOCI_GEOCODE_RETRIES,\n    DJANGO_LOCI_GEOCODER,\n)\n\ngeocoder = import_string(f\"geopy.geocoders.{DJANGO_LOCI_GEOCODER}\")\nif DJANGO_LOCI_GEOCODER != \"GoogleV3\":\n    geolocator = geocoder(user_agent=\"django_loci\")\nelse:\n    geolocator = geocoder(api_key=DJANGO_LOCI_GEOCODE_API_KEY)  # pragma: nocover\ngeocode = RateLimiter(\n    geolocator.geocode,\n    max_retries=DJANGO_LOCI_GEOCODE_RETRIES,\n    error_wait_seconds=DJANGO_LOCI_GEOCODE_FAILURE_DELAY,\n)\nreverse_geocode = RateLimiter(\n    geolocator.reverse,\n    max_retries=DJANGO_LOCI_GEOCODE_RETRIES,\n    error_wait_seconds=DJANGO_LOCI_GEOCODE_FAILURE_DELAY,\n)\n\n\ndef geocode_view(request):\n    address = request.GET.get(\"address\")\n    if address is None:\n        return JsonResponse({\"error\": \"Address parameter not defined\"}, status=400)\n    location = geocode(address)\n    if location is None:\n        return JsonResponse({\"error\": \"Not found location with given name\"}, status=404)\n    return JsonResponse({\"lat\": location.latitude, \"lng\": location.longitude})\n\n\ndef reverse_geocode_view(request):\n    lat = request.GET.get(\"lat\")\n    lng = request.GET.get(\"lng\")\n    if not lat or not lng:\n        return JsonResponse({\"error\": \"lat or lng parameter not defined\"}, status=400)\n    location = reverse_geocode((lat, lng))\n    if location is None:\n        return JsonResponse({\"address\": \"\"}, status=404)\n    # if multiple locations are returned, use the most relevant result\n    location = location[0] if isinstance(location, list) else location\n    address = str(location.address)\n    return JsonResponse({\"address\": address})\n"
  },
  {
    "path": "django_loci/base/models.py",
    "content": "import logging\n\nfrom django.contrib.contenttypes.fields import GenericForeignKey\nfrom django.contrib.contenttypes.models import ContentType\nfrom django.contrib.gis.db import models\nfrom django.contrib.humanize.templatetags.humanize import ordinal\nfrom django.core.exceptions import ValidationError\nfrom django.utils.translation import gettext_lazy as _\n\nfrom openwisp_utils.base import TimeStampedEditableModel\n\nfrom .. import settings as app_settings\n\nlogger = logging.getLogger(__name__)\n\n\nclass AbstractLocation(TimeStampedEditableModel):\n    LOCATION_TYPES = (\n        (\"outdoor\", _(\"Outdoor environment (eg: street, square, garden, land)\")),\n        (\n            \"indoor\",\n            _(\"Indoor environment (eg: building, roofs, subway, large vehicles)\"),\n        ),\n    )\n    name = models.CharField(\n        _(\"name\"),\n        max_length=75,\n        help_text=_(\n            \"A descriptive name of the location \" \"(building name, company name, etc.)\"\n        ),\n    )\n    type = models.CharField(\n        choices=LOCATION_TYPES,\n        max_length=8,\n        db_index=True,\n        help_text=_(\"indoor locations can have floorplans associated to them\"),\n    )\n    is_mobile = models.BooleanField(\n        _(\"is mobile?\"),\n        default=False,\n        db_index=True,\n        help_text=_(\"is this location a moving object?\"),\n    )\n    address = models.CharField(_(\"address\"), db_index=True, max_length=256, blank=True)\n    geometry = models.GeometryField(_(\"geometry\"), blank=True, null=True)\n\n    class Meta:\n        abstract = True\n\n    # overriding __init__ to store the initial type\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, **kwargs)\n        self._initial_type = self.type\n\n    def __str__(self):\n        return self.name\n\n    def clean(self):\n        self._validate_geometry_if_not_mobile()\n\n    def _validate_geometry_if_not_mobile(self):\n        \"\"\"\n        geometry can be NULL, but only if_mobile is True\n        otherwise raise a ValidationError\n        \"\"\"\n        if not self.is_mobile and not self.geometry:\n            raise ValidationError({\"geometry\": _(\"No geometry value provided.\")})\n\n    @property\n    def short_type(self):\n        return _(self.type.capitalize())\n\n    # save method is automatically wrapped in atomic transaction\n    def save(self, *args, **kwargs):\n        # if location type is changed to outdoor, remove all associated floorplans\n        if (\n            self.type != self._initial_type\n            and not self._state.adding\n            and self.type == \"outdoor\"\n            and self.floorplan_set.exists()\n        ):\n            self.objectlocation_set.update(floorplan=None, indoor=None)\n            self.floorplan_set.all().delete()\n        return super().save(*args, **kwargs)\n\n\nclass AbstractFloorPlan(TimeStampedEditableModel):\n    location = models.ForeignKey(\"django_loci.Location\", on_delete=models.CASCADE)\n    floor = models.SmallIntegerField(_(\"floor\"))\n    image = models.ImageField(\n        _(\"image\"),\n        upload_to=app_settings.FLOORPLAN_STORAGE.upload_to,\n        storage=app_settings.FLOORPLAN_STORAGE(),\n        help_text=_(\"floor plan image\"),\n    )\n\n    class Meta:\n        abstract = True\n        unique_together = (\"location\", \"floor\")\n\n    def __str__(self):\n        if self.floor != 0:\n            suffix = _(\"{0} floor\").format(ordinal(self.floor))\n        else:\n            suffix = _(\"ground floor\")\n        return \"{0} {1}\".format(self.location.name, suffix)\n\n    def clean(self):\n        self._validate_location_type()\n\n    def delete(self, *args, **kwargs):\n        super().delete(*args, **kwargs)\n        self._remove_image()\n\n    def _validate_location_type(self):\n        if not hasattr(self, \"location\") or not hasattr(self.location, \"type\"):\n            return\n        if self.location.type and self.location.type != \"indoor\":\n            msg = \"floorplans can only be associated \" 'to locations of type \"indoor\"'\n            raise ValidationError(msg)\n\n    def _remove_image(self):\n        path = self.image.name\n        if self.image.storage.exists(path):\n            self.image.delete(save=False)\n        else:\n            msg = \"floorplan image not found while deleting {0}:\\n{1}\"\n            logger.error(msg.format(self, path))\n\n\nclass AbstractObjectLocation(TimeStampedEditableModel):\n    LOCATION_TYPES = (\n        (\"outdoor\", _(\"Outdoor\")),\n        (\"indoor\", _(\"Indoor\")),\n        (\"mobile\", _(\"Mobile\")),\n    )\n    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)\n    object_id = models.CharField(max_length=36, db_index=True)\n    content_object = GenericForeignKey(\"content_type\", \"object_id\")\n    location = models.ForeignKey(\n        \"django_loci.Location\", models.PROTECT, blank=True, null=True\n    )\n    floorplan = models.ForeignKey(\n        \"django_loci.Floorplan\", models.PROTECT, blank=True, null=True\n    )\n    indoor = models.CharField(\n        _(\"indoor position\"), max_length=64, blank=True, null=True\n    )\n\n    class Meta:\n        abstract = True\n        unique_together = (\"content_type\", \"object_id\")\n\n    def _clean_indoor_location(self):\n        \"\"\"\n        ensures related floorplan is not\n        associated to a different location\n        \"\"\"\n        # skip validation if the instance does not\n        # have a floorplan assigned to it yet\n        if not self.location or self.location.type != \"indoor\" or not self.floorplan:\n            return\n        if self.location != self.floorplan.location:\n            raise ValidationError(\n                _(\"Invalid floorplan (belongs to a different location)\")\n            )\n\n    def _raise_invalid_indoor(self):\n        raise ValidationError({\"indoor\": _(\"invalid value\")})\n\n    def _clean_indoor_position(self):\n        \"\"\"\n        ensures invalid indoor position values\n        cannot be inserted into the database\n        \"\"\"\n        # stop here if location not defined yet\n        # (other validation errors will be triggered)\n        if not self.location:\n            return\n        # do not allow non empty values for outdoor locations\n        if self.location.type != \"indoor\" and self.indoor not in [None, \"\"]:\n            self._raise_invalid_indoor()\n        # allow empty values for outdoor locations\n        elif self.location.type != \"indoor\" and self.indoor in [None, \"\"]:\n            return\n        # allow empty values for indoor whose coordinates are not yet received\n        elif (\n            self.location.type == \"indoor\"\n            and self.indoor in [None, \"\"]\n            and not self.floorplan\n        ):\n            return\n        # split indoor position\n        position = []\n        if self.indoor:\n            position = self.indoor.split(\",\")\n        # must have at least e elements\n        if len(position) != 2:\n            self._raise_invalid_indoor()\n        # each member must be convertible to float\n        else:\n            for part in position:\n                try:\n                    float(part)\n                except ValueError:\n                    self._raise_invalid_indoor()\n\n    def clean(self):\n        self._clean_indoor_location()\n        self._clean_indoor_position()\n"
  },
  {
    "path": "django_loci/channels/__init__.py",
    "content": ""
  },
  {
    "path": "django_loci/channels/asgi.py",
    "content": "from channels.auth import AuthMiddlewareStack\nfrom channels.routing import ProtocolTypeRouter, URLRouter\nfrom channels.security.websocket import AllowedHostsOriginValidator\nfrom django.core.asgi import get_asgi_application\nfrom django.urls import path\n\nfrom django_loci.channels.base import (\n    common_location_broadcast_path,\n    location_broadcast_path,\n)\nfrom django_loci.channels.consumers import CommonLocationBroadcast, LocationBroadcast\n\nchannel_routing = ProtocolTypeRouter(\n    {\n        \"websocket\": AllowedHostsOriginValidator(\n            AuthMiddlewareStack(\n                URLRouter(\n                    [\n                        path(\n                            location_broadcast_path,\n                            LocationBroadcast.as_asgi(),\n                            name=\"LocationChannel\",\n                        ),\n                        path(\n                            common_location_broadcast_path,\n                            CommonLocationBroadcast.as_asgi(),\n                            name=\"AllLocationChannel\",\n                        ),\n                    ]\n                )\n            )\n        ),\n        \"http\": get_asgi_application(),\n    }\n)\n"
  },
  {
    "path": "django_loci/channels/base.py",
    "content": "from asgiref.sync import async_to_sync\nfrom channels.generic.websocket import JsonWebsocketConsumer\nfrom django.core.exceptions import ValidationError\n\nlocation_broadcast_path = \"ws/loci/location/<uuid:pk>/\"\ncommon_location_broadcast_path = \"ws/loci/location/\"\n\n\ndef _get_object_or_none(model, **kwargs):\n    try:\n        return model.objects.get(**kwargs)\n    except (ValidationError, model.DoesNotExist):\n        return None\n\n\nclass BaseLocationBroadcast(JsonWebsocketConsumer):\n    \"\"\"\n    Base WebSocket consumer for broadcasting location coordinate changes\n    to authorized users (superusers or organization operators).\n    \"\"\"\n\n    def connect(self):\n        \"\"\"\n        Handle WebSocket connection: authenticate user, validate location,\n        and join the location-specific broadcast group.\n        \"\"\"\n        self.pk = None\n        try:\n            user = self.scope[\"user\"]\n            self.pk = self.scope[\"url_route\"][\"kwargs\"][\"pk\"]\n        except KeyError:\n            # Will fall here when the scope does not have\n            # one of the variables, most commonly, user\n            # (When a user tries to access without loggin in)\n            self.close()\n        else:\n            location = _get_object_or_none(self.model, pk=self.pk)\n            if not location or not self.is_authorized(user, location):\n                self.close()\n                return\n            self.accept()\n            # Create group name once\n            self.group_name = \"loci.mobile-location.{}\".format(self.pk)\n            async_to_sync(self.channel_layer.group_add)(\n                self.group_name, self.channel_name\n            )\n\n    def is_authorized(self, user, location):\n        \"\"\"\n        Check if the user has permission to receive location broadcasts.\n        Requires authentication and change or view permissions on the location.\n        \"\"\"\n        perm = \"{0}.change_location\".format(self.model._meta.app_label)\n        # allow users with view permission\n        readperm = \"{0}.view_location\".format(self.model._meta.app_label)\n        authenticated = user.is_authenticated\n        is_permitted = user.has_perm(perm) or user.has_perm(readperm)\n        return authenticated and (user.is_superuser or (user.is_staff and is_permitted))\n\n    def send_message(self, event):\n        \"\"\"\n        Send JSON event data to the connected WebSocket client.\n        \"\"\"\n        self.send_json(event[\"message\"])\n\n    def disconnect(self, close_code):\n        \"\"\"\n        Handle cleanup on WebSocket disconnection.\n        \"\"\"\n        # The group_name is set only when the connection is accepted.\n        # Remove the user from the group, if it exists.\n        if hasattr(self, \"group_name\"):\n            async_to_sync(self.channel_layer.group_discard)(\n                self.group_name, self.channel_name\n            )\n\n\nclass BaseCommonLocationBroadcast(BaseLocationBroadcast):\n\n    def connect(self):\n        \"\"\"\n        Override connect to handle subscription to all locations\n        without requiring a specific location PK.\n        \"\"\"\n        try:\n            user = self.scope[\"user\"]\n        except KeyError:\n            self.close()\n        else:\n            if not self.is_authorized(user, None):\n                self.close()\n                return\n            self.accept()\n            self.join_groups(user)\n\n    def join_groups(self, user):\n        \"\"\"\n        Subscribe to broadcast groups.\n        Subclasses can override to add user-specific groups (using the ``user`` argument).\n        \"\"\"\n        self.group_name = \"loci.mobile-location.common\"\n        async_to_sync(self.channel_layer.group_add)(self.group_name, self.channel_name)\n"
  },
  {
    "path": "django_loci/channels/consumers.py",
    "content": "from ..models import Location\nfrom .base import BaseCommonLocationBroadcast, BaseLocationBroadcast\n\n\nclass LocationBroadcast(BaseLocationBroadcast):\n    model = Location\n\n\nclass CommonLocationBroadcast(BaseCommonLocationBroadcast):\n    model = Location\n"
  },
  {
    "path": "django_loci/channels/receivers.py",
    "content": "import json\n\nimport channels.layers\nfrom asgiref.sync import async_to_sync\nfrom django.db.models.signals import post_save\nfrom django.dispatch import receiver\n\n\ndef update_mobile_location(sender, instance, **kwargs):\n    \"\"\"\n    Sends WebSocket updates when a location record is updated.\n    - Sends a message to the location specific group.\n    - Sends a message to a common group for tracking all mobile location updates.\n    \"\"\"\n    if not kwargs.get(\"created\") and instance.geometry:\n        channel_layer = channels.layers.get_channel_layer()\n\n        # Send update to location specific group\n        async_to_sync(channel_layer.group_send)(\n            f\"loci.mobile-location.{instance.pk}\",\n            {\n                \"type\": \"send_message\",\n                \"message\": {\n                    \"geometry\": json.loads(instance.geometry.geojson),\n                    \"address\": instance.address,\n                },\n            },\n        )\n\n        # Send update to common mobile location group\n        async_to_sync(channel_layer.group_send)(\n            \"loci.mobile-location.common\",\n            {\n                \"type\": \"send_message\",\n                \"message\": {\n                    \"id\": str(instance.pk),\n                    \"geometry\": json.loads(instance.geometry.geojson),\n                    \"address\": instance.address,\n                    \"name\": instance.name,\n                    \"type\": instance.type,\n                    \"is_mobile\": instance.is_mobile,\n                },\n            },\n        )\n\n\ndef load_location_receivers(sender):\n    \"\"\"\n    enables signal listening when called\n    designed to be called in AppConfig subclasses\n    \"\"\"\n    # using decorator pattern with old syntax\n    # in order to decorate an existing function\n    receiver(post_save, sender=sender, dispatch_uid=\"ws_update_mobile_location\")(\n        update_mobile_location\n    )\n"
  },
  {
    "path": "django_loci/fields.py",
    "content": "from leaflet.forms.fields import GeometryField as BaseGeometryField\n\nfrom .widgets import LeafletWidget\n\n\nclass GeometryField(BaseGeometryField):\n    widget = LeafletWidget\n"
  },
  {
    "path": "django_loci/migrations/0001_initial.py",
    "content": "# -*- coding: utf-8 -*-\n# Generated by Django 1.11.7 on 2017-11-25 10:09\nimport uuid\n\nimport django.contrib.gis.db.models.fields\nimport django.db.models.deletion\nimport django.utils.timezone\nimport model_utils.fields\nfrom django.db import migrations, models\n\nimport django_loci.storage\n\n\nclass Migration(migrations.Migration):\n    initial = True\n\n    dependencies = [(\"contenttypes\", \"0002_remove_content_type_name\")]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"FloorPlan\",\n            fields=[\n                (\n                    \"id\",\n                    models.UUIDField(\n                        default=uuid.uuid4,\n                        editable=False,\n                        primary_key=True,\n                        serialize=False,\n                    ),\n                ),\n                (\n                    \"created\",\n                    model_utils.fields.AutoCreatedField(\n                        default=django.utils.timezone.now,\n                        editable=False,\n                        verbose_name=\"created\",\n                    ),\n                ),\n                (\n                    \"modified\",\n                    model_utils.fields.AutoLastModifiedField(\n                        default=django.utils.timezone.now,\n                        editable=False,\n                        verbose_name=\"modified\",\n                    ),\n                ),\n                (\"floor\", models.SmallIntegerField(verbose_name=\"floor\")),\n                (\n                    \"image\",\n                    models.ImageField(\n                        help_text=\"floor plan image\",\n                        storage=django_loci.storage.OverwriteStorage(),\n                        upload_to=django_loci.storage.OverwriteStorage.upload_to,\n                        verbose_name=\"image\",\n                    ),\n                ),\n            ],\n        ),\n        migrations.CreateModel(\n            name=\"Location\",\n            fields=[\n                (\n                    \"id\",\n                    models.UUIDField(\n                        default=uuid.uuid4,\n                        editable=False,\n                        primary_key=True,\n                        serialize=False,\n                    ),\n                ),\n                (\n                    \"created\",\n                    model_utils.fields.AutoCreatedField(\n                        default=django.utils.timezone.now,\n                        editable=False,\n                        verbose_name=\"created\",\n                    ),\n                ),\n                (\n                    \"modified\",\n                    model_utils.fields.AutoLastModifiedField(\n                        default=django.utils.timezone.now,\n                        editable=False,\n                        verbose_name=\"modified\",\n                    ),\n                ),\n                (\n                    \"name\",\n                    models.CharField(\n                        help_text=\"A descriptive name of the location (building name, company name, etc.)\",\n                        max_length=75,\n                        verbose_name=\"name\",\n                    ),\n                ),\n                (\n                    \"type\",\n                    models.CharField(\n                        choices=[\n                            (\n                                \"outdoor\",\n                                \"Outdoor environment (eg: street, square, garden, land)\",\n                            ),\n                            (\n                                \"indoor\",\n                                \"Indoor environment (eg: building, roofs, subway, large vehicles)\",\n                            ),\n                        ],\n                        db_index=True,\n                        help_text=\"indoor locations can have floorplans associated to them\",\n                        max_length=8,\n                    ),\n                ),\n                (\n                    \"is_mobile\",\n                    models.BooleanField(\n                        db_index=True,\n                        default=False,\n                        help_text=\"is this location a moving object?\",\n                        verbose_name=\"is mobile?\",\n                    ),\n                ),\n                (\n                    \"address\",\n                    models.CharField(\n                        blank=True,\n                        db_index=True,\n                        max_length=256,\n                        verbose_name=\"address\",\n                    ),\n                ),\n                (\n                    \"geometry\",\n                    django.contrib.gis.db.models.fields.GeometryField(\n                        blank=True, null=True, srid=4326, verbose_name=\"geometry\"\n                    ),\n                ),\n            ],\n            options={\"abstract\": False},\n        ),\n        migrations.CreateModel(\n            name=\"ObjectLocation\",\n            fields=[\n                (\n                    \"id\",\n                    models.UUIDField(\n                        default=uuid.uuid4,\n                        editable=False,\n                        primary_key=True,\n                        serialize=False,\n                    ),\n                ),\n                (\n                    \"created\",\n                    model_utils.fields.AutoCreatedField(\n                        default=django.utils.timezone.now,\n                        editable=False,\n                        verbose_name=\"created\",\n                    ),\n                ),\n                (\n                    \"modified\",\n                    model_utils.fields.AutoLastModifiedField(\n                        default=django.utils.timezone.now,\n                        editable=False,\n                        verbose_name=\"modified\",\n                    ),\n                ),\n                (\"object_id\", models.CharField(db_index=True, max_length=36)),\n                (\n                    \"indoor\",\n                    models.CharField(\n                        blank=True,\n                        max_length=64,\n                        null=True,\n                        verbose_name=\"indoor position\",\n                    ),\n                ),\n                (\n                    \"content_type\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"contenttypes.ContentType\",\n                    ),\n                ),\n                (\n                    \"floorplan\",\n                    models.ForeignKey(\n                        blank=True,\n                        null=True,\n                        on_delete=django.db.models.deletion.PROTECT,\n                        to=\"django_loci.FloorPlan\",\n                    ),\n                ),\n                (\n                    \"location\",\n                    models.ForeignKey(\n                        blank=True,\n                        null=True,\n                        on_delete=django.db.models.deletion.PROTECT,\n                        to=\"django_loci.Location\",\n                    ),\n                ),\n            ],\n        ),\n        migrations.AddField(\n            model_name=\"floorplan\",\n            name=\"location\",\n            field=models.ForeignKey(\n                on_delete=django.db.models.deletion.CASCADE, to=\"django_loci.Location\"\n            ),\n        ),\n        migrations.AlterUniqueTogether(\n            name=\"objectlocation\", unique_together=set([(\"content_type\", \"object_id\")])\n        ),\n        migrations.AlterUniqueTogether(\n            name=\"floorplan\", unique_together=set([(\"location\", \"floor\")])\n        ),\n    ]\n"
  },
  {
    "path": "django_loci/migrations/__init__.py",
    "content": ""
  },
  {
    "path": "django_loci/models.py",
    "content": "from .base.models import AbstractFloorPlan, AbstractLocation, AbstractObjectLocation\n\n\nclass Location(AbstractLocation):\n    class Meta(AbstractLocation.Meta):\n        abstract = False\n\n\nclass FloorPlan(AbstractFloorPlan):\n    class Meta(AbstractFloorPlan.Meta):\n        abstract = False\n\n\nclass ObjectLocation(AbstractObjectLocation):\n    class Meta(AbstractObjectLocation.Meta):\n        abstract = False\n"
  },
  {
    "path": "django_loci/settings.py",
    "content": "from django.conf import settings\nfrom django.core.exceptions import ImproperlyConfigured\nfrom django.utils.module_loading import import_string\n\nDJANGO_LOCI_GEOCODER = getattr(settings, \"DJANGO_LOCI_GEOCODER\", \"ArcGIS\")\nDJANGO_LOCI_GEOCODE_FAILURE_DELAY = getattr(\n    settings, \"DJANGO_LOCI_GEOCODE_FAILURE_DELAY\", 1\n)\nDJANGO_LOCI_GEOCODE_RETRIES = getattr(settings, \"DJANGO_LOCI_GEOCODE_RETRIES\", 3)\nDJANGO_LOCI_GEOCODE_API_KEY = getattr(\n    settings, \"DJANGO_LOCI_GEOCODE_GOOGLE_API_KEY\", None\n)\nFLOORPLAN_STORAGE = getattr(\n    settings, \"LOCI_FLOORPLAN_STORAGE\", \"django_loci.storage.OverwriteStorage\"\n)\n\ntry:\n    FLOORPLAN_STORAGE = import_string(FLOORPLAN_STORAGE)\nexcept ImportError:  # pragma: nocover\n    raise ImproperlyConfigured(\"Import of {0} failed\".format(FLOORPLAN_STORAGE))\n"
  },
  {
    "path": "django_loci/static/django-loci/css/floorplan-widget.css",
    "content": ".floorplan-raw {\n  display: none;\n}\n.floorplan-widget {\n  width: 100%;\n  height: 500px;\n  padding: 0;\n  margin: 0;\n  background: #fff;\n}\n.floorplan-image img {\n  width: 50%;\n}\np.change-image {\n  margin-left: 170px;\n}\n/* Set .readonly to flex to ensure the floorplan-widget inherits the full width of \nits parent flex container */\n.field-indoor .readonly {\n  display: flex;\n  width: 100%;\n}\n"
  },
  {
    "path": "django_loci/static/django-loci/css/loci.css",
    "content": ".coords,\n.coords .form-row {\n  display: none;\n}\n.coords .field-location_selection,\n.coords .field-floorplan_selection {\n  display: block;\n}\n/* hide redundant heading in objectlocationinline */\n.loci.coords > h2,\n.loci.coords > h4 {\n  display: none;\n}\n/* improve raw_id_field look */\n.coords .field-location input[type=\"text\"] {\n  display: none;\n}\n.field-location .related-lookup {\n  width: auto;\n  padding-left: 20px;\n  background-position: left center;\n}\n.field-location .item-label {\n  display: inline-block;\n  height: 16px;\n  font-weight: bold;\n  margin-left: 6px;\n}\n#floorplan_form .field-location .item-label {\n  vertical-align: middle;\n}\n.coords .field-location .item-label,\n.field-location strong {\n  position: relative;\n  bottom: -3px;\n}\n.field-location strong {\n  margin-left: 3px;\n}\ninput[type=\"text\"] {\n  width: 320px;\n}\n/* simulate required fields */\n.coords.loci .flex-container > label {\n  font-weight: bold !important;\n  color: #333;\n}\n.no-location {\n  padding: 10px 0 10px 0;\n  font-weight: bold;\n  text-align: center;\n  font-size: 14px;\n}\n.form-row.field-geometry .flex-container {\n  display: block;\n}\n/* Ensures Layer Control labels in Leaflet are not stretched horizontally */\n.form-row .leaflet-control label {\n  min-width: 0;\n  width: auto;\n  display: inline-block;\n}\n.form-row .leaflet-control label:last-child {\n  padding-right: 0;\n}\n.form-row .leaflet-control input[type=\"radio\"] {\n  margin-top: -6px;\n}\n.form-row .leaflet-control-layers-expanded {\n  padding: 6px 12px;\n}\n#floorplan_set-group {\n  display: none;\n}\n/* hide image file input for readonly view */\n.field-image .readonly input {\n  display: none;\n}\n/* hide geometry edit tools for readonly view */\n.field-geometry .readonly .leaflet-draw.geometry {\n  display: none;\n}\n/* avoid underlining leaflet controls */\n.leaflet-bar a {\n  text-decoration: none !important;\n}\n@media (max-width: 767px) {\n  .field-geometry > div {\n    flex-direction: column;\n  }\n}\n"
  },
  {
    "path": "django_loci/static/django-loci/js/floorplan-inlines.js",
    "content": "django.jQuery(function ($) {\n  \"use strict\";\n  var $type_field = $(\"#id_type\"),\n    $floorplan_set = $(\"#floorplan_set-group\"),\n    $floorplans_length = $floorplan_set.find(\".inline-related.has_original\").length,\n    type_change_event = function (e) {\n      var value = $type_field.val();\n      // if value is undefined, check for readonly field\n      if (typeof value === \"undefined\") {\n        value = $(\".field-type .readonly\").text();\n        if (value && value.startsWith(\"Indoor\")) {\n          $floorplan_set.show();\n        } else {\n          $floorplan_set.hide();\n        }\n      }\n      if (value === \"indoor\") {\n        $floorplan_set.show();\n      } else if (value === \"outdoor\" && $floorplans_length === 0) {\n        $floorplan_set.hide();\n      } else if (value === \"outdoor\" && $floorplans_length > 0) {\n        // Confirm deletion on switching indoor to outdoor, if floorplans exist\n        var msg = gettext(\n          \"This location has floorplans associated to it. \" +\n            \"Converting it to outdoor will remove all these floorplans, \" +\n            \"affecting all devices related to this location. \" +\n            \"Do you want to proceed?\",\n        );\n        if (!confirm(msg)) {\n          $type_field.val(\"indoor\");\n        } else {\n          $floorplan_set.hide();\n        }\n      }\n    };\n  $type_field.change(type_change_event);\n  type_change_event();\n});\n"
  },
  {
    "path": "django_loci/static/django-loci/js/floorplan-widget.js",
    "content": "(function () {\n  \"use strict\";\n  django.loadFloorPlan = function (widgetName, imageUrl, imageW, imageH) {\n    var $input = django.jQuery(\"#id_\" + widgetName),\n      $parent = $input.parents(\"fieldset\").eq(0),\n      url = imageUrl || $parent.find(\"a.floorplan-image\").attr(\"href\"),\n      $dim = $parent.find(\"#id_\" + widgetName.replace(\"indoor\", \"image\") + \"-dim\"),\n      $indoorPosition = $parent.find(\".field-indoor\"),\n      mapId = \"id_\" + widgetName + \"_map\",\n      w = imageW || $dim.data(\"width\"),\n      h = imageH || $dim.data(\"height\"),\n      coordinates,\n      map;\n\n    if (!url) {\n      return;\n    }\n    $indoorPosition.show();\n\n    map = L.map(mapId, {\n      crs: L.CRS.Simple,\n      minZoom: -1,\n      maxZoom: 2,\n    });\n\n    // calculate the edges of the image, in coordinate space\n    var bottomRight = map.unproject([0, h * 2], map.getMaxZoom() - 1),\n      upperLeft = map.unproject([w * 2, 0], map.getMaxZoom() - 1),\n      bounds = new L.LatLngBounds(bottomRight, upperLeft);\n    L.imageOverlay(url, bounds).addTo(map);\n    map.fitBounds(bounds);\n    map.setMaxBounds(bounds);\n    map.setView([0, 0], 0);\n\n    function updateInput(e) {\n      var latlng = e.latlng || e.target._latlng;\n      $input.val(latlng.lat + \",\" + latlng.lng);\n    }\n\n    if ($input.val()) {\n      var latlng = $input.val().split(\",\");\n      coordinates = { lat: latlng[0], lng: latlng[1] };\n    } else {\n      coordinates = undefined;\n    }\n    var draggable = true;\n    // if readonly field, don't allow dragging\n    if ($indoorPosition.find(\".readonly\").length) {\n      draggable = false;\n    }\n\n    var marker = new L.marker(coordinates, { draggable: draggable });\n    marker.bindPopup(gettext(\"Drag to reposition\"));\n    marker.on(\"dragend\", updateInput);\n    if (coordinates) {\n      marker.addTo(map);\n    }\n\n    map.on(\"click\", function (e) {\n      if (marker.getLatLng() === undefined) {\n        marker.setLatLng(e.latlng);\n        marker.addTo(map);\n        updateInput(e);\n      }\n    });\n\n    // clear indoor coordinates if map is removed\n    map.on(\"unload\", function () {\n      $input.val(\"\");\n    });\n\n    return map;\n  };\n})();\n"
  },
  {
    "path": "django_loci/static/django-loci/js/loci.js",
    "content": "/*global\nalert, confirm, console, Debug, opera, prompt, WSH\n*/\n/*\nthis JS is shared between:\n    - DeviceLocationForm\n    - LocationForm\n*/\ndjango.jQuery(function ($) {\n  \"use strict\";\n\n  var $outdoor = $(\".loci.coords\"),\n    $indoor = $(\".indoor.coords\"),\n    $allSections = $(\".coords\"),\n    $geoEdit = $(\n      \".field-name, .field-type, .field-is_mobile, \" +\n        \".field-address, .field-geometry\",\n      \".loci.coords\",\n    ),\n    $indoorRows = $(\".indoor.coords .form-row:not(.field-indoor)\"),\n    $indoorEdit = $(\".indoor.coords .form-row:not(.field-floorplan_selection)\"),\n    $indoorPositionRow = $(\".indoor.coords .field-indoor\"),\n    geometryId = $(\".field-geometry label\").attr(\"for\") || \"geometry\", // fallback for readonly\n    mapName = \"leafletmap\" + geometryId + \"-map\",\n    loadMapName = \"loadmap\" + geometryId + \"-map\",\n    $typeRow = $(\".inline-group .field-type\"),\n    $type = $typeRow.find(\"select\"),\n    $isMobile = $(\n      \".coords .field-is_mobile input, #location_form .field-is_mobile input\",\n    ),\n    $locationSelectionRow = $(\".field-location_selection\"),\n    $locationSelection = $locationSelectionRow.find(\"select\"),\n    $locationRow = $(\".loci.coords .field-location\"),\n    $location = $locationRow.find(\"select, input\"),\n    $locationLabel = $(\".field-location .item-label\"),\n    $name = $(\".field-name input\", \".loci.coords\"),\n    $address = $(\".coords .field-address input, #location_form .field-address input\"),\n    $geometryTextarea = $(\".field-geometry textarea\"),\n    baseLocationJsonUrl = $(\"#loci-location-json-url\").attr(\"data-url\"),\n    baseLocationFloorplansJsonUrl = $(\"#loci-location-floorplans-json-url\").attr(\n      \"data-url\",\n    ),\n    $geometryRow = $geometryTextarea.parents(\".form-row\"),\n    msg = gettext(\"Location data not received yet\"),\n    $noLocationDiv = $(\".no-location\", \".loci.coords\"),\n    $floorplanSelectionRow = $(\".indoor.coords .field-floorplan_selection\"),\n    $floorplanSelection = $floorplanSelectionRow.find(\"select\"),\n    $floorplanRow = $(\".indoor .field-floorplan\"),\n    $floorplan = $floorplanRow.find(\"select\").eq(0),\n    $floorplanImage = $(\".indoor.coords .field-image input\"),\n    $floorplanMap = $(\".indoor.coords .floorplan-widget\"),\n    isNew = true,\n    $addressInput = $(\".field-address input\"),\n    $mapGeojsonTextarea = $(\".django-leaflet-raw-textarea\"),\n    $oldLat,\n    $oldLng,\n    $coordsUrl = $(\"#loci-geocode-url\").attr(\"data-url\"),\n    $addrUrl = $(\"#loci-reverse-geocode-url\").attr(\"data-url\");\n\n  // define dummy gettext if django i18n is not enabled\n  if (!gettext) {\n    window.gettext = function (text) {\n      return text;\n    };\n  }\n\n  function getLocationJsonUrl(pk) {\n    return baseLocationJsonUrl.replace(\"00000000-0000-0000-0000-000000000000\", pk);\n  }\n\n  function getLocationFloorplansJsonUrl(pk) {\n    return baseLocationFloorplansJsonUrl.replace(\n      \"00000000-0000-0000-0000-000000000000\",\n      pk,\n    );\n  }\n\n  function getMap() {\n    return window[mapName];\n  }\n\n  function invalidateMapSize() {\n    var map = getMap();\n    if (map) {\n      map.invalidateSize();\n    }\n    return map;\n  }\n\n  function resetOutdoorForm(keepLocationSelection) {\n    $locationSelectionRow.show();\n    if (!keepLocationSelection) {\n      $type.val(\"\");\n    }\n    $location.val(\"\");\n    $locationLabel.text(\"\");\n    $isMobile.prop(\"checked\", false);\n    $name.val(\"\");\n    $address.val(\"\");\n    $geometryTextarea.val(\"\");\n    $geoEdit.hide();\n    $locationRow.hide();\n    $locationSelection.show();\n    $noLocationDiv.hide();\n  }\n\n  function resetIndoorForm(keepFloorplanSelection) {\n    if (!keepFloorplanSelection) {\n      $indoor.hide();\n      $floorplanSelection.val(\"\");\n    }\n    $indoorRows.hide();\n    $floorplanSelectionRow.show();\n    // reset values\n    $indoorEdit.find(\"input,select\").val(\"\");\n  }\n\n  function resetDeviceLocationForm() {\n    resetOutdoorForm();\n    resetIndoorForm();\n  }\n\n  function indoorForm(selection) {\n    // fallbacks for view only users\n    var type = $type.val() || $typeRow.find(\".readonly\").text(),\n      floorplanValue =\n        $floorplanSelection.val() || $floorplanSelectionRow.find(\".readonly\").text(),\n      locationSelectionValue =\n        $locationSelection.val() || $locationSelectionRow.find(\".readonly\").text();\n    if (type !== \"indoor\") {\n      return;\n    }\n    $indoorPositionRow.hide();\n    $indoor.show();\n    if (!selection) {\n      $indoorRows.hide();\n      $floorplanSelectionRow.show();\n    } else if (selection === \"new\") {\n      $indoorRows.show();\n      $floorplan.val(\"\");\n      $floorplanRow.hide();\n    }\n    if (locationSelectionValue === \"new\") {\n      $floorplanSelection.val(\"new\");\n      $floorplanSelectionRow.hide();\n    }\n    if (!floorplanValue) {\n      $indoorRows.hide();\n      $floorplanSelectionRow.show();\n    }\n  }\n\n  function locationSelectionChange(e, initial) {\n    // get value from 'readonly' in case of view only permissions\n    var value =\n      $locationSelection.val() || $locationSelectionRow.find(\".readonly\").text();\n    $allSections.hide();\n    if (!initial) {\n      resetDeviceLocationForm();\n    }\n    if (value === \"new\") {\n      $outdoor.show();\n      $typeRow.show();\n      indoorForm(value);\n    } else if (value === \"existing\") {\n      $outdoor.show();\n      $locationRow.show();\n    }\n  }\n\n  function isMobileChange() {\n    var rows = [$address, $geometryTextarea];\n    if ($isMobile.prop(\"checked\")) {\n      $(rows).each(function (i, el) {\n        if (!$(el).val()) {\n          $(el).parents(\".form-row\").hide();\n        }\n      });\n      // name not relevant in mobile locations\n      $name.parents(\".form-row\").hide();\n      if (!$geometryTextarea.val()) {\n        $(\".no-location\").show();\n      }\n    } else {\n      $(rows).each(function (i, el) {\n        $(el).parents(\".form-row\").show();\n      });\n      $name.parents(\".form-row\").show();\n      $(\".no-location\").hide();\n    }\n  }\n\n  function typeChange(e, initial) {\n    // get value from 'readonly' in case of view only permissions\n    var value = $type.val() || $typeRow.find(\".readonly\").text(),\n      // floorplansLength for choice field includes the placeholder option so\n      // need to subtract it.\n      floorplansLength = $floorplan.find(\"option\").length - 1;\n    if (value) {\n      $outdoor.show();\n      $geoEdit.show();\n      invalidateMapSize();\n      isMobileChange();\n    } else {\n      $geoEdit.hide();\n      $indoor.hide();\n      $typeRow.show();\n    }\n    if (value === \"indoor\") {\n      $indoor.show();\n      indoorForm($locationSelection.val());\n    } else if (value === \"outdoor\" && floorplansLength >= 1) {\n      // Confirm deletion on switching indoor to outdoor, if floorplans exist\n      var msg = gettext(\n        \"This location has floorplans associated to it. \" +\n          \"Converting it to outdoor will remove all these floorplans, \" +\n          \"affecting all devices related to this location. \" +\n          \"Do you want to proceed?\",\n      );\n      if (!confirm(msg)) {\n        $type.val(\"indoor\");\n        return;\n      }\n      $indoor.hide();\n    } else {\n      $indoor.hide();\n    }\n  }\n\n  function floorplanSelectionChange(e, initial) {\n    // fallbacks for view only users\n    var value =\n        $floorplanSelection.val() || $floorplanSelectionRow.find(\".readonly\").text(),\n      optionsLength =\n        $floorplan.find(\"option\").length ||\n        $floorplanSelectionRow.find(\".readonly\").length;\n    // do not reset indoor form at first load\n    if (!initial) {\n      resetIndoorForm(true);\n    }\n    indoorForm(value);\n    // optionslength includes the placeholder option\n    if (value === \"existing\" && optionsLength >= 1) {\n      $floorplanRow.show();\n      // if no floorplan available, make it obvious\n    } else if (value === \"existing\" && optionsLength < 1) {\n      alert(gettext(\"This location has no floorplans available yet\"));\n      $floorplanSelection.val(\"\");\n    }\n  }\n\n  // HACK to override `dismissRelatedLookupPopup()` and\n  // `dismissAddAnotherPopup()` in Django's RelatedObjectLookups.js to\n  // trigger change event when an ID is selected or added via popup.\n  function triggerChangeOnField(win, chosenId) {\n    // In Django 4.2, the popup index is appended to the window name.\n    // Hence, we remove that before selecting the element.\n    $(document.getElementById(win.name.replace(/__\\d+$/, \"\"))).change();\n  }\n  window.ORIGINAL_dismissRelatedLookupPopup = window.dismissRelatedLookupPopup;\n  window.dismissRelatedLookupPopup = function (win, chosenId) {\n    window.ORIGINAL_dismissRelatedLookupPopup(win, chosenId);\n    triggerChangeOnField(win, chosenId);\n  };\n  window.ORIGINAL_dismissAddAnotherPopup = window.dismissAddAnotherPopup;\n  window.dismissAddAnotherPopup = function (win, chosenId) {\n    window.ORIGINAL_dismissAddAnotherPopup(win, chosenId);\n    triggerChangeOnField(win, chosenId);\n  };\n\n  $type.change(typeChange);\n  typeChange(null, true);\n\n  $locationSelection.change(locationSelectionChange);\n  locationSelectionChange(null, true);\n\n  function locationChange(e, initial) {\n    function loadIndoor() {\n      indoorForm();\n      // fallback for view only users\n      var type = $type.val() || $typeRow.find(\".readonly\").text();\n      if (type !== \"indoor\") {\n        $indoor.hide();\n        return;\n      }\n      var floorplansUrl = getLocationFloorplansJsonUrl($location.val());\n      $.getJSON(floorplansUrl, function (data) {\n        var $current = $floorplan.find(\"option:selected\"),\n          currentValue = $current.val();\n        $floorplan.find('option[value!=\"\"]').remove();\n        $(data.choices).each(function (i, el) {\n          var o = $(\"<option></option>\")\n            .attr(\"value\", el.id)\n            .text(el.str)\n            .data(\"floor\", el.floor)\n            .data(\"image\", el.image)\n            .data(\"image_width\", el.image_width)\n            .data(\"image_height\", el.image_height);\n          if (el.id === currentValue) {\n            o.attr(\"selected\", \"selected\");\n          }\n          $floorplan.append(o);\n        });\n      });\n    }\n    $typeRow.show();\n    if (!initial) {\n      // update location fields\n      var url = getLocationJsonUrl($location.val());\n      $.getJSON(url, function (data) {\n        $locationLabel.text(data.name);\n        $name.val(data.name);\n        $type.val(data.type);\n        $isMobile.prop(\"checked\", data.is_mobile);\n        $address.val(data.address);\n        $geometryTextarea.val(data.geometry ? JSON.stringify(data.geometry) : \"\");\n        var map = getMap();\n        if (map) {\n          map.remove();\n        }\n        $geoEdit.show();\n        window[loadMapName]();\n        isMobileChange();\n        loadIndoor();\n      });\n    } else {\n      loadIndoor();\n    }\n  }\n\n  // listen to change events\n  // although these events are being artificially triggered\n  // see the override of dismissRelatedLookupPopup above\n  $location.change(locationChange);\n  // initial set up\n  locationChange(null, true);\n\n  $isMobile.change(isMobileChange);\n\n  $floorplanSelection.change(floorplanSelectionChange);\n  floorplanSelectionChange(null, true);\n\n  $floorplan.change(function () {\n    // reset floorplan data if no floorplan is chosen\n    if (!$floorplan.val()) {\n      resetIndoorForm(true);\n      $indoorRows.show();\n      $indoorEdit.hide();\n      $floorplanRow.show();\n      return;\n    }\n    var option = $floorplan.find(\"option:selected\"),\n      widgetName = $floorplanMap\n        .parents(\".field-indoor\")\n        .find(\".floorplan-widget\")\n        .attr(\"id\")\n        .replace(\"id_\", \"\")\n        .replace(\"_map\", \"\"),\n      globalName = \"django-loci-floorplan-\" + widgetName,\n      image = option.data(\"image\"),\n      $a = $indoor.find(\".field-image a\"),\n      $aText = $a.text(),\n      $aNewText = $aText.split(\": \")[0] + \": \" + image.split(\"/\").slice(-1);\n    $indoor.find(\".field-floor input\").val(option.data(\"floor\"));\n    $indoor.find(\".form-row:not(.field-floorplan_selection)\").show();\n    $a.attr(\"href\", image).text($aNewText);\n    // remove previous indoor map if present\n    if (window[globalName]) {\n      window[globalName].remove();\n    }\n    window[globalName] = django.loadFloorPlan(\n      widgetName,\n      image,\n      option.data(\"image_width\"),\n      option.data(\"image_height\"),\n    );\n  });\n\n  $floorplanImage.change(function () {\n    var input = this,\n      reader = new FileReader(),\n      image = new Image(),\n      $indoorRow = $floorplanMap.parents(\".field-indoor\"),\n      widgetName = $indoorRow\n        .find(\".floorplan-widget\")\n        .attr(\"id\")\n        .replace(\"id_\", \"\")\n        .replace(\"_map\", \"\"),\n      globalName = \"django-loci-floorplan-\" + widgetName;\n    if (!input.files || !input.files[0]) {\n      return;\n    }\n    reader.onload = function (e) {\n      image.src = e.target.result;\n      image.onload = function () {\n        $indoorRow.show();\n        // remove previous indoor map if present\n        if (window[globalName]) {\n          window[globalName].remove();\n        }\n        window[globalName] = django.loadFloorPlan(\n          widgetName,\n          this.src,\n          this.width,\n          this.height,\n        );\n      };\n    };\n    reader.readAsDataURL(input.files[0]);\n  });\n\n  $(\"#content-main form\").submit(function (e) {\n    var indoorPosition = $(\".field-indoor .floorplan-raw input\").val(),\n      typeSelect = $type.find(\"option\").length\n        ? $type\n        : $(\".module.aligned .field-type\").find(\"select\");\n    if (isNew && $type.val() === \"indoor\" && !indoorPosition) {\n      var message = gettext(\n        \"You have set this location as indoor but have \" +\n          \"not specified indoor cordinates on a floorplan, \" +\n          \"do you want to save anyway?\",\n      );\n      if (!confirm(message)) {\n        e.preventDefault();\n      } else {\n        $floorplanSelection.val(\"\");\n        indoorForm();\n      }\n    }\n  });\n\n  // websocket for mobile coords\n  function listenForLocationUpdates(pk) {\n    var host = window.location.host,\n      protocol = window.location.protocol === \"http:\" ? \"ws\" : \"wss\",\n      ws = new ReconnectingWebSocket(\n        protocol + \"://\" + host + \"/ws/loci/location/\" + pk + \"/\",\n      );\n    ws.onmessage = function (e) {\n      const data = JSON.parse(e.data);\n      $geometryRow.show();\n      $noLocationDiv.hide();\n      $geometryTextarea.val(JSON.stringify(data.geometry));\n      $address.val(data.address);\n      getMap().remove();\n      window[loadMapName]();\n    };\n  }\n\n  // returns marker or featureGroup\n  function getMarkerFeatureGroup(option) {\n    var map = getMap(),\n      layer;\n    map.eachLayer(function (lay) {\n      if (lay.hasOwnProperty(option)) {\n        layer = lay;\n      }\n    });\n    return layer;\n  }\n  // returns placed marker\n  function getMarker() {\n    return getMarkerFeatureGroup(\"_latlng\");\n  }\n\n  // returns map's feature group\n  function getFeatureGroup() {\n    return getMarkerFeatureGroup(\"_layers\");\n  }\n\n  // update lat and lng\n  function updateLatLng(latlng) {\n    $oldLat = latlng.lat.toString();\n    $oldLng = latlng.lng.toString();\n  }\n\n  // update map view\n  function updateMapView(data) {\n    var geojson =\n      '{ \"type\": \"Point\", \"coordinates\": [ ' + data.lng + \", \" + data.lat + \"] }\";\n    $mapGeojsonTextarea.val(geojson);\n    getMap().setView([data.lat, data.lng], 15);\n  }\n\n  // update map\n  function updateMap() {\n    var addressValue = $addressInput.val(),\n      message;\n    if (!addressValue) {\n      getFeatureGroup().clearLayers();\n      return;\n    }\n    $.get($coordsUrl, { address: addressValue })\n      .done(function (data) {\n        var marker = getMarker(),\n          featureGroup = getFeatureGroup();\n        if (marker === undefined) {\n          updateLatLng(data);\n          featureGroup.addLayer(L.marker([data.lat, data.lng]));\n        } else {\n          var latlng = marker.getLatLng();\n          if (latlng.lat !== data.lat || latlng.lng !== data.lng) {\n            message = gettext(\n              \"The address was changed, would you like to \" +\n                \"automatically update the location on the map?\",\n            );\n            if (confirm(message)) {\n              updateLatLng(data);\n              featureGroup.removeLayer(marker);\n              featureGroup.addLayer(L.marker([data.lat, data.lng]));\n            }\n          }\n        }\n      })\n      .fail(function () {\n        message = gettext(\n          \"Location with address: \" + $addressInput.val() + \"was not found.\",\n        );\n        alert(message);\n      });\n  }\n\n  function updateAdress() {\n    var marker = getMarker(),\n      message,\n      latlng;\n    if (marker === undefined) {\n      return;\n    }\n    latlng = marker.getLatLng();\n    if (latlng.lat.toString() === $oldLat && latlng.lng.toString() === $oldLng) {\n      return;\n    }\n    updateLatLng(latlng);\n    $.get($addrUrl, { lat: latlng.lat, lng: latlng.lng })\n      .done(function (data) {\n        if (!$addressInput.val()) {\n          $addressInput.val(data.address);\n        } else {\n          message = gettext(\n            \"The location on the map was changed, would you \" +\n              \"like to update the address to\",\n          );\n          message += ' \"' + data.address + '\"?';\n          if (confirm(message)) {\n            $addressInput.val(data.address);\n          }\n        }\n      })\n      .fail(function () {\n        message = gettext(\"Could not find any address related to this location.\");\n        alert(message);\n      });\n  }\n\n  // triggers update of the address when the location on the map is changed\n  function updateAddressOnMapChange() {\n    var marker = getMarker();\n    if (!marker) {\n      return;\n    }\n    getMap().on(\"draw:edited\", function (e) {\n      updateAdress();\n      updateMapView(marker.getLatLng());\n    });\n  }\n\n  $addressInput.change(function () {\n    updateMap();\n  });\n\n  function geometryListeners() {\n    if (!getMap()) {\n      return;\n    }\n    var featureGroup = getFeatureGroup(),\n      marker = getMarker();\n    featureGroup.on(\"layeradd\", function () {\n      updateAdress();\n      updateAddressOnMapChange();\n      marker = getMarker();\n      if (!marker) {\n        return;\n      }\n      updateMapView(marker.getLatLng());\n    });\n    if (marker !== undefined) {\n      updateLatLng(marker.getLatLng());\n      updateAddressOnMapChange();\n    }\n  }\n  // some browsers fires load event before attaching listener\n  // so we need to check if the document is ready\n  if (document.readyState === \"complete\") {\n    geometryListeners();\n  } else {\n    $(window).on(\"load\", function () {\n      geometryListeners();\n    });\n  }\n\n  // show existing location\n  var pk;\n  if ($location.val()) {\n    $locationSelectionRow.hide();\n    $geoEdit.show();\n    isNew = false;\n    pk = $location.val();\n  } else {\n    pk = window.location.pathname.split(\"/\").slice(\"-3\", \"-2\")[0];\n  }\n  // fallback for view only users\n  var typeLength = $type.length || $typeRow.find(\".readonly\").length;\n  // show mobile map (hide not relevant fields)\n  if ($isMobile.prop(\"checked\")) {\n    listenForLocationUpdates(pk);\n    $outdoor.show();\n    $locationSelection.parents(\".form-row\").hide();\n    $locationRow.hide();\n    $name.parents(\".form-row\").hide();\n    if (!$address.val()) {\n      $address.parents(\".form-row\").hide();\n    }\n    // if no location data yet\n    if (!$geometryTextarea.val()) {\n      $geometryRow.hide();\n      $geometryRow.parent().append('<div class=\"no-location\">' + msg + \"</div>\");\n      $noLocationDiv = $(\".no-location\", \".loci.coords\");\n    }\n    // this is triggered in the location form page\n  } else if (!typeLength) {\n    if (pk !== \"location\") {\n      listenForLocationUpdates(pk);\n    }\n  }\n  // show existing indoor\n  // fallbacks for view only users\n  if ($floorplan.val() || $floorplanSelectionRow.find(\".readonly\").text()) {\n    $indoor.show();\n    if ($floorplanSelection.val() || $floorplanSelectionRow.find(\".readonly\").text()) {\n      $indoorRows.show();\n      $floorplanSelectionRow.hide();\n    }\n    // adding indoor\n  } else if (($type.val() || $typeRow.find(\".readonly\").text()) === \"indoor\") {\n    $indoor.show();\n    $indoorRows.show();\n    indoorForm(\n      $locationSelection.val() || $locationSelectionRow.find(\".readonly\").text(),\n    );\n  }\n});\n"
  },
  {
    "path": "django_loci/storage.py",
    "content": "from django.core.files.storage import FileSystemStorage\n\n\nclass OverwriteMixin:\n    floorplan_upload_dir = \"floorplans\"\n\n    @classmethod\n    def upload_to(cls, instance, filename):\n        \"\"\"\n        passed to FloorPlan.image.upload_to\n        \"\"\"\n        ext = filename.split(\".\")[-1]\n        dir_ = cls.floorplan_upload_dir\n        return \"{0}/{1}.{2}\".format(dir_, instance.id, ext)\n\n    def get_available_name(self, name, max_length=None):\n        \"\"\"\n        removes file if it already exists\n        \"\"\"\n        if self.exists(name):\n            self.delete(name)\n        return name\n\n\nclass OverwriteStorage(OverwriteMixin, FileSystemStorage):\n    \"\"\"\n    Adds the overwrite functionality to the file storage class\n    currently in-use by the Django project.\n    \"\"\"\n\n    pass\n"
  },
  {
    "path": "django_loci/templates/admin/django_loci/foreign_key_raw_id.html",
    "content": "{% include 'django/forms/widgets/input.html' %}{% load i18n %}\n{% if related_url %}\n    <a href=\"{{ related_url }}\"\n       class=\"related-lookup\"\n       id=\"lookup_id_{{ widget.name }}\"\n       title=\"{{ link_title }}\">{% trans 'Select item' %}</a>\n{% endif %}\n<span class=\"item-label\">\n{% if link_label %}\n    {% if link_url %}<a href=\"{{ link_url }}\">{% endif %}\n    {{ link_label }}\n    {% if link_url %}</a>{% endif %}\n{% endif %}\n</span>\n"
  },
  {
    "path": "django_loci/templates/admin/django_loci/location_change_form.html",
    "content": "{% extends \"admin/change_form.html\" %}\n{% block content %}\n{{ block.super }}\n{% comment %}\n    We use django to generate URLs that are then\n    read by javascript. This allows the JS features\n    to work also when django-loci is used as a based\n    app to create a new app with a different app_label\n{% endcomment %}\n<span id=\"loci-geocode-url\"\n      data-url=\"{% url \"admin:django_loci_location_geocode_api\" %}\"></span>\n<span id=\"loci-reverse-geocode-url\"\n      data-url=\"{% url \"admin:django_loci_location_reverse_geocode_api\" %}\"></span>\n{% endblock %}\n"
  },
  {
    "path": "django_loci/templates/admin/django_loci/location_inline.html",
    "content": "{% include \"admin/edit_inline/stacked.html\" %}\n{% comment %}\n    We use django to generate URLs that are then\n    read by javascript. This allows the JS features\n    to work also when django-loci is used as a based\n    app to create a new app with a different app_label\n{% endcomment %}\n<span id=\"loci-location-json-url\"\n      data-url=\"{% url \"admin:django_loci_location_json\" \"00000000-0000-0000-0000-000000000000\" %}\"></span>\n<span id=\"loci-location-floorplans-json-url\"\n      data-url=\"{% url \"admin:django_loci_location_floorplans_json\" \"00000000-0000-0000-0000-000000000000\" %}\"></span>\n<span id=\"loci-geocode-url\"\n      data-url=\"{% url \"admin:django_loci_location_geocode_api\" %}\"></span>\n<span id=\"loci-reverse-geocode-url\"\n      data-url=\"{% url \"admin:django_loci_location_reverse_geocode_api\" %}\"></span>\n"
  },
  {
    "path": "django_loci/templates/admin/widgets/floorplan.html",
    "content": "<div id=\"id_{{ widget.name }}_map\" class=\"floorplan-widget\"></div>\n<div id=\"id_{{ widget.name }}_raw\" class=\"floorplan-raw\">\n    {% include \"django/forms/widgets/input.html\" %}\n</div>\n{% if '__prefix__' not in widget.name %}\n<script>\ndjango.jQuery(function() {\n    window['django-loci-floorplan-{{ widget.name }}'] = django.loadFloorPlan('{{ widget.name }}');\n});\n</script>\n{% endif %}\n"
  },
  {
    "path": "django_loci/templates/admin/widgets/foreign_key_raw_id.html",
    "content": "{% include 'admin/django_loci/foreign_key_raw_id.html' %}\n"
  },
  {
    "path": "django_loci/templates/admin/widgets/image.html",
    "content": "{% load i18n %}\n{% if url %}\n<a target=\"_blank\"\n   href=\"{{ url }}\"\n   class=\"floorplan-image\">\n    {% if thumbnail %}\n    <img src=\"{{ url }}\" />\n    {% else %}\n    {% trans 'Currently' %}: {{ filename }}\n    {% endif %}\n</a>\n<p class=\"change-image\">\n{% endif %}\n{% include \"django/forms/widgets/input.html\" %}\n{% if width and height %}\n<span id=\"id_{{ widget.name }}-dim\"\n      data-width=\"{{ width }}\"\n      data-height=\"{{ height }}\">\n{% endif %}\n{% if url %}</p>{% endif %}\n"
  },
  {
    "path": "django_loci/tests/__init__.py",
    "content": "\"\"\"\nReusable test helpers\n\"\"\"\n\nimport importlib\nimport os\n\nfrom channels.db import database_sync_to_async\nfrom channels.testing import WebsocketCommunicator\nfrom django.conf import settings\nfrom django.contrib.auth import login\nfrom django.core.files.uploadedfile import SimpleUploadedFile\nfrom django.http.request import HttpRequest\n\n\nclass TestLociMixin(object):\n    _object_kwargs = dict(name=\"test-object\")\n    _floorplan_path = os.path.join(settings.MEDIA_ROOT, \"floorplan.jpg\")\n\n    def tearDown(self):\n        if not hasattr(self, \"floorplan_model\"):\n            return\n        for fl in self.floorplan_model.objects.all():\n            fl.objectlocation_set.all().delete()\n            fl.delete()\n\n    def _create_object(self, **kwargs):\n        self._object_kwargs.update(kwargs)\n        return self.object_model.objects.create(**self._object_kwargs)\n\n    def _create_location(self, **kwargs):\n        options = dict(\n            name=\"test-location\",\n            address=\"Via del Corso, Roma, Italia\",\n            geometry=\"SRID=4326;POINT (12.512124 41.898903)\",\n            type=\"outdoor\",\n        )\n        options.update(kwargs)\n        location = self.location_model(**options)\n        location.full_clean()\n        location.save()\n        return location\n\n    def _get_simpleuploadedfile(self):\n        with open(self._floorplan_path, \"rb\") as f:\n            image = f.read()\n        return SimpleUploadedFile(\n            name=\"floorplan.jpg\", content=image, content_type=\"image/jpeg\"\n        )\n\n    def _create_floorplan(self, **kwargs):\n        options = dict(floor=1)\n        options.update(kwargs)\n        if \"image\" not in options:\n            options[\"image\"] = self._get_simpleuploadedfile()\n        if \"location\" not in options:\n            options[\"location\"] = self._create_location(type=\"indoor\")\n        fl = self.floorplan_model(**options)\n        fl.full_clean()\n        fl.save()\n        return fl\n\n    def _create_object_location(self, **kwargs):\n        options = {}\n        options.update(**kwargs)\n        if \"content_object\" not in options:\n            options[\"content_object\"] = self._create_object()\n        if \"location\" not in options:\n            options[\"location\"] = self._create_location()\n        elif options[\"location\"].type == \"indoor\":\n            options[\"indoor\"] = \"-140.38620,40.369227\"\n        ol = self.object_location_model(**options)\n        ol.full_clean()\n        ol.save()\n        return ol\n\n\nclass TestAdminMixin(object):\n    @property\n    def url_prefix(self):\n        return \"admin:{0}\".format(self.location_model._meta.app_label)\n\n    @property\n    def object_url_prefix(self):\n        return \"admin:{0}\".format(self.object_model._meta.app_label)\n\n    def _create_admin(self, **kwargs):\n        opts = dict(\n            username=\"admin\",\n            password=\"admin\",\n            email=\"admin@email.org\",\n            is_superuser=True,\n            is_staff=True,\n        )\n        opts.update(kwargs)\n        return self.user_model.objects.create_user(**opts)\n\n    def _login_as_admin(self):\n        admin = self._create_admin()\n        self.client.force_login(admin)\n        return admin\n\n    def _create_readonly_admin(self, **kwargs):\n        \"\"\"Creates a read-only admin user with view permissions for the specified models.\"\"\"\n        models = kwargs.pop(\"models\", [])\n        user = self._create_admin(is_superuser=False, **kwargs)\n        if models:\n            permission_codenames = []\n            for model in models:\n                permission_codenames.append(f\"view_{model.__name__.lower()}\")\n            # assign view permissions to user\n            view_permission = self.permission_model.objects.filter(\n                codename__in=permission_codenames\n            )\n            user.user_permissions.add(*view_permission)\n        return user\n\n    def _load_content(self, file):\n        d = os.path.dirname(os.path.abspath(__file__))\n        return open(os.path.join(d, file)).read()\n\n\n# Mixin for testing admin inline views\nclass TestAdminInlineMixin(TestAdminMixin):\n    @classmethod\n    def _get_prefix(cls):\n        s = \"{0}-{1}-content_type-object_id\"\n        return s.format(\n            cls.location_model._meta.app_label,\n            cls.object_location_model.__name__.lower(),\n        )\n\n    def _get_url_prefix(self):\n        return \"{0}_{1}\".format(\n            self.object_url_prefix, self.object_model.__name__.lower()\n        )\n\n    @property\n    def add_url(self):\n        return \"{0}_add\".format(self._get_url_prefix())\n\n    @property\n    def change_url(self):\n        return \"{0}_change\".format(self._get_url_prefix())\n\n\nclass TestChannelsMixin(object):\n\n    async def _force_login(self, user, backend=None):\n        engine = importlib.import_module(settings.SESSION_ENGINE)\n        request = HttpRequest()\n        request.session = engine.SessionStore()\n        await database_sync_to_async(login)(request, user, backend)\n        await database_sync_to_async(request.session.save)()\n        return request.session\n\n    async def _get_location_request_dict(self, path, pk=None, user=None):\n        if not pk:\n            location = await database_sync_to_async(self._create_location)(\n                is_mobile=True\n            )\n            await database_sync_to_async(self._create_object_location)(\n                location=location\n            )\n            pk = location.pk\n        session = None\n        if user:\n            session = await self._force_login(user)\n        return {\"pk\": pk, \"path\": path, \"session\": session}\n\n    async def _get_specific_location_request_dict(self, pk=None, user=None):\n        result = await self._get_location_request_dict(\n            path=\"/ws/loci/location/{0}/\", pk=pk, user=user\n        )\n        result[\"path\"] = result[\"path\"].format(result[\"pk\"])\n        return result\n\n    async def _get_common_location_request_dict(self, pk=None, user=None):\n        return await self._get_location_request_dict(\n            path=\"/ws/loci/location/\", pk=pk, user=user\n        )\n\n    def _get_location_communicator(\n        self, consumer, request_vars, user=None, include_pk=False\n    ):\n        communicator = WebsocketCommunicator(consumer.as_asgi(), request_vars[\"path\"])\n        if user:\n            scope = {\n                \"user\": user,\n                \"session\": request_vars[\"session\"],\n            }\n            if include_pk:\n                scope[\"url_route\"] = {\"kwargs\": {\"pk\": request_vars[\"pk\"]}}\n            communicator.scope.update(scope)\n        return communicator\n\n    def _get_specific_location_communicator(self, request_vars, user=None):\n        return self._get_location_communicator(\n            consumer=self.location_consumer,\n            request_vars=request_vars,\n            user=user,\n            include_pk=True,\n        )\n\n    def _get_common_location_communicator(self, request_vars, user=None):\n        return self._get_location_communicator(\n            consumer=self.common_location_consumer,\n            request_vars=request_vars,\n            user=user,\n            include_pk=False,\n        )\n\n    async def _save_location(self, pk):\n        loc = await self.location_model.objects.aget(pk=pk)\n        loc.geometry = \"POINT (12.513124 41.897903)\"\n        await loc.asave()\n"
  },
  {
    "path": "django_loci/tests/base/__init__.py",
    "content": "# pytest_*.py files in this folder can run via pytest.\n"
  },
  {
    "path": "django_loci/tests/base/static/test-geocode-invalid-address.json",
    "content": "{\n  \"spatialReference\": {\n    \"wkid\": 4326,\n    \"latestWkid\": 4326\n  },\n  \"candidates\": []\n}\n"
  },
  {
    "path": "django_loci/tests/base/static/test-geocode.json",
    "content": "{\n  \"spatialReference\": {\n    \"wkid\": 4326,\n    \"latestWkid\": 4326\n  },\n  \"candidates\": [\n    {\n      \"address\": \"Red Square\",\n      \"location\": {\n        \"x\": 37.620020000000068,\n        \"y\": 55.754120000000057\n      },\n      \"score\": 100,\n      \"attributes\": {},\n      \"extent\": {\n        \"xmin\": 37.615020000000065,\n        \"ymin\": 55.749120000000055,\n        \"xmax\": 37.62502000000007,\n        \"ymax\": 55.75912000000006\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": "django_loci/tests/base/static/test-reverse-geocode.json",
    "content": "{\n  \"address\": {\n    \"Match_addr\": \"05-500\",\n    \"LongLabel\": \"05-500, POL\",\n    \"ShortLabel\": \"05-500\",\n    \"Addr_type\": \"Postal\",\n    \"Type\": \"\",\n    \"PlaceName\": \"05-500\",\n    \"AddNum\": \"\",\n    \"Address\": \"\",\n    \"Block\": \"\",\n    \"Sector\": \"\",\n    \"Neighborhood\": \"\",\n    \"District\": \"\",\n    \"City\": \"Piaseczno\",\n    \"MetroArea\": \"\",\n    \"Subregion\": \"Powiat Piaseczyński\",\n    \"Region\": \"Woj. Mazowieckie\",\n    \"Territory\": \"\",\n    \"Postal\": \"05-500\",\n    \"PostalExt\": \"\",\n    \"CountryCode\": \"POL\"\n  },\n  \"location\": {\n    \"x\": 21,\n    \"y\": 52,\n    \"spatialReference\": {\n      \"wkid\": 4326,\n      \"latestWkid\": 4326\n    }\n  }\n}\n"
  },
  {
    "path": "django_loci/tests/base/static/test-reverse-location-with-no-address.json",
    "content": "{\n  \"error\": {\n    \"code\": 400,\n    \"message\": \"Cannot perform query. Invalid query parameters.\",\n    \"details\": [\"Unable to find address for the specified location.\"]\n  }\n}\n"
  },
  {
    "path": "django_loci/tests/base/test_admin.py",
    "content": "import json\n\nimport responses\nfrom django.contrib.auth.models import Permission\nfrom django.contrib.humanize.templatetags.humanize import ordinal\nfrom django.urls import reverse\n\nfrom .. import TestAdminMixin, TestLociMixin\n\n\nclass BaseTestAdmin(TestAdminMixin, TestLociMixin):\n    app_label = \"django_loci\"\n    geocode_url = \"https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/\"\n    permission_model = Permission\n\n    def test_location_list(self):\n        self._login_as_admin()\n        self._create_location(name=\"test-admin-location-1\")\n        url = reverse(\"{0}_location_changelist\".format(self.url_prefix))\n        r = self.client.get(url)\n        self.assertContains(r, \"test-admin-location-1\")\n\n    def test_floorplan_list(self):\n        self._login_as_admin()\n        self._create_floorplan()\n        self._create_location()\n        url = reverse(\"{0}_floorplan_changelist\".format(self.url_prefix))\n        r = self.client.get(url)\n        self.assertContains(r, \"1st floor\")\n\n    def test_location_json_view(self):\n        self._login_as_admin()\n        loc = self._create_location()\n        r = self.client.get(reverse(\"admin:django_loci_location_json\", args=[loc.pk]))\n        expected = {\n            \"name\": loc.name,\n            \"address\": loc.address,\n            \"type\": loc.type,\n            \"is_mobile\": loc.is_mobile,\n            \"geometry\": json.loads(loc.geometry.json),\n        }\n        self.assertDictEqual(r.json(), expected)\n\n    def test_location_floorplan_json_view(self):\n        self._login_as_admin()\n        fl = self._create_floorplan()\n        r = self.client.get(\n            reverse(\"admin:django_loci_location_floorplans_json\", args=[fl.location.pk])\n        )\n        expected = {\n            \"choices\": [\n                {\n                    \"id\": str(fl.pk),\n                    \"str\": str(fl),\n                    \"floor\": fl.floor,\n                    \"image\": fl.image.url,\n                    \"image_width\": fl.image.width,\n                    \"image_height\": fl.image.height,\n                }\n            ]\n        }\n        self.assertDictEqual(r.json(), expected)\n\n    def test_location_change_image_removed(self):\n        self._login_as_admin()\n        loc = self._create_location(name=\"test-admin-location-1\", type=\"indoor\")\n        fl = self._create_floorplan(location=loc)\n        # remove floorplan image\n        fl.image.delete(save=False)\n        url = reverse(\"{0}_location_change\".format(self.url_prefix), args=[loc.pk])\n        r = self.client.get(url)\n        self.assertContains(r, \"test-admin-location-1\")\n\n    def test_floorplan_change_image_removed(self):\n        self._login_as_admin()\n        loc = self._create_location(name=\"test-admin-location-1\", type=\"indoor\")\n        fl = self._create_floorplan(location=loc)\n        # remove floorplan image\n        fl.image.delete(save=False)\n        url = reverse(\"{0}_floorplan_change\".format(self.url_prefix), args=[fl.pk])\n        r = self.client.get(url)\n        self.assertContains(r, \"test-admin-location-1\")\n\n    def test_floorplan_add_view_filters_indoor_location(self):\n        self._login_as_admin()\n        loc_indoor = self._create_location(\n            name=\"test-admin-indoor-location\", type=\"indoor\"\n        )\n        loc_outdoor = self._create_location(\n            name=\"test-admin-outdoor-location\", type=\"outdoor\"\n        )\n        url = reverse(\"{0}_floorplan_add\".format(self.url_prefix))\n        filter_url = (\n            f\"/admin/{self.app_label}/location/?_to_field=id&type__exact=indoor\"\n        )\n        r1 = self.client.get(url)\n        self.assertContains(\n            r1,\n            f\"\"\"\n                <a href=\"{filter_url}\"\n                class=\"related-lookup\" id=\"lookup_id_location\" title=\"Lookup\">\n                    Select item\n                </a>\n            \"\"\",\n            html=True,\n        )\n        # Ensure that when the user clicks on the\n        # filter URL only indoor locations are displayed\n        r2 = self.client.get(filter_url)\n        self.assertContains(r2, f\"{loc_indoor.name}</a>\")\n        self.assertNotContains(r2, f\"{loc_outdoor.name}</a>\")\n\n    def test_is_mobile_location_json_view(self):\n        self._login_as_admin()\n        loc = self._create_location(is_mobile=True, geometry=None)\n        response = self.client.get(\n            reverse(\"admin:django_loci_location_json\", args=[loc.pk])\n        )\n        self.assertEqual(response.status_code, 200)\n        content = json.loads(response.content)\n        self.assertEqual(content[\"geometry\"], None)\n        loc1 = self._create_location(\n            name=\"location2\", address=\"loc2 add\", type=\"outdoor\"\n        )\n        response1 = self.client.get(\n            reverse(\"admin:django_loci_location_json\", args=[loc1.pk])\n        )\n        self.assertEqual(response1.status_code, 200)\n        content1 = json.loads(response1.content)\n        expected = {\n            \"name\": \"location2\",\n            \"address\": \"loc2 add\",\n            \"type\": \"outdoor\",\n            \"is_mobile\": False,\n            \"geometry\": {\"type\": \"Point\", \"coordinates\": [12.512124, 41.898903]},\n        }\n        self.assertEqual(content1, expected)\n\n    @responses.activate\n    def test_geocode(self):\n        self._login_as_admin()\n        address = \"Red Square\"\n        url = \"{0}?address={1}\".format(\n            reverse(\"admin:django_loci_location_geocode_api\"), address\n        )\n        # Mock HTTP request to the URL to work offline\n        responses.add(\n            responses.GET,\n            f\"{self.geocode_url}findAddressCandidates?singleLine=Red+Square&f=json&maxLocations=1\",\n            body=self._load_content(\"base/static/test-geocode.json\"),\n            content_type=\"application/json\",\n        )\n        response = self.client.get(url)\n        response_lat = round(response.json()[\"lat\"])\n        response_lng = round(response.json()[\"lng\"])\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response_lat, 56)\n        self.assertEqual(response_lng, 38)\n\n    def test_geocode_no_address(self):\n        self._login_as_admin()\n        url = reverse(\"admin:django_loci_location_geocode_api\")\n        response = self.client.get(url)\n        expected = {\"error\": \"Address parameter not defined\"}\n        self.assertEqual(response.status_code, 400)\n        self.assertEqual(response.json(), expected)\n\n    @responses.activate\n    def test_geocode_invalid_address(self):\n        self._login_as_admin()\n        invalid_address = \"thisaddressisnotvalid123abc\"\n        url = \"{0}?address={1}\".format(\n            reverse(\"admin:django_loci_location_geocode_api\"), invalid_address\n        )\n        responses.add(\n            responses.GET,\n            f\"{self.geocode_url}findAddressCandidates?singleLine=thisaddressisnotvalid123abc\"\n            \"&f=json&maxLocations=1\",\n            body=self._load_content(\"base/static/test-geocode-invalid-address.json\"),\n            content_type=\"application/json\",\n        )\n        response = self.client.get(url)\n        expected = {\"error\": \"Not found location with given name\"}\n        self.assertEqual(response.status_code, 404)\n        self.assertEqual(response.json(), expected)\n\n    @responses.activate\n    def test_reverse_geocode(self):\n        self._login_as_admin()\n        lat = 52\n        lng = 21\n        url = \"{0}?lat={1}&lng={2}\".format(\n            reverse(\"admin:django_loci_location_reverse_geocode_api\"), lat, lng\n        )\n        # Mock HTTP request to the URL to work offline\n        responses.add(\n            responses.GET,\n            f\"{self.geocode_url}reverseGeocode?location=21.0%2C52.0&f=json&outSR=4326\",\n            body=self._load_content(\"base/static/test-reverse-geocode.json\"),\n            content_type=\"application/json\",\n        )\n        response = self.client.get(url)\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"POL\")\n\n    @responses.activate\n    def test_reverse_location_with_no_address(self):\n        self._login_as_admin()\n        lat = -30\n        lng = -30\n        url = \"{0}?lat={1}&lng={2}\".format(\n            reverse(\"admin:django_loci_location_reverse_geocode_api\"), lat, lng\n        )\n        responses.add(\n            responses.GET,\n            f\"{self.geocode_url}reverseGeocode?location=-30.0%2C-30.0&f=json&outSR=4326\",\n            body=self._load_content(\n                \"base/static/test-reverse-location-with-no-address.json\"\n            ),\n            content_type=\"application/json\",\n        )\n        response = self.client.get(url)\n        response_address = response.json()[\"address\"]\n        self.assertEqual(response.status_code, 404)\n        self.assertEqual(response_address, \"\")\n\n    def test_reverse_geocode_no_coords(self):\n        self._login_as_admin()\n        url = reverse(\"admin:django_loci_location_reverse_geocode_api\")\n        response = self.client.get(url)\n        expected = {\"error\": \"lat or lng parameter not defined\"}\n        self.assertEqual(response.status_code, 400)\n        self.assertEqual(response.json(), expected)\n\n    def _get_location_add_params(self, **kwargs):\n        params = {\n            \"name\": \"test location\",\n            \"type\": \"outdoor\",\n            \"is_mobile\": \"\",\n            \"address\": \"\",\n            \"geometry\": \"\",\n            \"floorplan_set-TOTAL_FORMS\": \"0\",\n            \"floorplan_set-INITIAL_FORMS\": \"0\",\n            \"floorplan_set-MIN_NUM_FORMS\": \"0\",\n            \"floorplan_set-MAX_NUM_FORMS\": \"1000\",\n            \"_save\": \"Save\",\n        }\n        params.update(kwargs)\n        return params\n\n    def test_add_mobile_location(self):\n        self._login_as_admin()\n        url = reverse(\"{0}_location_add\".format(self.url_prefix))\n        params = self._get_location_add_params(is_mobile=\"on\")\n        response = self.client.post(url, params, follow=True)\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(self.location_model.objects.filter(is_mobile=True).count(), 1)\n\n    def test_add_non_mobile_location_without_geometry(self):\n        self._login_as_admin()\n        url = reverse(\"{0}_location_add\".format(self.url_prefix))\n        params = self._get_location_add_params()\n        response = self.client.post(url, params)\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"No geometry value provided.\")\n        self.assertEqual(self.location_model.objects.count(), 0)\n\n    # for users with view only permissions to floorplans\n    def test_readonly_floorplans(self):\n        user = self._create_readonly_admin(models=[self.floorplan_model])\n        self.client.force_login(user)\n        loc = self._create_location(name=\"test-admin-location-1\", type=\"indoor\")\n        fl = self._create_floorplan(location=loc)\n        url = reverse(\"{0}_floorplan_change\".format(self.url_prefix), args=[fl.pk])\n        r = self.client.get(url)\n        self.assertEqual(r.status_code, 200)\n        # assert if image is being rendered or not\n        self.assertContains(r, 'img src=\"{0}\"'.format(fl.image.url))\n        self.assertContains(r, f\"{loc.name} {ordinal(fl.floor)}\")\n        self.assertContains(r, fl.floor)\n        self.assertContains(r, loc.name)\n"
  },
  {
    "path": "django_loci/tests/base/test_admin_inline.py",
    "content": "from django.contrib.auth.models import Permission\nfrom django.contrib.gis.geos import GEOSGeometry\nfrom django.contrib.humanize.templatetags.humanize import ordinal\nfrom django.db.models.fields.files import ImageFieldFile\nfrom django.urls import reverse\n\nfrom .. import TestAdminInlineMixin, TestLociMixin\n\n\nclass BaseTestAdminInline(TestAdminInlineMixin, TestLociMixin):\n    permission_model = Permission\n\n    @classmethod\n    def _get_params(cls):\n        _p = cls._get_prefix()\n        return {\n            \"{0}-0-is_mobile\".format(_p): False,\n            \"{0}-0-name\".format(_p): \"Centro Piazza Venezia\",\n            \"{0}-0-address\".format(_p): \"Piazza Venezia, Roma, Italia\",\n            \"{0}-0-geometry\".format(\n                _p\n            ): '{\"type\": \"Point\", \"coordinates\": [12.512124, 41.898903]}',\n            \"{0}-TOTAL_FORMS\".format(_p): \"1\",\n            \"{0}-INITIAL_FORMS\".format(_p): \"0\",\n            \"{0}-MIN_NUM_FORMS\".format(_p): \"0\",\n            \"{0}-MAX_NUM_FORMS\".format(_p): \"1\",\n        }\n\n    @property\n    def params(self):\n        return self.__class__._get_params()\n\n    def test_json_urls(self):\n        self._login_as_admin()\n        r = self.client.get(reverse(self.add_url))\n        placeholder_pk = \"00000000-0000-0000-0000-000000000000\"\n        url = reverse(\"admin:django_loci_location_json\", args=[placeholder_pk])\n        self.assertContains(r, url)\n        url = reverse(\n            \"admin:django_loci_location_floorplans_json\", args=[placeholder_pk]\n        )\n        self.assertContains(r, url)\n\n    def test_add_outdoor_new(self):\n        self._login_as_admin()\n        p = self._get_prefix()\n        params = self.params\n        params.update(\n            {\n                \"name\": \"test-outdoor-add-new\",\n                \"{0}-0-type\".format(p): \"outdoor\",\n                \"{0}-0-location_selection\".format(p): \"new\",\n                \"{0}-0-location\".format(p): \"\",\n                \"{0}-0-floorplan_selection\".format(p): \"\",\n                \"{0}-0-floorplan\".format(p): \"\",\n                \"{0}-0-floor\".format(p): \"\",\n                \"{0}-0-image\".format(p): \"\",\n                \"{0}-0-indoor\".format(p): \"\",\n                \"{0}-0-id\".format(p): \"\",\n            }\n        )\n        r = self.client.post(reverse(self.add_url), params, follow=True)\n        self.assertNotContains(r, \"errors\")\n        loc = self.location_model.objects.get(name=params[\"{0}-0-name\".format(p)])\n        self.assertEqual(loc.address, params[\"{0}-0-address\".format(p)])\n        self.assertEqual(\n            loc.geometry.coords, GEOSGeometry(params[\"{0}-0-geometry\".format(p)]).coords\n        )\n        self.assertEqual(loc.objectlocation_set.count(), 1)\n        self.assertEqual(\n            loc.objectlocation_set.first().content_object.name, params[\"name\"]\n        )\n\n    def test_add_outdoor_existing(self):\n        self._login_as_admin()\n        p = self._get_prefix()\n        pre_loc = self._create_location()\n        params = self.params\n        params.update(\n            {\n                \"name\": \"test-outdoor-add-existing\",\n                \"{0}-0-type\".format(p): \"outdoor\",\n                \"{0}-0-location_selection\".format(p): \"existing\",\n                \"{0}-0-location\".format(p): pre_loc.id,\n                \"{0}-0-floorplan_selection\".format(p): \"\",\n                \"{0}-0-floorplan\".format(p): \"\",\n                \"{0}-0-floor\".format(p): \"\",\n                \"{0}-0-image\".format(p): \"\",\n                \"{0}-0-indoor\".format(p): \"\",\n                \"{0}-0-id\".format(p): \"\",\n            }\n        )\n        r = self.client.post(reverse(self.add_url), params, follow=True)\n        self.assertNotContains(r, \"errors\")\n        loc = self.location_model.objects.get(name=params[\"{0}-0-name\".format(p)])\n        self.assertEqual(pre_loc.id, loc.id)\n        self.assertEqual(loc.address, params[\"{0}-0-address\".format(p)])\n        self.assertEqual(\n            loc.geometry.coords, GEOSGeometry(params[\"{0}-0-geometry\".format(p)]).coords\n        )\n        self.assertEqual(loc.objectlocation_set.count(), 1)\n        self.assertEqual(\n            loc.objectlocation_set.first().content_object.name, params[\"name\"]\n        )\n        self.assertEqual(self.location_model.objects.count(), 1)\n\n    def test_change_outdoor(self):\n        self._login_as_admin()\n        p = self._get_prefix()\n        obj = self._create_object(name=\"test-change-outdoor\")\n        pre_loc = self._create_location()\n        ol = self._create_object_location(location=pre_loc, content_object=obj)\n        # -- ensure change form doesn't raise any exception\n        r = self.client.get(reverse(self.change_url, args=[obj.pk]))\n        self.assertContains(r, obj.name)\n        # -- post changes\n        params = self.params\n        params.update(\n            {\n                \"name\": obj.name,\n                \"{0}-0-type\".format(p): \"outdoor\",\n                \"{0}-0-location_selection\".format(p): \"existing\",\n                \"{0}-0-location\".format(p): pre_loc.id,\n                \"{0}-0-floorplan_selection\".format(p): \"\",\n                \"{0}-0-floorplan\".format(p): \"\",\n                \"{0}-0-floor\".format(p): \"\",\n                \"{0}-0-image\".format(p): \"\",\n                \"{0}-0-indoor\".format(p): \"\",\n                \"{0}-0-id\".format(p): ol.id,\n                \"{0}-INITIAL_FORMS\".format(p): \"1\",\n            }\n        )\n        r = self.client.post(\n            reverse(self.change_url, args=[obj.pk]), params, follow=True\n        )\n        self.assertNotContains(r, \"errors\")\n        loc = self.location_model.objects.get(name=params[\"{0}-0-name\".format(p)])\n        self.assertEqual(pre_loc.id, loc.id)\n        self.assertEqual(loc.address, params[\"{0}-0-address\".format(p)])\n        self.assertEqual(\n            loc.geometry.coords, GEOSGeometry(params[\"{0}-0-geometry\".format(p)]).coords\n        )\n        self.assertEqual(loc.objectlocation_set.count(), 1)\n        self.assertEqual(\n            loc.objectlocation_set.first().content_object.name, params[\"name\"]\n        )\n        self.assertEqual(self.location_model.objects.count(), 1)\n\n    def test_change_outdoor_to_different_location(self):\n        self._login_as_admin()\n        p = self._get_prefix()\n        ol = self._create_object_location()\n        new_loc = self._create_location(\n            name=\"different-location\",\n            address=\"Piazza Venezia, Roma, Italia\",\n            geometry=\"SRID=4326;POINT (12.512324 41.898703)\",\n        )\n        # -- post changes\n        params = self.params\n        changed_name = \"{0} changed\".format(new_loc.name)\n        params.update(\n            {\n                \"name\": \"test-outdoor-change-different\",\n                \"{0}-0-type\".format(p): \"outdoor\",\n                \"{0}-0-location_selection\".format(p): \"existing\",\n                \"{0}-0-location\".format(p): new_loc.id,\n                \"{0}-0-name\".format(p): changed_name,\n                \"{0}-0-address\".format(p): new_loc.address,\n                \"{0}-0-geometry\".format(p): new_loc.geometry.geojson,\n                \"{0}-0-floorplan_selection\".format(p): \"\",\n                \"{0}-0-floorplan\".format(p): \"\",\n                \"{0}-0-floor\".format(p): \"\",\n                \"{0}-0-image\".format(p): \"\",\n                \"{0}-0-indoor\".format(p): \"\",\n                \"{0}-0-id\".format(p): ol.id,\n                \"{0}-INITIAL_FORMS\".format(p): \"1\",\n            }\n        )\n        r = self.client.post(\n            reverse(self.change_url, args=[ol.content_object.pk]), params, follow=True\n        )\n        self.assertNotContains(r, \"errors\")\n        loc = self.location_model.objects.get(name=changed_name)\n        self.assertEqual(new_loc.id, loc.id)\n        self.assertEqual(loc.address, params[\"{0}-0-address\".format(p)])\n        self.assertEqual(\n            loc.geometry.coords, GEOSGeometry(params[\"{0}-0-geometry\".format(p)]).coords\n        )\n        self.assertEqual(loc.objectlocation_set.count(), 1)\n        self.assertEqual(\n            loc.objectlocation_set.first().content_object.name, params[\"name\"]\n        )\n        self.assertEqual(self.location_model.objects.count(), 2)\n\n    def test_add_indoor_new_location_new_floorplan(self):\n        self._login_as_admin()\n        p = self._get_prefix()\n        params = self.params\n        params.update(\n            {\n                \"name\": \"test-add-indoor-new-location-new-floorplan\",\n                \"{0}-0-type\".format(p): \"indoor\",\n                \"{0}-0-location_selection\".format(p): \"new\",\n                \"{0}-0-location\".format(p): \"\",\n                \"{0}-0-floorplan_selection\".format(p): \"new\",\n                \"{0}-0-floorplan\".format(p): \"\",\n                \"{0}-0-floor\".format(p): \"1\",\n                \"{0}-0-image\".format(p): self._get_simpleuploadedfile(),\n                \"{0}-0-indoor\".format(p): \"-100,100\",\n                \"{0}-0-id\".format(p): \"\",\n            }\n        )\n        r = self.client.post(reverse(self.add_url), params, follow=True)\n        self.assertNotContains(r, \"errors\")\n        loc = self.location_model.objects.get(name=params[\"{0}-0-name\".format(p)])\n        self.assertEqual(loc.address, params[\"{0}-0-address\".format(p)])\n        self.assertEqual(\n            loc.geometry.coords, GEOSGeometry(params[\"{0}-0-geometry\".format(p)]).coords\n        )\n        self.assertEqual(loc.objectlocation_set.count(), 1)\n        self.assertEqual(self.location_model.objects.count(), 1)\n        self.assertEqual(self.floorplan_model.objects.count(), 1)\n        ol = loc.objectlocation_set.first()\n        self.assertEqual(ol.content_object.name, params[\"name\"])\n        self.assertEqual(ol.location.type, \"indoor\")\n        self.assertEqual(ol.floorplan.floor, 1)\n        self.assertIsInstance(ol.floorplan.image, ImageFieldFile)\n        self.assertEqual(ol.indoor, \"-100,100\")\n\n    def test_add_indoor_existing_location_new_floorplan(self):\n        self._login_as_admin()\n        pre_loc = self._create_location(type=\"indoor\")\n        p = self._get_prefix()\n        params = self.params\n        name = \"test-add-indoor-existing-location-new-floorplan\"\n        params.update(\n            {\n                \"name\": name,\n                \"{0}-0-type\".format(p): \"indoor\",\n                \"{0}-0-location_selection\".format(p): \"existing\",\n                \"{0}-0-location\".format(p): pre_loc.id,\n                \"{0}-0-name\".format(p): pre_loc.name,\n                \"{0}-0-address\".format(p): pre_loc.address,\n                \"{0}-0-geometry\".format(p): pre_loc.geometry.geojson,\n                \"{0}-0-floorplan_selection\".format(p): \"new\",\n                \"{0}-0-floorplan\".format(p): \"\",\n                \"{0}-0-floor\".format(p): \"0\",\n                \"{0}-0-image\".format(p): self._get_simpleuploadedfile(),\n                \"{0}-0-indoor\".format(p): \"-100,100\",\n                \"{0}-0-id\".format(p): \"\",\n            }\n        )\n        r = self.client.post(reverse(self.add_url), params, follow=True)\n        # with open('test.html', 'w') as f:\n        #     f.write(r.content.decode())\n        self.assertNotContains(r, \"errors\")\n        loc = self.location_model.objects.get(name=params[\"{0}-0-name\".format(p)])\n        self.assertEqual(loc.address, params[\"{0}-0-address\".format(p)])\n        self.assertEqual(\n            loc.geometry.coords, GEOSGeometry(params[\"{0}-0-geometry\".format(p)]).coords\n        )\n        self.assertEqual(loc.objectlocation_set.count(), 1)\n        self.assertEqual(self.location_model.objects.count(), 1)\n        self.assertEqual(self.floorplan_model.objects.count(), 1)\n        ol = loc.objectlocation_set.first()\n        self.assertEqual(ol.content_object.name, params[\"name\"])\n        self.assertEqual(ol.location.type, \"indoor\")\n        self.assertEqual(ol.floorplan.floor, 0)\n        self.assertIsInstance(ol.floorplan.image, ImageFieldFile)\n        self.assertEqual(ol.indoor, \"-100,100\")\n\n    def test_add_indoor_existing_location_existing_floorplan(self):\n        self._login_as_admin()\n        pre_loc = self._create_location(type=\"indoor\")\n        pre_fl = self._create_floorplan(location=pre_loc, floor=2)\n        p = self._get_prefix()\n        params = self.params\n        name = \"test-add-indoor-existing-location-new-floorplan\"\n        params.update(\n            {\n                \"name\": name,\n                \"{0}-0-type\".format(p): \"indoor\",\n                \"{0}-0-location_selection\".format(p): \"existing\",\n                \"{0}-0-location\".format(p): pre_loc.id,\n                \"{0}-0-name\".format(p): name,\n                \"{0}-0-address\".format(p): pre_loc.address,\n                \"{0}-0-location-geometry\".format(p): pre_loc.geometry,\n                \"{0}-0-floorplan_selection\".format(p): \"existing\",\n                \"{0}-0-floorplan\".format(p): pre_fl.id,\n                \"{0}-0-floor\".format(p): 3,  # floor\n                \"{0}-0-image\".format(p): \"\",\n                \"{0}-0-indoor\".format(p): \"-110,110\",\n                \"{0}-0-id\".format(p): \"\",\n            }\n        )\n        r = self.client.post(reverse(self.add_url), params, follow=True)\n        self.assertNotContains(r, \"errors\")\n        loc = self.location_model.objects.get(name=name)\n        self.assertEqual(loc.id, pre_loc.id)\n        self.assertEqual(loc.address, params[\"{0}-0-address\".format(p)])\n        self.assertEqual(\n            loc.geometry.coords, GEOSGeometry(params[\"{0}-0-geometry\".format(p)]).coords\n        )\n        self.assertEqual(loc.objectlocation_set.count(), 1)\n        self.assertEqual(self.location_model.objects.count(), 1)\n        self.assertEqual(self.floorplan_model.objects.count(), 1)\n        ol = loc.objectlocation_set.first()\n        self.assertEqual(ol.content_object.name, params[\"name\"])\n        self.assertEqual(ol.location.type, \"indoor\")\n        self.assertEqual(ol.floorplan.id, pre_fl.id)\n        self.assertEqual(ol.floorplan.floor, 3)\n        self.assertIsInstance(ol.floorplan.image, ImageFieldFile)\n        self.assertEqual(ol.indoor, \"-110,110\")\n\n    def test_change_indoor(self):\n        self._login_as_admin()\n        p = self._get_prefix()\n        obj = self._create_object(name=\"test-change-indoor\")\n        pre_loc = self._create_location(type=\"indoor\")\n        pre_fl = self._create_floorplan(location=pre_loc)\n        ol = self._create_object_location(\n            content_object=obj, location=pre_loc, floorplan=pre_fl, indoor=\"-100,100\"\n        )\n        # -- ensure change form doesn't raise any exception\n        r = self.client.get(reverse(self.change_url, args=[obj.pk]))\n        self.assertContains(r, obj.name)\n        # -- post changes\n        params = self.params\n        changed_name = \"{0} changed\".format(pre_loc.name)\n        params.update(\n            {\n                \"name\": obj.name,\n                \"{0}-0-type\".format(p): \"indoor\",\n                \"{0}-0-location_selection\".format(p): \"existing\",\n                \"{0}-0-location\".format(p): pre_loc.id,\n                \"{0}-0-name\".format(p): changed_name,\n                \"{0}-0-address\".format(p): \"changed-address\",\n                \"{0}-0-location-geometry\".format(p): pre_loc.geometry,\n                \"{0}-0-floorplan_selection\".format(p): \"existing\",\n                \"{0}-0-floorplan\".format(p): pre_fl.id,\n                \"{0}-0-floor\".format(p): 3,  # floor\n                \"{0}-0-image\".format(p): self._get_simpleuploadedfile(),\n                \"{0}-0-indoor\".format(p): \"-110,110\",\n                \"{0}-0-id\".format(p): ol.id,\n                \"{0}-INITIAL_FORMS\".format(p): \"1\",\n            }\n        )\n        r = self.client.post(\n            reverse(self.change_url, args=[obj.pk]), params, follow=True\n        )\n        self.assertNotContains(r, \"errors\")\n        loc = self.location_model.objects.get(name=changed_name)\n        self.assertEqual(loc.id, pre_loc.id)\n        self.assertEqual(loc.address, \"changed-address\")\n        self.assertEqual(\n            loc.geometry.coords, GEOSGeometry(params[\"{0}-0-geometry\".format(p)]).coords\n        )\n        self.assertEqual(loc.objectlocation_set.count(), 1)\n        self.assertEqual(self.location_model.objects.count(), 1)\n        self.assertEqual(self.floorplan_model.objects.count(), 1)\n        ol = loc.objectlocation_set.first()\n        self.assertEqual(ol.content_object.name, params[\"name\"])\n        self.assertEqual(ol.location.type, \"indoor\")\n        self.assertEqual(ol.floorplan.id, pre_fl.id)\n        self.assertEqual(ol.floorplan.floor, 3)\n        self.assertIsInstance(ol.floorplan.image, ImageFieldFile)\n        self.assertEqual(ol.indoor, \"-110,110\")\n\n    def test_change_indoor_missing_indoor_position(self):\n        self._login_as_admin()\n        p = self._get_prefix()\n        obj = self._create_object(name=\"test-change-indoor\")\n        pre_loc = self._create_location(type=\"indoor\")\n        pre_fl = self._create_floorplan(location=pre_loc)\n        ol = self._create_object_location(\n            content_object=obj, location=pre_loc, floorplan=pre_fl, indoor=\"-100,100\"\n        )\n        # -- post changes\n        params = self.params\n        params.update(\n            {\n                \"name\": obj.name,\n                \"{0}-0-type\".format(p): \"indoor\",\n                \"{0}-0-location_selection\".format(p): \"existing\",\n                \"{0}-0-location\".format(p): pre_loc.id,\n                \"{0}-0-name\".format(p): pre_loc.name,\n                \"{0}-0-address\".format(p): pre_loc.address,\n                \"{0}-0-location-geometry\".format(p): pre_loc.geometry,\n                \"{0}-0-floorplan_selection\".format(p): \"existing\",\n                \"{0}-0-floorplan\".format(p): pre_fl.id,\n                \"{0}-0-floor\".format(p): pre_fl.floor,\n                \"{0}-0-indoor\".format(p): \"\",\n                \"{0}-0-id\".format(p): ol.id,\n                \"{0}-INITIAL_FORMS\".format(p): \"1\",\n            }\n        )\n        r = self.client.post(\n            reverse(self.change_url, args=[obj.pk]), params, follow=True\n        )\n        self.assertContains(r, \"errors field-indoor\")\n\n    def test_add_outdoor_invalid(self):\n        self._login_as_admin()\n        p = self._get_prefix()\n        params = self.params\n        params.update(\n            {\n                \"name\": \"test-outdoor-invalid\",\n                \"{0}-0-type\".format(p): \"outdoor\",\n                \"{0}-0-location_selection\".format(p): \"new\",\n                \"{0}-0-location\".format(p): \"\",\n                \"{0}-0-name\".format(p): \"\",\n                \"{0}-0-address\".format(p): \"\",\n                \"{0}-0-geometry\".format(p): \"\",\n            }\n        )\n        r = self.client.post(reverse(self.add_url), params, follow=True)\n        self.assertContains(r, \"errors field-name\")\n        self.assertContains(r, \"errors field-address\")\n        self.assertContains(r, \"errors field-geometry\")\n\n    def test_add_outdoor_invalid_geometry(self):\n        self._login_as_admin()\n        p = self._get_prefix()\n        params = self.params\n        params.update(\n            {\n                \"name\": \"test-outdoor-invalid-geometry\",\n                \"{0}-0-type\".format(p): \"outdoor\",\n                \"{0}-0-location_selection\".format(p): \"new\",\n                \"{0}-0-location\".format(p): \"\",\n                \"{0}-0-geometry\".format(p): \"INVALID\",\n            }\n        )\n        r = self.client.post(reverse(self.add_url), params, follow=True)\n        self.assertContains(r, \"errors field-geometry\")\n\n    def test_add_mobile(self):\n        self._login_as_admin()\n        p = self._get_prefix()\n        params = self.params\n        params.update(\n            {\n                \"name\": \"test-add-mobile\",\n                \"{0}-0-type\".format(p): \"outdoor\",\n                \"{0}-0-is_mobile\".format(p): True,\n                \"{0}-0-location_selection\".format(p): \"new\",\n                \"{0}-0-name\".format(p): \"\",\n                \"{0}-0-address\".format(p): \"\",\n                \"{0}-0-geometry\".format(p): \"\",\n            }\n        )\n        self.assertEqual(self.location_model.objects.count(), 0)\n        r = self.client.post(reverse(self.add_url), params, follow=True)\n        self.assertNotContains(r, \"errors\")\n        self.assertEqual(self.location_model.objects.filter(is_mobile=True).count(), 1)\n        self.assertEqual(self.object_location_model.objects.count(), 1)\n        loc = self.location_model.objects.first()\n        self.assertEqual(\n            loc.objectlocation_set.first().content_object.name, params[\"name\"]\n        )\n        self.assertEqual(loc.name, params[\"name\"])\n\n    def test_change_mobile(self):\n        self._login_as_admin()\n        obj = self._create_object(name=\"test-change-mobile\")\n        pre_loc = self._create_location(name=obj.name, is_mobile=True)\n        ol = self._create_object_location(content_object=obj, location=pre_loc)\n        p = self._get_prefix()\n        params = self.params\n        params.update(\n            {\n                \"name\": \"test-change-mobile\",\n                \"{0}-0-type\".format(p): \"outdoor\",\n                \"{0}-0-is_mobile\".format(p): True,\n                \"{0}-0-location\".format(p): pre_loc.id,\n                \"{0}-0-name\".format(p): \"\",\n                \"{0}-0-address\".format(p): \"\",\n                \"{0}-0-geometry\".format(p): \"\",\n                \"{0}-0-location_selection\".format(p): \"new\",\n                \"{0}-0-id\".format(p): ol.id,\n                \"{0}-INITIAL_FORMS\".format(p): \"1\",\n            }\n        )\n        self.assertEqual(self.location_model.objects.count(), 1)\n        r = self.client.post(\n            reverse(self.change_url, args=[obj.pk]), params, follow=True\n        )\n        self.assertNotContains(r, \"errors\")\n        self.assertEqual(self.object_location_model.objects.count(), 1)\n        self.assertEqual(self.location_model.objects.filter(is_mobile=True).count(), 1)\n        loc = self.location_model.objects.first()\n        self.assertEqual(\n            loc.objectlocation_set.first().content_object.name, params[\"name\"]\n        )\n\n    def test_remove_mobile(self):\n        self._login_as_admin()\n        p = self._get_prefix()\n        obj = self._create_object(name=\"test-remove-mobile\")\n        pre_loc = self._create_location(name=obj.name, is_mobile=True)\n        ol = self._create_object_location(content_object=obj, location=pre_loc)\n        # -- post changes\n        params = self.params\n        params.update(\n            {\n                \"name\": \"test-remove-mobile\",\n                \"{0}-0-type\".format(p): \"outdoor\",\n                \"{0}-0-is_mobile\".format(p): False,\n                \"{0}-0-location_selection\".format(p): \"existing\",\n                \"{0}-0-location\".format(p): pre_loc.id,\n                \"{0}-0-name\".format(p): pre_loc.name,\n                \"{0}-0-address\".format(p): pre_loc.address,\n                \"{0}-0-geometry\".format(p): pre_loc.geometry.geojson,\n                \"{0}-0-id\".format(p): ol.id,\n                \"{0}-INITIAL_FORMS\".format(p): \"1\",\n            }\n        )\n        r = self.client.post(\n            reverse(self.change_url, args=[ol.content_object.pk]), params, follow=True\n        )\n        self.assertNotContains(r, \"errors\")\n        self.assertEqual(self.location_model.objects.filter(is_mobile=False).count(), 1)\n        self.assertEqual(self.location_model.objects.count(), 1)\n\n    def test_change_indoor_missing_floorplan_pk(self):\n        self._login_as_admin()\n        p = self._get_prefix()\n        obj = self._create_object(name=\"test-floorplan-error\")\n        pre_loc = self._create_location(type=\"indoor\")\n        pre_fl = self._create_floorplan(location=pre_loc)\n        ol = self._create_object_location(\n            content_object=obj, location=pre_loc, floorplan=pre_fl, indoor=\"-100,100\"\n        )\n        # -- post changes\n        params = self.params\n        params.update(\n            {\n                \"name\": obj.name,\n                \"{0}-0-type\".format(p): \"indoor\",\n                \"{0}-0-location_selection\".format(p): \"existing\",\n                \"{0}-0-location\".format(p): pre_loc.id,\n                \"{0}-0-name\".format(p): pre_loc.name,\n                \"{0}-0-type\".format(p): pre_loc.type,\n                \"{0}-0-is_mobile\".format(p): pre_loc.is_mobile,\n                \"{0}-0-address\".format(p): pre_loc.address,\n                \"{0}-0-location-geometry\".format(p): pre_loc.geometry,\n                \"{0}-0-floorplan_selection\".format(p): \"existing\",\n                \"{0}-0-floorplan\".format(p): \"\",\n                \"{0}-0-floor\".format(p): pre_fl.floor,\n                \"{0}-0-indoor\".format(p): \"-100,100\",\n                \"{0}-0-id\".format(p): ol.id,\n                \"{0}-INITIAL_FORMS\".format(p): \"1\",\n            }\n        )\n        r = self.client.post(\n            reverse(self.change_url, args=[obj.pk]), params, follow=True\n        )\n        self.assertContains(r, \"errors field-floorplan\")\n        self.assertContains(r, \"No floorplan selected\")\n\n    def test_change_indoor_floorplan_doesnotexist(self):\n        self._login_as_admin()\n        p = self._get_prefix()\n        obj = self._create_object(name=\"test-floorplan-error\")\n        pre_loc = self._create_location(type=\"indoor\")\n        pre_fl = self._create_floorplan(location=pre_loc)\n        ol = self._create_object_location(\n            content_object=obj, location=pre_loc, floorplan=pre_fl, indoor=\"-100,100\"\n        )\n        # -- post changes\n        params = self.params\n        params.update(\n            {\n                \"name\": obj.name,\n                \"{0}-0-type\".format(p): \"indoor\",\n                \"{0}-0-location_selection\".format(p): \"existing\",\n                \"{0}-0-location\".format(p): pre_loc.id,\n                \"{0}-0-name\".format(p): pre_loc.name,\n                \"{0}-0-type\".format(p): pre_loc.type,\n                \"{0}-0-is_mobile\".format(p): pre_loc.is_mobile,\n                \"{0}-0-address\".format(p): pre_loc.address,\n                \"{0}-0-location-geometry\".format(p): pre_loc.geometry,\n                \"{0}-0-floorplan_selection\".format(p): \"existing\",\n                \"{0}-0-floorplan\".format(p): self.floorplan_model().id,\n                \"{0}-0-floor\".format(p): pre_fl.floor,\n                \"{0}-0-indoor\".format(p): \"-100,100\",\n                \"{0}-0-id\".format(p): ol.id,\n                \"{0}-INITIAL_FORMS\".format(p): \"1\",\n            }\n        )\n        r = self.client.post(\n            reverse(self.change_url, args=[obj.pk]), params, follow=True\n        )\n        self.assertContains(r, \"errors field-floorplan\")\n        self.assertContains(r, \"Selected floorplan does not exist\")\n\n    def test_change_indoor_floorplan_different_location(self):\n        self._login_as_admin()\n        p = self._get_prefix()\n        obj = self._create_object(name=\"test-floorplan-error\")\n        pre_loc = self._create_location(type=\"indoor\")\n        pre_fl = self._create_floorplan(location=pre_loc)\n        ol = self._create_object_location(\n            content_object=obj, location=pre_loc, floorplan=pre_fl, indoor=\"-100,100\"\n        )\n        fl = self._create_floorplan()\n        # -- post changes\n        params = self.params\n        params.update(\n            {\n                \"name\": obj.name,\n                \"{0}-0-type\".format(p): \"indoor\",\n                \"{0}-0-location_selection\".format(p): \"existing\",\n                \"{0}-0-location\".format(p): pre_loc.id,\n                \"{0}-0-name\".format(p): pre_loc.name,\n                \"{0}-0-address\".format(p): pre_loc.address,\n                \"{0}-0-location-geometry\".format(p): pre_loc.geometry,\n                \"{0}-0-floorplan_selection\".format(p): \"existing\",\n                \"{0}-0-floorplan\".format(p): fl.id,\n                \"{0}-0-floor\".format(p): pre_fl.floor,\n                \"{0}-0-indoor\".format(p): \"-100,100\",\n                \"{0}-0-id\".format(p): ol.id,\n                \"{0}-INITIAL_FORMS\".format(p): \"1\",\n            }\n        )\n        r = self.client.post(\n            reverse(self.change_url, args=[obj.pk]), params, follow=True\n        )\n        self.assertContains(r, \"errors field-floorplan\")\n        self.assertContains(r, \"This floorplan is associated to a different location\")\n\n    def test_missing_type_error(self):\n        self._login_as_admin()\n        p = self._get_prefix()\n        params = self.params\n        params.update(\n            {\n                \"name\": \"test-outdoor-add-new\",\n                \"{0}-0-type\".format(p): \"\",\n                \"{0}-0-location_selection\".format(p): \"new\",\n                \"{0}-0-location\".format(p): \"\",\n                \"{0}-0-floorplan_selection\".format(p): \"\",\n                \"{0}-0-floorplan\".format(p): \"\",\n                \"{0}-0-floor\".format(p): \"\",\n                \"{0}-0-image\".format(p): \"\",\n                \"{0}-0-indoor\".format(p): \"\",\n                \"{0}-0-id\".format(p): \"\",\n            }\n        )\n        r = self.client.post(reverse(self.add_url), params, follow=True)\n        self.assertContains(r, \"errors field-type\")\n\n    def test_add_indoor_location_without_indoor_coords(self):\n        self._login_as_admin()\n        p = self._get_prefix()\n        params = self.params\n        params.update(\n            {\n                \"name\": \"test-add-indoor-location-without-coords\",\n                \"{0}-0-type\".format(p): \"indoor\",\n                \"{0}-0-location_selection\".format(p): \"new\",\n                \"{0}-0-location\".format(p): \"\",\n                \"{0}-0-floorplan_selection\".format(p): \"new\",\n                \"{0}-0-floorplan\".format(p): \"\",\n                \"{0}-0-floor\".format(p): \"\",\n                \"{0}-0-image\".format(p): \"\",\n                \"{0}-0-indoor\".format(p): \"\",\n                \"{0}-0-id\".format(p): \"\",\n            }\n        )\n        r = self.client.post(reverse(self.add_url), params, follow=True)\n        self.assertNotContains(r, \"errors\")\n        loc = self.location_model.objects.get(name=params[\"{0}-0-name\".format(p)])\n        self.assertEqual(loc.address, params[\"{0}-0-address\".format(p)])\n        self.assertEqual(loc.objectlocation_set.count(), 1)\n        self.assertEqual(self.location_model.objects.count(), 1)\n        self.assertEqual(self.floorplan_model.objects.count(), 0)\n        ol = loc.objectlocation_set.first()\n        self.assertEqual(ol.content_object.name, params[\"name\"])\n        self.assertEqual(ol.location.type, \"indoor\")\n        self.assertEqual(ol.indoor, \"\")\n\n    def test_add_indoor_mobile_location_without_floor(self):\n        self._login_as_admin()\n        p = self._get_prefix()\n        params = self.params\n        params.update(\n            {\n                \"name\": \"test-add-indoor-mobile-location-without-floor\",\n                \"{0}-0-type\".format(p): \"indoor\",\n                \"{0}-0-location_selection\".format(p): \"new\",\n                \"{0}-0-is_mobile\".format(p): True,\n                \"{0}-0-location\".format(p): \"\",\n                \"{0}-0-floorplan_selection\".format(p): \"new\",\n                \"{0}-0-floorplan\".format(p): \"\",\n                \"{0}-0-floor\".format(p): \"\",\n                \"{0}-0-image\".format(p): self._get_simpleuploadedfile(),\n                \"{0}-0-indoor\".format(p): \"\",\n                \"{0}-0-id\".format(p): \"\",\n            }\n        )\n        r = self.client.post(reverse(self.add_url), params, follow=True)\n        self.assertContains(r, \"errors field-floor\")\n        loc = self.location_model.objects.filter(name=params[\"{0}-0-name\".format(p)])\n        self.assertEqual(loc.count(), 0)\n\n    def test_add_indoor_location_without_coords(self):\n        self._login_as_admin()\n        p = self._get_prefix()\n        params = self.params\n        params.update(\n            {\n                \"name\": \"test-add-indoor-location-without-coords\",\n                \"{0}-0-type\".format(p): \"indoor\",\n                \"{0}-0-location_selection\".format(p): \"new\",\n                \"{0}-0-location\".format(p): \"\",\n                \"{0}-0-floorplan_selection\".format(p): \"new\",\n                \"{0}-0-floorplan\".format(p): \"\",\n                \"{0}-0-floor\".format(p): \"1\",\n                \"{0}-0-image\".format(p): self._get_simpleuploadedfile(),\n                \"{0}-0-indoor\".format(p): \"\",\n                \"{0}-0-id\".format(p): \"\",\n            }\n        )\n        r = self.client.post(reverse(self.add_url), params, follow=True)\n        self.assertContains(r, \"error\")\n        loc = self.location_model.objects.filter(name=params[\"{0}-0-name\".format(p)])\n        self.assertEqual(loc.count(), 0)\n\n    def test_add_indoor_location_without_floor(self):\n        p = self._get_prefix()\n        params = self.params\n        params.update(\n            {\n                \"name\": \"test-add-indoor-location-without-coords\",\n                \"{0}-0-type\".format(p): \"indoor\",\n                \"{0}-0-location_selection\".format(p): \"new\",\n                \"{0}-0-location\".format(p): \"\",\n                \"{0}-0-floorplan_selection\".format(p): \"new\",\n                \"{0}-0-floorplan\".format(p): \"\",\n                \"{0}-0-floor\".format(p): \"\",\n                \"{0}-0-image\".format(p): self._get_simpleuploadedfile(),\n                \"{0}-0-indoor\".format(p): \"-100,100\",\n                \"{0}-0-id\".format(p): \"\",\n            }\n        )\n        r = self.client.post(reverse(self.add_url), params)\n        self.assertEqual(r.status_code, 302)\n\n    # Test for ensuring that the floorplan is not created when the location is outdoor\n    def test_add_outdoor_with_floorplan(self):\n        self._login_as_admin()\n        p = \"floorplan_set\"\n        params = self.params\n        params.update(\n            {\n                \"name\": \"test-add-outdoor-with-floorplan\",\n                \"type\": \"outdoor\",\n                \"geometry\": \"SRID=4326;POINT (12.512324 41.898703)\",\n                \"address\": \"Piazza Venezia, Roma, Italia\",\n                \"{0}-0-floor\".format(p): \"1\",\n                \"{0}-0-image\".format(p): self._get_simpleuploadedfile(),\n                \"{0}-0-id\".format(p): \"\",\n                \"{0}-0-location\".format(p): \"\",\n                \"{0}-TOTAL_FORMS\".format(p): \"1\",\n                \"{0}-INITIAL_FORMS\".format(p): \"0\",\n                \"{0}-MIN_NUM_FORMS\".format(p): \"0\",\n                \"{0}-MAX_NUM_FORMS\".format(p): \"1\",\n            }\n        )\n        location_url = \"{0}_{1}_add\".format(\n            self.url_prefix, self.location_model.__name__.lower()\n        )\n        r = self.client.post(reverse(location_url), params, follow=True)\n        self.assertNotContains(r, \"errors\")\n        self.assertNotContains(r, \"errornote\")\n        loc = self.location_model.objects.get(name=params[\"name\"])\n        self.assertEqual(loc.address, params[\"address\"])\n        self.assertEqual(loc.geometry.coords, GEOSGeometry(params[\"geometry\"]).coords)\n        self.assertEqual(self.location_model.objects.count(), 1)\n        self.assertEqual(self.floorplan_model.objects.count(), 0)\n\n    # Test for ensuring that location is changed from outdoor to indoor, with related floorplans created\n    def test_device_change_location_from_outdoor_to_indoor(self):\n        self._login_as_admin()\n        p = self._get_prefix()\n        pre_loc = self._create_location()\n        obj = self._create_object()\n        ol = self._create_object_location(location=pre_loc, content_object=obj)\n        params = self.params\n        params.update(\n            {\n                \"name\": obj.name,\n                \"{0}-0-type\".format(p): \"indoor\",\n                \"{0}-0-location_selection\".format(p): \"existing\",\n                \"{0}-0-location\".format(p): pre_loc.id,\n                \"{0}-0-name\".format(p): pre_loc.name,\n                \"{0}-0-address\".format(p): pre_loc.address,\n                \"{0}-0-location-geometry\".format(p): pre_loc.geometry,\n                \"{0}-0-floorplan_selection\".format(p): \"new\",\n                \"{0}-0-floorplan\".format(p): \"\",\n                \"{0}-0-floor\".format(p): \"1\",\n                \"{0}-0-image\".format(p): self._get_simpleuploadedfile(),\n                \"{0}-0-indoor\".format(p): \"-100,100\",\n                \"{0}-0-id\".format(p): ol.id,\n                \"{0}-INITIAL_FORMS\".format(p): \"1\",\n            }\n        )\n        r = self.client.post(\n            reverse(self.change_url, args=[obj.pk]), params, follow=True\n        )\n        self.assertNotContains(r, \"errors\")\n        self.assertEqual(self.location_model.objects.count(), 1)\n        self.assertEqual(self.object_location_model.objects.count(), 1)\n        self.assertEqual(self.location_model.objects.first().type, \"indoor\")\n        self.assertEqual(self.location_model.objects.first().floorplan_set.count(), 1)\n        self.assertIsNotNone(self.object_location_model.objects.first().floorplan)\n        self.assertEqual(\n            self.location_model.objects.first().objectlocation_set.count(), 1\n        )\n        self.assertEqual(\n            self.location_model.objects.first()\n            .objectlocation_set.first()\n            .content_object,\n            obj,\n        )\n\n    # Test for ensuring that location is changed from indoor to outdoor, with related floorplans removed\n    def test_device_change_location_from_indoor_to_outdoor(self):\n        self._login_as_admin()\n        p = self._get_prefix()\n        pre_loc = self._create_location(type=\"indoor\")\n        obj = self._create_object()\n        ol = self._create_object_location(location=pre_loc, content_object=obj)\n        params = self.params\n        params.update(\n            {\n                \"name\": obj.name,\n                \"{0}-0-type\".format(p): \"outdoor\",\n                \"{0}-0-location_selection\".format(p): \"existing\",\n                \"{0}-0-location\".format(p): pre_loc.id,\n                \"{0}-0-name\".format(p): pre_loc.name,\n                \"{0}-0-address\".format(p): pre_loc.address,\n                \"{0}-0-location-geometry\".format(p): pre_loc.geometry,\n                \"{0}-0-id\".format(p): ol.id,\n                \"{0}-INITIAL_FORMS\".format(p): \"1\",\n            }\n        )\n        r = self.client.post(\n            reverse(self.change_url, args=[obj.pk]), params, follow=True\n        )\n        self.assertNotContains(r, \"errors\")\n        self.assertEqual(self.location_model.objects.count(), 1)\n        self.assertEqual(self.object_location_model.objects.count(), 1)\n        self.assertEqual(self.location_model.objects.first().type, \"outdoor\")\n        self.assertEqual(self.location_model.objects.first().floorplan_set.count(), 0)\n        self.assertIsNone(self.object_location_model.objects.first().floorplan)\n        self.assertEqual(\n            self.location_model.objects.first().objectlocation_set.count(), 1\n        )\n        self.assertEqual(\n            self.location_model.objects.first()\n            .objectlocation_set.first()\n            .content_object,\n            obj,\n        )\n\n    # for users with view only permissions to location\n    def test_readonly_indoor_location(self):\n        user = self._create_readonly_admin(\n            models=[self.location_model, self.floorplan_model]\n        )\n        self.client.force_login(user)\n        loc = self._create_location(name=\"test-admin-location-1\", type=\"indoor\")\n        fl = self._create_floorplan(location=loc)\n        url = reverse(\"{0}_location_change\".format(self.url_prefix), args=[loc.pk])\n        r = self.client.get(url)\n        self.assertEqual(r.status_code, 200)\n        # assert if map is being rendered or not\n        self.assertContains(r, \"geometry-div-map\")\n        # assert if inline fields are visible\n        self.assertContains(r, f\"{loc.name} {ordinal(fl.floor)}\")\n        self.assertContains(r, fl.floor)\n        self.assertContains(r, fl.image.url)\n        self.assertContains(r, loc.name)\n        self.assertContains(r, loc.address)\n        self.assertContains(r, loc.type)\n        self.assertContains(r, loc.is_mobile)\n\n    # for users with view only permissions to objectlocation\n    def test_readonly_indoor_object_location(self):\n        user = self._create_readonly_admin(\n            models=[self.object_model, self.object_location_model]\n        )\n        self.client.force_login(user)\n        obj = self._create_object(name=\"test-admin-object-1\")\n        loc = self._create_location(name=\"test-admin-location-1\", type=\"indoor\")\n        fl = self._create_floorplan(location=loc)\n        ol = self._create_object_location(\n            content_object=obj, location=loc, floorplan=fl\n        )\n        r = self.client.get(reverse(self.change_url, args=[obj.pk]))\n        self.assertEqual(r.status_code, 200)\n        # assert if map is being rendered or not\n        self.assertContains(r, \"geometry-div-map\")\n        self.assertContains(r, \"id_indoor_map\")\n        # id is required for indoor map to render\n        self.assertContains(r, 'id=\"id_indoor\"')\n        # assert if inline fields are visible\n        self.assertContains(r, f\"{loc.name} {ordinal(fl.floor)} floor\")\n        self.assertContains(r, fl.floor)\n        self.assertContains(r, fl.image.url)\n        self.assertContains(r, loc.name)\n        self.assertContains(r, loc.address)\n        self.assertContains(r, loc.type)\n        self.assertContains(r, loc.is_mobile)\n        self.assertContains(r, obj.name)\n        self.assertContains(r, ol.indoor)\n"
  },
  {
    "path": "django_loci/tests/base/test_apps.py",
    "content": "from unittest.mock import patch\n\nfrom django.conf import settings\nfrom django.core.checks import Warning\n\nfrom ...apps import test_geocoding\nfrom .. import TestLociMixin\n\n\nclass BaseTestApps(TestLociMixin):\n    @patch(\"django_loci.apps.geocode\", return_value=None)\n    @patch.object(settings, \"TESTING\", False)\n    def test_geocode_strict(self, geocode_mocked):\n        warning = test_geocoding()\n        self.assertEqual(\n            warning,\n            [\n                Warning(\n                    \"Geocoding service is experiencing issues or is not properly configured\"\n                )\n            ],\n        )\n        geocode_mocked.assert_called_once()\n"
  },
  {
    "path": "django_loci/tests/base/test_channels.py",
    "content": "# use pytest\nimport pytest\nfrom channels.db import database_sync_to_async\nfrom channels.routing import ProtocolTypeRouter\nfrom django.contrib.auth.models import Permission\n\nfrom django_loci.channels.consumers import CommonLocationBroadcast, LocationBroadcast\n\nfrom ...channels.base import _get_object_or_none\nfrom .. import TestAdminMixin, TestChannelsMixin, TestLociMixin\n\n\nclass BaseTestChannels(TestAdminMixin, TestLociMixin, TestChannelsMixin):\n    \"\"\"\n    In channels 2.x, Websockets can only be tested\n    asynchronously, hence, pytest is used for these tests.\n    \"\"\"\n\n    location_consumer = LocationBroadcast\n    common_location_consumer = CommonLocationBroadcast\n\n    @pytest.mark.django_db(transaction=True)\n    def test_object_or_none(self):\n        result = _get_object_or_none(self.location_model, pk=1)\n        assert result is None\n        plausible_pk = self.location_model().pk\n        result = _get_object_or_none(self.location_model, pk=plausible_pk)\n        assert result is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.django_db(transaction=True)\n    async def test_consumer_unauthenticated(self):\n        request_vars = await self._get_specific_location_request_dict()\n        communicator = self._get_specific_location_communicator(request_vars)\n        connected, _ = await communicator.connect()\n        assert not connected\n        await communicator.disconnect()\n\n    @pytest.mark.asyncio\n    @pytest.mark.django_db(transaction=True)\n    async def test_common_location_consumer_unauthenticated(self):\n        request_vars = await self._get_common_location_request_dict()\n        communicator = self._get_common_location_communicator(request_vars)\n        connected, _ = await communicator.connect()\n        assert not connected\n        await communicator.disconnect()\n\n    @pytest.mark.asyncio\n    @pytest.mark.django_db(transaction=True)\n    async def test_connect_admin(self):\n        test_user = await database_sync_to_async(self._create_admin)()\n        request_vars = await self._get_specific_location_request_dict(user=test_user)\n        communicator = self._get_specific_location_communicator(request_vars, test_user)\n        connected, _ = await communicator.connect()\n        assert connected\n        await communicator.disconnect()\n\n    @pytest.mark.asyncio\n    @pytest.mark.django_db(transaction=True)\n    async def test_common_location_connect_admin(self):\n        test_user = await database_sync_to_async(self._create_admin)()\n        request_vars = await self._get_common_location_request_dict(user=test_user)\n        communicator = self._get_common_location_communicator(request_vars, test_user)\n        connected, _ = await communicator.connect()\n        assert connected\n        await communicator.disconnect()\n\n    @pytest.mark.asyncio\n    @pytest.mark.django_db(transaction=True)\n    async def test_consumer_not_staff(self):\n        test_user = await database_sync_to_async(self.user_model.objects.create_user)(\n            username=\"user\", password=\"password\", email=\"test@test.org\"\n        )\n        request_vars = await self._get_specific_location_request_dict(user=test_user)\n        communicator = self._get_specific_location_communicator(request_vars, test_user)\n        connected, _ = await communicator.connect()\n        assert not connected\n        await communicator.disconnect()\n\n    @pytest.mark.asyncio\n    @pytest.mark.django_db(transaction=True)\n    async def test_common_location_consumer_not_staff(self):\n        test_user = await database_sync_to_async(self.user_model.objects.create_user)(\n            username=\"user\", password=\"password\", email=\"test@test.org\"\n        )\n        request_vars = await self._get_common_location_request_dict(user=test_user)\n        communicator = self._get_common_location_communicator(request_vars, test_user)\n        connected, _ = await communicator.connect()\n        assert not connected\n        await communicator.disconnect()\n\n    @pytest.mark.asyncio\n    @pytest.mark.django_db(transaction=True)\n    async def test_consumer_404(self):\n        pk = self.location_model().pk\n        admin = await database_sync_to_async(self._create_admin)()\n        request_vars = await self._get_specific_location_request_dict(pk=pk, user=admin)\n        communicator = self._get_specific_location_communicator(request_vars, admin)\n        connected, _ = await communicator.connect()\n        assert not connected\n\n    @pytest.mark.asyncio\n    @pytest.mark.django_db(transaction=True)\n    async def test_consumer_staff_but_no_change_permission(self):\n        test_user = await database_sync_to_async(self.user_model.objects.create_user)(\n            username=\"user\", password=\"password\", email=\"test@test.org\", is_staff=True\n        )\n        location = await database_sync_to_async(self._create_location)(is_mobile=True)\n        ol = await database_sync_to_async(self._create_object_location)(\n            location=location\n        )\n        pk = ol.location.pk\n        request_vars = await self._get_specific_location_request_dict(\n            pk=pk, user=test_user\n        )\n        communicator = self._get_specific_location_communicator(request_vars, test_user)\n        connected, _ = await communicator.connect()\n        assert not connected\n        await communicator.disconnect()\n\n        # add permission to change location and repeat\n        loc_perm = await Permission.objects.filter(\n            codename=f\"change_{self.location_model._meta.model_name}\"\n        ).afirst()\n        await test_user.user_permissions.aadd(loc_perm)\n        test_user = await self.user_model.objects.aget(pk=test_user.pk)\n        communicator = self._get_specific_location_communicator(request_vars, test_user)\n        connected, _ = await communicator.connect()\n        assert connected\n        await communicator.disconnect()\n\n    @pytest.mark.asyncio\n    @pytest.mark.django_db(transaction=True)\n    async def test_common_location_consumer_staff_but_no_change_permission(self):\n        test_user = await database_sync_to_async(self.user_model.objects.create_user)(\n            username=\"user\", password=\"password\", email=\"test@test.org\", is_staff=True\n        )\n        location = await database_sync_to_async(self._create_location)(is_mobile=True)\n        await database_sync_to_async(self._create_object_location)(location=location)\n        request_vars = await self._get_common_location_request_dict(user=test_user)\n        communicator = self._get_common_location_communicator(request_vars, test_user)\n        connected, _ = await communicator.connect()\n        assert not connected\n        await communicator.disconnect()\n        # add permission to change location and repeat\n        loc_perm = await Permission.objects.filter(\n            codename=f\"change_{self.location_model._meta.model_name}\"\n        ).afirst()\n        await test_user.user_permissions.aadd(loc_perm)\n        test_user = await self.user_model.objects.aget(pk=test_user.pk)\n        communicator = self._get_common_location_communicator(request_vars, test_user)\n        connected, _ = await communicator.connect()\n        assert connected\n        await communicator.disconnect()\n\n    @pytest.mark.asyncio\n    @pytest.mark.django_db(transaction=True)\n    async def test_location_update(self):\n        test_user = await database_sync_to_async(self._create_admin)()\n        request_vars = await self._get_specific_location_request_dict(user=test_user)\n        communicator = self._get_specific_location_communicator(request_vars, test_user)\n        connected, _ = await communicator.connect()\n        assert connected\n        await self._save_location(request_vars[\"pk\"])\n        response = await communicator.receive_json_from()\n        assert response == {\n            \"geometry\": {\"type\": \"Point\", \"coordinates\": [12.513124, 41.897903]},\n            \"address\": \"Via del Corso, Roma, Italia\",\n        }\n        await communicator.disconnect()\n\n    @pytest.mark.asyncio\n    @pytest.mark.django_db(transaction=True)\n    async def test_common_location_update(self):\n        test_user = await database_sync_to_async(self._create_admin)()\n        location1 = await database_sync_to_async(self._create_location)(is_mobile=True)\n        await database_sync_to_async(self._create_object_location)(location=location1)\n        location2 = await database_sync_to_async(self._create_location)(is_mobile=True)\n        await database_sync_to_async(self._create_object_location)(location=location2)\n        request_vars = await self._get_common_location_request_dict(\n            pk=location1.pk, user=test_user\n        )\n        communicator = self._get_common_location_communicator(request_vars, test_user)\n        connected, _ = await communicator.connect()\n        assert connected\n        await self._save_location(request_vars[\"pk\"])\n        response = await communicator.receive_json_from()\n        assert response == {\n            \"id\": str(location1.pk),\n            \"geometry\": {\"type\": \"Point\", \"coordinates\": [12.513124, 41.897903]},\n            \"address\": \"Via del Corso, Roma, Italia\",\n            \"name\": location1.name,\n            \"type\": location1.type,\n            \"is_mobile\": True,\n        }\n        await self._save_location(location2.pk)\n        response = await communicator.receive_json_from()\n        assert response == {\n            \"id\": str(location2.pk),\n            \"geometry\": {\"type\": \"Point\", \"coordinates\": [12.513124, 41.897903]},\n            \"address\": \"Via del Corso, Roma, Italia\",\n            \"name\": location2.name,\n            \"type\": location2.type,\n            \"is_mobile\": True,\n        }\n        await communicator.disconnect()\n\n    def test_routing(self):\n        from django_loci.channels.asgi import channel_routing\n\n        assert isinstance(channel_routing, ProtocolTypeRouter)\n"
  },
  {
    "path": "django_loci/tests/base/test_models.py",
    "content": "from django.core.exceptions import ValidationError\n\nfrom .. import TestLociMixin\n\n\nclass BaseTestModels(TestLociMixin):\n    def test_location_str(self):\n        loc = self.location_model(name=\"test-location\")\n        self.assertEqual(str(loc), loc.name)\n\n    def test_floorplan_str(self):\n        loc = self._create_location()\n        fl = self.floorplan_model(location=loc, floor=2)\n        self.assertEqual(str(fl), \"test-location 2nd floor\")\n        fl.floor = 0\n        self.assertEqual(str(fl), \"test-location ground floor\")\n\n    def test_object_location_clean_location(self):\n        l1 = self._create_location(type=\"indoor\")\n        l2 = self._create_location(type=\"indoor\")\n        fl2 = self._create_floorplan(location=l2)\n        obj = self._create_object()\n        ol = self.object_location_model(content_object=obj, location=l1, floorplan=fl2)\n        try:\n            ol.full_clean()\n        except ValidationError as e:\n            self.assertIn(\"__all__\", e.message_dict)\n            self.assertEqual(\n                e.message_dict.get(\"__all__\")[0],\n                \"Invalid floorplan (belongs to a different location)\",\n            )\n        else:\n            self.fail(\"ValidationError not raised\")\n\n    def test_floorplan_image(self):\n        fl = self._create_floorplan()\n        path = fl.image.file.name.split(\"/\")\n        name = path[-1]\n        dir_ = path[-2]\n        self.assertEqual(name, \"{0}.jpg\".format(fl.id))\n        self.assertEqual(dir_, \"floorplans\")\n        # overwrite\n        fl.image = self._get_simpleuploadedfile()\n        fl.full_clean()\n        fl.save()\n        path = fl.image.file.name.split(\"/\")\n        name = path[-1]\n        dir_ = path[-2]\n        self.assertEqual(name, \"{0}.jpg\".format(fl.id))\n        self.assertEqual(dir_, \"floorplans\")\n        # delete\n        image_path = fl.image.file.name\n        fl.delete()\n        self.assertFalse(fl.image.storage.exists(image_path))\n\n    def test_floorplan_delete_corner_case(self):\n        fl = self._create_floorplan()\n        fl.image.storage.delete(fl.image.file.name)\n        # there should be no failure\n        fl.delete()\n\n    def test_floorplan_association_validation(self):\n        outdoor = self._create_location(type=\"outdoor\")\n        try:\n            self._create_floorplan(location=outdoor)\n        except ValidationError as e:\n            err_str = str(e)\n            self.assertIn(\"floorplans can only be associated to\", err_str)\n            self.assertIn(\"indoor\", err_str)\n        else:\n            self.fail(\"ValidationError not raised\")\n\n    def test_geometry_if_not_mobile(self):\n        try:\n            self._create_location(geometry=None)\n        except ValidationError as e:\n            err_str = str(e)\n            self.assertIn(\"No geometry value provided.\", err_str)\n        else:\n            self.fail(\"ValidationError not raised\")\n\n    def test_geometry_if_mobile(self):\n        try:\n            self._create_location(is_mobile=True, geometry=None)\n        except ValidationError:\n            self.fail(\"Unexpected ValidationError raised\")\n\n    # changing location type from indoor to outdoor, deletes floorplans\n    def test_location_change_indoor_to_outdoor(self):\n        fl = self._create_floorplan()\n        location = fl.location\n        location.type = \"outdoor\"\n        location.save()\n        self.assertEqual(location.floorplan_set.count(), 0)\n\n    # similar to 'test_location_change_indoor_to_outdoor' but with object location\n    def test_object_location_change_indoor_to_outdoor(self):\n        l1 = self._create_location(type=\"indoor\")\n        fl = self._create_floorplan(location=l1)\n        obj = self._create_object()\n        ol = self.object_location_model(\n            content_object=obj, location=l1, floorplan=fl, indoor=\"-100,100\"\n        )\n        ol.full_clean()\n        ol.save()\n        l1.type = \"outdoor\"\n        l1.save()\n        # refetching again to check if object location is updated\n        ol = self.object_location_model.objects.get(pk=ol.pk)\n        self.assertIsNone(ol.floorplan)\n        self.assertIsNone(ol.indoor)\n        self.assertEqual(ol.location.type, \"outdoor\")\n        self.assertEqual(ol.location.floorplan_set.count(), 0)\n\n    def _test_indoor_position_validation_error(self, ol):\n        try:\n            ol.full_clean()\n        except ValidationError as e:\n            self.assertIn(\"indoor\", e.message_dict)\n            self.assertIn(\"invalid value\", e.message_dict[\"indoor\"])\n        else:\n            self.fail(\"ValidationError not raised\")\n\n    def test_invalid_indoor_position(self):\n        loc = self._create_location(type=\"indoor\")\n        ol = self._create_object_location(location=loc)\n        ol.indoor = \"TOTALLYWRONG\"\n        self._test_indoor_position_validation_error(ol)\n        ol.indoor = \"WRONG,WRONG\"\n        self._test_indoor_position_validation_error(ol)\n        ol.indoor = \"10,WRONG\"\n        self._test_indoor_position_validation_error(ol)\n        ol.indoor = \"WRONG,10\"\n        self._test_indoor_position_validation_error(ol)\n        ol.indoor = \"10,10.10,100\"\n        self._test_indoor_position_validation_error(ol)\n        ol.indoor = \"TOTALLY.WRONG\"\n        self._test_indoor_position_validation_error(ol)\n        ol.indoor = \"100.2300,-45.23454\"\n        ol.full_clean()\n        # allow empty indoor for location whose indoor coordinates are not yet received\n        ol.indoor = \"\"\n        ol.full_clean()\n        ol.save()\n        ol.indoor = None\n        ol.full_clean()\n        ol.save()\n        # outdoor allows empty but not invalid values\n        loc.type = \"outdoor\"\n        loc.full_clean()\n        loc.save()\n        ol.indoor = None\n        ol.full_clean()\n        ol.indoor = \"\"\n        ol.full_clean()\n        ol.indoor = \"TOTALLY.WRONG\"\n        self._test_indoor_position_validation_error(ol)\n        # outdoor does not allow valid indoor positions\n        ol.indoor = \"100.2300,-45.23454\"\n        self._test_indoor_position_validation_error(ol)\n"
  },
  {
    "path": "django_loci/tests/base/test_selenium.py",
    "content": "from time import sleep\n\nfrom django.urls.base import reverse\nfrom selenium.webdriver import ActionChains\nfrom selenium.webdriver.common.by import By\nfrom selenium.webdriver.support import expected_conditions as EC\nfrom selenium.webdriver.support.ui import Select, WebDriverWait\n\nfrom openwisp_utils.tests import SeleniumTestMixin\n\nfrom .. import TestAdminInlineMixin, TestLociMixin\n\n\nclass BaseTestDeviceAdminSelenium(\n    SeleniumTestMixin, TestAdminInlineMixin, TestLociMixin\n):\n    app_label = \"django_loci\"\n\n    def _fill_device_form(self):\n        \"\"\"\n        This method can be extended by downstream implementations\n        needing more complex logic to fill the device form\n        \"\"\"\n        self.find_element(by=By.NAME, value=\"name\").send_keys(\"11:22:33:44:55:66\")\n\n    def test_create_new_device(self):\n        self.login()\n        self.open(reverse(self.add_url))\n        self._fill_device_form()\n        select = Select(\n            self.find_element(\n                by=By.NAME, value=f\"{self._get_prefix()}-0-location_selection\"\n            )\n        )\n        select.select_by_value(\"new\")\n\n        select = Select(\n            self.find_element(by=By.NAME, value=f\"{self._get_prefix()}-0-type\")\n        )\n        select.select_by_value(\"outdoor\")\n\n        self.find_element(by=By.NAME, value=f\"{self._get_prefix()}-0-name\").send_keys(\n            \"Test Location\"\n        )\n        # use the marker button to select location on map\n        self.find_element(\n            by=By.XPATH, value='//a[@class=\"leaflet-draw-draw-marker\"]'\n        ).click()\n        action = ActionChains(self.web_driver)\n        # place the marker on the map at a random location\n        elem = self.find_element(\n            by=By.ID, value=f\"id_{self._get_prefix()}-0-geometry-map\"\n        )\n        # (15, 5) is a random offset from the top left corner of the map\n        action.move_to_element(elem).move_by_offset(15, 5).click().perform()\n        # Wait until address field gets populated with the location marked above\n        WebDriverWait(self.web_driver, 5).until(\n            lambda x: x.find_element(\n                by=By.XPATH, value=f'//input[@name=\"{self._get_prefix()}-0-address\"]'\n            )\n            .get_attribute(\"value\")\n            .strip()\n            not in (\"\", None)\n        )\n\n        self.find_element(by=By.NAME, value=\"_save\").click()\n        self.wait_for_presence(By.CSS_SELECTOR, \".messagelist .success\")\n        # device model verbose name is dynamic\n        object_verbose_name = self.object_model._meta.verbose_name\n        self.assertEqual(\n            self.find_elements(by=By.CLASS_NAME, value=\"success\")[0].text,\n            f\"The {object_verbose_name} “11:22:33:44:55:66” was added successfully.\",\n        )\n\n    def test_real_time_update_address_field(self):\n        location = self._create_location()\n        self.login()\n        url = reverse(f\"admin:{self.app_label}_location_change\", args=[location.id])\n        self.open(url)\n        # Changing the address in tab 1 should update it in tab 0 in real time without a page reload\n        self.web_driver.switch_to.new_window(\"tab\")\n        tabs = self.web_driver.window_handles\n        # Swtich to last tab\n        self.web_driver.switch_to.window(tabs[-1])\n        self.open(url)\n        address_input = self.find_element(by=By.ID, value=\"id_address\")\n        self.assertEqual(address_input.get_attribute(\"value\"), location.address)\n        self.find_element(\n            by=By.XPATH, value='//a[@class=\"leaflet-draw-draw-marker\"]'\n        ).click()\n        elem = self.find_element(by=By.ID, value=\"id_geometry-map\")\n        # Updating the marker to a random new location\n        ActionChains(self.web_driver).move_to_element(elem).move_by_offset(\n            30, 15\n        ).click().perform()\n        alert = WebDriverWait(self.web_driver, 2).until(EC.alert_is_present())\n        alert.accept()\n        sleep(0.05)\n        new_address = \"Lazio 00185, ITA\"\n        address_input = self.find_element(by=By.ID, value=\"id_address\")\n        self.assertIn(new_address, address_input.get_attribute(\"value\"))\n        self.wait_for(\"element_to_be_clickable\", by=By.NAME, value=\"_continue\").click()\n        # Close tab[1] so other tests are not affected\n        self.web_driver.close()\n        # on some systems the zero tab may be an empty tab\n        # hence we open the tab before the last one\n        initial_tab = tabs.index(tabs[-1]) - 1\n        self.web_driver.switch_to.window(tabs[initial_tab])\n        address_input = self.find_element(by=By.ID, value=\"id_address\")\n        self.assertIn(new_address, address_input.get_attribute(\"value\"))\n"
  },
  {
    "path": "django_loci/tests/pytest_channels.py",
    "content": "from django.contrib.auth import get_user_model\n\nfrom ..models import Location, ObjectLocation\nfrom .base.test_channels import BaseTestChannels\nfrom .testdeviceapp.models import Device\n\n\nclass TestChannels(BaseTestChannels):\n    object_model = Device\n    location_model = Location\n    object_location_model = ObjectLocation\n    user_model = get_user_model()\n"
  },
  {
    "path": "django_loci/tests/test_admin.py",
    "content": "from django.contrib.auth import get_user_model\nfrom django.test import TestCase\n\nfrom ..models import FloorPlan, Location, ObjectLocation\nfrom .base.test_admin import BaseTestAdmin\nfrom .testdeviceapp.models import Device\n\n\nclass TestAdmin(BaseTestAdmin, TestCase):\n    object_model = Device\n    location_model = Location\n    floorplan_model = FloorPlan\n    object_location_model = ObjectLocation\n    user_model = get_user_model()\n"
  },
  {
    "path": "django_loci/tests/test_admin_inline.py",
    "content": "from django.contrib.auth import get_user_model\nfrom django.test import TestCase\n\nfrom ..models import FloorPlan, Location, ObjectLocation\nfrom .base.test_admin_inline import BaseTestAdminInline\nfrom .testdeviceapp.models import Device\n\n\nclass TestAdminInline(BaseTestAdminInline, TestCase):\n    object_model = Device\n    location_model = Location\n    floorplan_model = FloorPlan\n    object_location_model = ObjectLocation\n    user_model = get_user_model()\n"
  },
  {
    "path": "django_loci/tests/test_apps.py",
    "content": "from django.test import TestCase\n\nfrom .base.test_apps import BaseTestApps\n\n\nclass TestApps(BaseTestApps, TestCase):\n    pass\n"
  },
  {
    "path": "django_loci/tests/test_models.py",
    "content": "from django.test import TestCase\n\nfrom ..models import FloorPlan, Location, ObjectLocation\nfrom .base.test_models import BaseTestModels\nfrom .testdeviceapp.models import Device\n\n\nclass TestModels(BaseTestModels, TestCase):\n    object_model = Device\n    location_model = Location\n    floorplan_model = FloorPlan\n    object_location_model = ObjectLocation\n"
  },
  {
    "path": "django_loci/tests/test_selenium.py",
    "content": "from channels.testing import ChannelsLiveServerTestCase\nfrom django.contrib.auth import get_user_model\nfrom django.test import tag\n\nfrom ..models import Location, ObjectLocation\nfrom .base.test_selenium import BaseTestDeviceAdminSelenium\nfrom .testdeviceapp.models import Device\n\n\n@tag(\"selenium_tests\")\nclass TestDeviceAdminSelenium(BaseTestDeviceAdminSelenium, ChannelsLiveServerTestCase):\n    user_model = get_user_model()\n    object_model = Device\n    location_model = Location\n    object_location_model = ObjectLocation\n"
  },
  {
    "path": "django_loci/tests/testdeviceapp/__init__.py",
    "content": ""
  },
  {
    "path": "django_loci/tests/testdeviceapp/admin.py",
    "content": "from django.contrib import admin\nfrom django.shortcuts import render\nfrom django.urls import path\n\nfrom django_loci.admin import ObjectLocationInline\nfrom openwisp_utils.admin import TimeReadonlyAdminMixin\n\nfrom .models import Device\n\n\nclass DeviceAdmin(TimeReadonlyAdminMixin, admin.ModelAdmin):\n    list_display = (\"name\", \"created\", \"modified\")\n    save_on_top = True\n    inlines = [ObjectLocationInline]\n\n    def get_urls(self):\n        urls = super().get_urls()\n        urls = [\n            path(\n                \"location-broadcast-listener/\",\n                self.admin_site.admin_view(self.location_broadcast_listener),\n                name=\"location-broadcast-listener\",\n            ),\n        ] + urls\n        return urls\n\n    def location_broadcast_listener(self, request):\n        return render(\n            request,\n            \"admin/location_broadcast_listener.html\",\n            {\"title\": \"Location Broadcast Listener\", \"site_title\": \"OpenWISP 2\"},\n        )\n\n\nadmin.site.register(Device, DeviceAdmin)\n"
  },
  {
    "path": "django_loci/tests/testdeviceapp/migrations/0001_initial.py",
    "content": "# -*- coding: utf-8 -*-\n# Generated by Django 1.11.5 on 2017-11-14 10:36\nimport uuid\n\nimport django.utils.timezone\nimport model_utils.fields\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    initial = True\n\n    dependencies = []\n\n    operations = [\n        migrations.CreateModel(\n            name=\"Device\",\n            fields=[\n                (\n                    \"id\",\n                    models.UUIDField(\n                        default=uuid.uuid4,\n                        editable=False,\n                        primary_key=True,\n                        serialize=False,\n                    ),\n                ),\n                (\n                    \"created\",\n                    model_utils.fields.AutoCreatedField(\n                        default=django.utils.timezone.now,\n                        editable=False,\n                        verbose_name=\"created\",\n                    ),\n                ),\n                (\n                    \"modified\",\n                    model_utils.fields.AutoLastModifiedField(\n                        default=django.utils.timezone.now,\n                        editable=False,\n                        verbose_name=\"modified\",\n                    ),\n                ),\n                (\"name\", models.CharField(max_length=75, verbose_name=\"name\")),\n            ],\n            options={\"abstract\": False},\n        )\n    ]\n"
  },
  {
    "path": "django_loci/tests/testdeviceapp/migrations/__init__.py",
    "content": ""
  },
  {
    "path": "django_loci/tests/testdeviceapp/models.py",
    "content": "from django.db import models\nfrom django.utils.translation import gettext_lazy as _\n\nfrom openwisp_utils.base import TimeStampedEditableModel\n\n\nclass Device(TimeStampedEditableModel):\n    name = models.CharField(_(\"name\"), max_length=75)\n\n    def __str__(self):\n        return self.name\n"
  },
  {
    "path": "django_loci/tests/testdeviceapp/templates/admin/location_broadcast_listener.html",
    "content": "{% extends \"admin/base_site.html\" %}\n\n{% load static %}\n{% load i18n %}\n\n{% block content %}\n<p class=\"hidden\" id=\"ws-connected\">{% trans \"WebSocket connection established. Location updates will appear below.\" %}</p>\n<ul id=\"location-updates\"></ul>\n{% endblock content %}\n\n{% block footer %}\n{{ block.super }}\n<script src=\"{% static 'django-loci/js/vendor/reconnecting-websocket.min.js' %}\"></script>\n<script>\n    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n    const wsUrl = `${protocol}//${window.location.host}/ws/loci/location/`;\n    const ws = new ReconnectingWebSocket(wsUrl);\n    const locationUpdates = document.getElementById('location-updates');\n\n    ws.onmessage = function(event) {\n        const li = document.createElement('li');\n        const pre = document.createElement('pre');\n        const data = JSON.parse(event.data);\n        pre.textContent = JSON.stringify(data, null, 2);\n        li.appendChild(pre);\n        locationUpdates.appendChild(li);\n    };\n\n    ws.onopen = function() {\n        const statusMessage = document.querySelector('#ws-connected');\n        if (statusMessage) {\n            statusMessage.classList.remove('hidden');\n        }\n    };\n</script>\n{% endblock footer %}\n"
  },
  {
    "path": "django_loci/tests/testdeviceapp/templates/admin/testdeviceapp/change_list.html",
    "content": "{% extends \"admin/change_list.html\" %}\n{% load i18n %}\n\n{% block object-tools-items %}\n    <li>\n        <a href=\"{% url 'admin:location-broadcast-listener' %}\">\n            {% trans \"View Broadcast Listener\" %}\n        </a>\n    </li>\n    {{ block.super }}\n{% endblock %}\n"
  },
  {
    "path": "django_loci/tests/testdeviceapp/tests/__init__.py",
    "content": ""
  },
  {
    "path": "django_loci/tests/testdeviceapp/tests/test_selenium.py",
    "content": "from channels.testing import ChannelsLiveServerTestCase\nfrom django.contrib.auth import get_user_model\nfrom django.test import tag\nfrom django.urls import reverse\nfrom selenium.webdriver.common.by import By\nfrom selenium.webdriver.support import expected_conditions as EC\nfrom selenium.webdriver.support.ui import WebDriverWait\n\nfrom django_loci.models import Location, ObjectLocation\nfrom django_loci.tests import TestAdminMixin, TestLociMixin\nfrom openwisp_utils.tests.selenium import SeleniumTestMixin\n\n\n@tag(\"selenium_tests\")\nclass TestCommonLocationWebsocket(\n    SeleniumTestMixin, TestLociMixin, TestAdminMixin, ChannelsLiveServerTestCase\n):\n    location_model = Location\n    object_location_model = ObjectLocation\n    user_model = get_user_model()\n\n    def test_common_location_broadcast_ws(self):\n        self.login()\n        location1 = self._create_location(is_mobile=True, name=\"Location 1\")\n        location2 = self._create_location(is_mobile=True, name=\"Location 2\")\n        self.open(reverse(\"admin:location-broadcast-listener\"))\n        WebDriverWait(self.web_driver, 3).until(\n            EC.visibility_of_element_located(\n                (By.CSS_SELECTOR, \"#ws-connected\"),\n            )\n        )\n        # Update location to trigger websocket message\n        location1.geometry = (\n            '{ \"type\": \"Point\", \"coordinates\": [ 77.218791, 28.6324252 ] }'\n        )\n        location1.address = \"Delhi, India\"\n        location1.full_clean()\n        location1.save()\n        # Wait for websocket message to be received\n        WebDriverWait(self.web_driver, 3).until(\n            EC.text_to_be_present_in_element(\n                (By.CSS_SELECTOR, \"#location-updates li\"),\n                \"77.218791\",\n            )\n        )\n        location2.geometry = (\n            '{ \"type\": \"Point\", \"coordinates\": [72.877656, 19.075984] }'\n        )\n        location2.address = \"Mumbai, India\"\n        location2.full_clean()\n        location2.save()\n        WebDriverWait(self.web_driver, 3).until(\n            EC.text_to_be_present_in_element(\n                (By.CSS_SELECTOR, \"#location-updates\"),\n                \"72.877656\",\n            )\n        )\n"
  },
  {
    "path": "django_loci/widgets.py",
    "content": "import logging\n\nfrom django import forms\nfrom leaflet.admin import LeafletAdminWidget as BaseLeafletWidget\n\nlogger = logging.getLogger(__name__)\n\n\nclass ImageWidget(forms.FileInput):\n    \"\"\"\n    Image widget which can show a thumbnail\n    and carries information regarding\n    the image width and height\n    \"\"\"\n\n    template_name = \"admin/widgets/image.html\"\n\n    def __init__(self, *args, **kwargs):\n        self.thumbnail = kwargs.pop(\"thumbnail\", True)\n        super().__init__(*args, **kwargs)\n\n    def get_context(self, name, value, attrs):\n        c = super().get_context(name, value, attrs)\n        if value and hasattr(value, \"url\"):\n            c.update(\n                {\"filename\": value.name, \"url\": value.url, \"thumbnail\": self.thumbnail}\n            )\n            try:\n                c.update({\"width\": value.width, \"height\": value.height})\n            except IOError:\n                msg = \"floorplan image not found while showing floorplan:\\n{0}\"\n                logger.error(msg.format(value.name))\n        return c\n\n\nclass FloorPlanWidget(forms.TextInput):\n    \"\"\"\n    widget that allows to manage indoor coordinates\n    \"\"\"\n\n    template_name = \"admin/widgets/floorplan.html\"\n\n\nclass LeafletWidget(BaseLeafletWidget):\n    include_media = True\n    geom_type = \"GEOMETRY\"\n    template_name = \"leaflet/admin/widget.html\"\n    modifiable = True\n    display_raw = False\n    settings_overrides = {}\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "---\nservices:\n  redis:\n    image: redis:8-alpine\n    ports:\n      - \"6379:6379\"\n    entrypoint: redis-server --appendonly yes\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[tool.coverage.run]\nsource = [\"django_loci\"]\nparallel = true\n# To ensure correct coverage, we need to include both\n# \"multiprocessing\" and \"thread\" in the concurrency list.\n# This is because Django test suite incorrectly reports coverage\n# when \"multiprocessing\" is omitted and the \"--parallel\" flag\n# is used. Similarly, coverage for websocket consumers is\n# incorrect when \"thread\" is omitted and pytest is used.\nconcurrency = [\"multiprocessing\", \"thread\"]\nomit = [\n    \"django_loci/__init__.py\",\n    \"*/tests/*\",\n    \"*/migrations/*\",\n]\n\n[tool.docstrfmt]\nextend_exclude = [\"**/*.py\"]\n\n[tool.isort]\nknown_third_party = [\"django\"]\nknown_first_party = [\"django_loci\", \"openwisp_utils\"]\ndefault_section = \"THIRDPARTY\"\nline_length = 88\nmulti_line_output = 3\nuse_parentheses = true\ninclude_trailing_comma = true\nforce_grid_wrap = 0\n\n"
  },
  {
    "path": "pytest.ini",
    "content": "[pytest]\naddopts = --create-db --reuse-db --nomigrations django_loci/tests\nDJANGO_SETTINGS_MODULE = openwisp2.settings\npython_files = pytest_*.py\n"
  },
  {
    "path": "requirements-test.txt",
    "content": "pytest-cov~=7.1.0\nresponses~=0.26.0\nopenwisp-utils[qa,selenium,channels,channels-test] @ https://github.com/openwisp/openwisp-utils/archive/refs/heads/1.3.tar.gz\n"
  },
  {
    "path": "requirements.txt",
    "content": "django>=4.2.0,<5.3.0\ndjango-leaflet~=0.33.0\nPillow>=12.2.0,<12.3.0\ngeopy~=2.4.1\nopenwisp-utils[channels] @ https://github.com/openwisp/openwisp-utils/archive/refs/heads/1.3.tar.gz\n"
  },
  {
    "path": "run-qa-checks",
    "content": "#!/bin/bash\nset -e\nopenwisp-qa-check \\\n    --migration-path \\\n        \"./django_loci/migrations \\\n        ./django_loci/tests/testdeviceapp/migrations\" \\\n    --migration-module django_loci \\\n    --csslinter \\\n    --jslinter\n"
  },
  {
    "path": "runtests",
    "content": "#!/bin/bash\nset -e\n\ncoverage run runtests.py --parallel --exclude-tag=selenium_tests\ncoverage run runtests.py --tag=selenium_tests --exclude-pytest\ncoverage combine\ncoverage xml\n"
  },
  {
    "path": "runtests.py",
    "content": "#!/usr/bin/env python\n# -*- coding: utf-8 -*-\n\nimport os\nimport sys\n\nsys.path.insert(0, \"tests\")\nos.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"openwisp2.settings\")\n\nif __name__ == \"__main__\":\n    import pytest\n    from django.core.management import execute_from_command_line\n\n    args = sys.argv\n    exclude_pytest = \"--exclude-pytest\" in args\n    if exclude_pytest:\n        args.pop(args.index(\"--exclude-pytest\"))\n\n    args.insert(1, \"test\")\n    args.insert(2, \"django_loci\")\n    django_tests = execute_from_command_line(args)\n\n    if not exclude_pytest:\n        # pytests is used to test django-channels\n        sys.exit(pytest.main([os.path.join(\"django_loci\", \"tests\")]))\n    else:\n        sys.exit(django_tests)\n"
  },
  {
    "path": "setup.cfg",
    "content": "[bdist_wheel]\nuniversal=1\n\n[flake8]\nexclude = migrations,\n\t  ./tests/*settings*.py\nmax-line-length = 110\n# W503: line break before or after operator\n# W504: line break after or after operator\n# W605: invalid escape sequence\n# E231 missing whitespace after ','\nignore = W605, W503, W504, E231\n"
  },
  {
    "path": "setup.py",
    "content": "#!/usr/bin/env python\nfrom setuptools import find_packages, setup\n\nfrom django_loci import get_version\n\n\ndef get_install_requires():\n    \"\"\"\n    parse requirements.txt, ignore links, exclude comments\n    \"\"\"\n    requirements = []\n    for line in open(\"requirements.txt\").readlines():\n        # skip to next iteration if comment or empty line\n        if (\n            line.startswith(\"#\")\n            or line == \"\"\n            or line.startswith(\"http\")\n            or line.startswith(\"git\")\n        ):\n            continue\n        # add line to requirements\n        requirements.append(line)\n    return requirements\n\n\nsetup(\n    name=\"django-loci\",\n    version=get_version(),\n    license=\"BSD\",\n    author=\"Federico Capoano\",\n    author_email=\"support@openwisp.io\",\n    description=\"Reusable django-app for outdoor and indoor mapping\",\n    long_description=open(\"README.rst\").read(),\n    url=\"http://openwisp.org\",\n    download_url=\"https://github.com/openwisp/django-loci/releases\",\n    platforms=[\"Platform Independent\"],\n    keywords=[\"django\", \"gis\"],\n    packages=find_packages(exclude=[\"tests*\", \"docs*\"]),\n    include_package_data=True,\n    zip_safe=False,\n    install_requires=get_install_requires(),\n    classifiers=[\n        \"Development Status :: 5 - Production/Stable\",\n        \"Environment :: Web Environment\",\n        \"Topic :: Internet :: WWW/HTTP\",\n        \"Topic :: Scientific/Engineering :: GIS\",\n        \"Intended Audience :: Developers\",\n        \"License :: OSI Approved :: BSD License\",\n        \"Operating System :: OS Independent\",\n        \"Framework :: Django\",\n        \"Programming Language :: Python :: 3\",\n    ],\n)\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/manage.py",
    "content": "#!/usr/bin/env python\nimport os\nimport sys\n\nif __name__ == \"__main__\":\n    os.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"openwisp2.settings\")\n\n    from django.core.management import execute_from_command_line\n\n    execute_from_command_line(sys.argv)\n"
  },
  {
    "path": "tests/openwisp2/__init__.py",
    "content": ""
  },
  {
    "path": "tests/openwisp2/local_settings.example.py",
    "content": "# RENAME THIS FILE TO local_settings.py IF YOU NEED TO CUSTOMIZE SOME SETTINGS\n# BUT DO NOT COMMIT\n\n# DATABASES = {\n#    'default': {\n#        'ENGINE': 'django.db.backends.sqlite3',\n#        'NAME': 'netjsonconfig.db',\n#        'USER': '',\n#        'PASSWORD': '',\n#        'HOST': '',\n#        'PORT': ''\n#    },\n# }\n"
  },
  {
    "path": "tests/openwisp2/media/.gitignore",
    "content": "*\n!.gitignore\n!.floorplan.jpg\n"
  },
  {
    "path": "tests/openwisp2/settings.py",
    "content": "import os\nimport sys\n\nBASE_DIR = os.path.dirname(os.path.abspath(__file__))\nTESTING = \"test\" in sys.argv\n\nDEBUG = True\n\nALLOWED_HOSTS = []\n\nDATABASES = {\n    \"default\": {\n        \"ENGINE\": \"openwisp_utils.db.backends.spatialite\",\n        \"NAME\": \"django-loci.db\",\n    }\n}\nif TESTING and \"--exclude-tag=selenium_tests\" not in sys.argv:\n    DATABASES[\"default\"][\"TEST\"] = {\n        \"NAME\": os.path.join(BASE_DIR, \"django-loci-test.db\"),\n    }\n\nSPATIALITE_LIBRARY_PATH = \"mod_spatialite.so\"\n\nSECRET_KEY = \"fn)t*+$)ugeyip6-#txyy$5wf2ervc0d2n#h)qb)y5@ly$t*@w\"\n\nINSTALLED_APPS = [\n    \"daphne\",\n    \"django.contrib.auth\",\n    \"django.contrib.contenttypes\",\n    \"django.contrib.sessions\",\n    \"django.contrib.messages\",\n    \"django.contrib.staticfiles\",\n    \"django.contrib.gis\",\n    \"openwisp_utils.admin_theme\",\n    # django-loci\n    \"django_loci\",\n    # admin\n    \"django.contrib.admin\",\n    # other dependencies\n    \"leaflet\",\n    # channels\n    \"channels\",\n    # test app\n    \"django_loci.tests.testdeviceapp\",\n]\n\nSTATICFILES_FINDERS = [\n    \"django.contrib.staticfiles.finders.FileSystemFinder\",\n    \"django.contrib.staticfiles.finders.AppDirectoriesFinder\",\n]\n\nMIDDLEWARE = [\n    \"django.middleware.security.SecurityMiddleware\",\n    \"django.contrib.sessions.middleware.SessionMiddleware\",\n    \"django.middleware.common.CommonMiddleware\",\n    \"django.middleware.csrf.CsrfViewMiddleware\",\n    \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n    \"django.contrib.messages.middleware.MessageMiddleware\",\n    \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n]\n\nROOT_URLCONF = \"openwisp2.urls\"\n\nASGI_APPLICATION = \"django_loci.channels.asgi.channel_routing\"\nCHANNEL_LAYERS = {\n    \"default\": {\n        \"BACKEND\": \"channels_redis.core.RedisChannelLayer\",\n        \"CONFIG\": {\n            \"hosts\": [(\"127.0.0.1\", 6379)],\n        },\n    },\n}\n\nTIME_ZONE = \"Europe/Rome\"\nLANGUAGE_CODE = \"en-gb\"\nUSE_TZ = True\nUSE_I18N = False\nSTATIC_URL = \"/static/\"\nMEDIA_URL = \"/media/\"\nMEDIA_ROOT = \"{0}/media/\".format(BASE_DIR)\n\nTEMPLATES = [\n    {\n        \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n        \"DIRS\": [],\n        \"OPTIONS\": {\n            \"loaders\": [\n                \"django.template.loaders.filesystem.Loader\",\n                \"django.template.loaders.app_directories.Loader\",\n            ],\n            \"context_processors\": [\n                \"django.template.context_processors.debug\",\n                \"django.template.context_processors.request\",\n                \"django.contrib.auth.context_processors.auth\",\n                \"django.contrib.messages.context_processors.messages\",\n            ],\n        },\n    }\n]\n\nLEAFLET_CONFIG = {\"RESET_VIEW\": False}\n\n# local settings must be imported before test runner otherwise they'll be ignored\ntry:\n    from .local_settings import *\nexcept (SystemError, ImportError):\n    try:\n        from local_settings import *\n    except ImportError:\n        pass\n"
  },
  {
    "path": "tests/openwisp2/urls.py",
    "content": "from django.conf import settings\nfrom django.conf.urls.static import static\nfrom django.contrib import admin\nfrom django.contrib.staticfiles.urls import staticfiles_urlpatterns\nfrom django.urls import include, path\n\nurlpatterns = [path(\"admin/\", admin.site.urls)]\n\nurlpatterns += staticfiles_urlpatterns()\nurlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)\n\nif settings.DEBUG and \"debug_toolbar\" in settings.INSTALLED_APPS:\n    import debug_toolbar\n\n    urlpatterns.append(path(\"__debug__/\", include(debug_toolbar.urls)))\n"
  }
]