Full Code of openwisp/django-loci for AI

master 16bab5ac7150 cached
92 files
205.8 KB
50.1k tokens
260 symbols
1 requests
Download .txt
Showing preview only (228K chars total). Download the full file or copy to clipboard to get everything.
Repository: openwisp/django-loci
Branch: master
Commit: 16bab5ac7150
Files: 92
Total size: 205.8 KB

Directory structure:
gitextract_phsp6pke/

├── .github/
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.md
│   │   ├── feature_request.md
│   │   └── question.md
│   ├── dependabot.yml
│   ├── pull_request_template.md
│   └── workflows/
│       ├── backport.yml
│       ├── ci.yml
│       ├── pypi.yml
│       └── version-branch.yml
├── .gitignore
├── .prettierignore
├── CHANGES.rst
├── CONTRIBUTING.rst
├── LICENSE
├── MANIFEST.in
├── README.rst
├── conftest.py
├── django_loci/
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── base/
│   │   ├── __init__.py
│   │   ├── admin.py
│   │   ├── geocoding_views.py
│   │   └── models.py
│   ├── channels/
│   │   ├── __init__.py
│   │   ├── asgi.py
│   │   ├── base.py
│   │   ├── consumers.py
│   │   └── receivers.py
│   ├── fields.py
│   ├── migrations/
│   │   ├── 0001_initial.py
│   │   └── __init__.py
│   ├── models.py
│   ├── settings.py
│   ├── static/
│   │   └── django-loci/
│   │       ├── css/
│   │       │   ├── floorplan-widget.css
│   │       │   └── loci.css
│   │       └── js/
│   │           ├── floorplan-inlines.js
│   │           ├── floorplan-widget.js
│   │           └── loci.js
│   ├── storage.py
│   ├── templates/
│   │   └── admin/
│   │       ├── django_loci/
│   │       │   ├── foreign_key_raw_id.html
│   │       │   ├── location_change_form.html
│   │       │   └── location_inline.html
│   │       └── widgets/
│   │           ├── floorplan.html
│   │           ├── foreign_key_raw_id.html
│   │           └── image.html
│   ├── tests/
│   │   ├── __init__.py
│   │   ├── base/
│   │   │   ├── __init__.py
│   │   │   ├── static/
│   │   │   │   ├── test-geocode-invalid-address.json
│   │   │   │   ├── test-geocode.json
│   │   │   │   ├── test-reverse-geocode.json
│   │   │   │   └── test-reverse-location-with-no-address.json
│   │   │   ├── test_admin.py
│   │   │   ├── test_admin_inline.py
│   │   │   ├── test_apps.py
│   │   │   ├── test_channels.py
│   │   │   ├── test_models.py
│   │   │   └── test_selenium.py
│   │   ├── pytest_channels.py
│   │   ├── test_admin.py
│   │   ├── test_admin_inline.py
│   │   ├── test_apps.py
│   │   ├── test_models.py
│   │   ├── test_selenium.py
│   │   └── testdeviceapp/
│   │       ├── __init__.py
│   │       ├── admin.py
│   │       ├── migrations/
│   │       │   ├── 0001_initial.py
│   │       │   └── __init__.py
│   │       ├── models.py
│   │       ├── templates/
│   │       │   └── admin/
│   │       │       ├── location_broadcast_listener.html
│   │       │       └── testdeviceapp/
│   │       │           └── change_list.html
│   │       └── tests/
│   │           ├── __init__.py
│   │           └── test_selenium.py
│   └── widgets.py
├── docker-compose.yml
├── pyproject.toml
├── pytest.ini
├── requirements-test.txt
├── requirements.txt
├── run-qa-checks
├── runtests
├── runtests.py
├── setup.cfg
├── setup.py
└── tests/
    ├── __init__.py
    ├── manage.py
    └── openwisp2/
        ├── __init__.py
        ├── local_settings.example.py
        ├── media/
        │   └── .gitignore
        ├── settings.py
        └── urls.py

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms

github: [openwisp]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: ["https://openwisp.org/sponsorship/"]


================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Open a bug report
title: "[bug] "
labels: bug
assignees: ""
---

**Describe the bug**
A clear and concise description of the bug or unexpected behavior.

**Steps To Reproduce**
Steps to reproduce the behavior:

1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error

**Expected behavior**
A clear and concise description of what you expected to happen.

**Screenshots**
If applicable, add screenshots to help explain your problem.

**System Informatioon:**

- OS: [e.g. Ubuntu 24.04 LTS]
- Python Version: [e.g. Python 3.11.2]
- Django Version: [e.g. Django 4.2.5]
- Browser and Browser Version (if applicable): [e.g. Chromium v126.0.6478.126]


================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: "[feature] "
labels: enhancement
assignees: ""
---

**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

**Describe the solution you'd like**
A clear and concise description of what you want to happen.

**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.

**Additional context**
Add any other context or screenshots about the feature request here.


================================================
FILE: .github/ISSUE_TEMPLATE/question.md
================================================
---
name: Question
about: Please use the Discussion Forum to ask questions
title: "[question] "
labels: question
assignees: ""
---

Please use the [Discussion Forum](https://github.com/openwisp/django-loci/discussions) to ask questions.

We will take care of moving the discussion to a more relevant repository if needed.


================================================
FILE: .github/dependabot.yml
================================================
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file

version: 2
updates:
  - package-ecosystem: "pip" # See documentation for possible values
    directory: "/" # Location of package manifests
    schedule:
      interval: "monthly"
    commit-message:
      prefix: "[deps] "
  - package-ecosystem: "github-actions" # Check for GitHub Actions updates
    directory: "/" # The root directory where the Ansible role is located
    schedule:
      interval: "monthly" # Check for updates weekly
    commit-message:
      prefix: "[ci] "


================================================
FILE: .github/pull_request_template.md
================================================
## Checklist

- [ ] I have read the [OpenWISP Contributing Guidelines](http://openwisp.io/docs/developer/contributing.html).
- [ ] I have manually tested the changes proposed in this pull request.
- [ ] I have written new test cases for new code and/or updated existing tests for changes to existing code.
- [ ] I have updated the documentation.

## Reference to Existing Issue

Closes #<issue-number>.

Please [open a new issue](https://github.com/openwisp/django-loci/issues/new/choose) if there isn't an existing issue yet.

## Description of Changes

Please describe these changes.

## Screenshot

Please include any relevant screenshots.


================================================
FILE: .github/workflows/backport.yml
================================================
name: Backport fixes to stable branch

on:
  push:
    branches:
      - master
      - main
  issue_comment:
    types: [created]

concurrency:
  group: backport-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: false

permissions:
  contents: write
  pull-requests: write

jobs:
  backport-on-push:
    if: github.event_name == 'push'
    uses: openwisp/openwisp-utils/.github/workflows/reusable-backport.yml@master
    with:
      commit_sha: ${{ github.sha }}
    secrets:
      app_id: ${{ secrets.OPENWISP_BOT_APP_ID }}
      private_key: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }}

  backport-on-comment:
    if: >
      github.event_name == 'issue_comment' &&
      github.event.issue.pull_request &&
      github.event.issue.pull_request.merged_at != null &&
      github.event.issue.state == 'closed' &&
      contains(fromJSON('["MEMBER", "OWNER"]'), github.event.comment.author_association) &&
      startsWith(github.event.comment.body, '/backport')
    uses: openwisp/openwisp-utils/.github/workflows/reusable-backport.yml@master
    with:
      pr_number: ${{ github.event.issue.number }}
      comment_body: ${{ github.event.comment.body }}
    secrets:
      app_id: ${{ secrets.OPENWISP_BOT_APP_ID }}
      private_key: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }}


================================================
FILE: .github/workflows/ci.yml
================================================
name: Django Loci Build

on:
  push:
    branches:
      - master
      - "1.2"
  pull_request:
    branches:
      - master
      - "1.2"

jobs:
  build:
    name: Python==${{ matrix.python-version }} | ${{ matrix.django-version }}
    runs-on: ubuntu-24.04

    services:
      redis:
        image: redis
        ports:
          - 6379:6379

    strategy:
      fail-fast: false
      matrix:
        python-version:
          - "3.10"
          - "3.11"
          - "3.12"
          - "3.13"
        django-version:
          - django~=4.2.0
          - django~=5.0.0
          - django~=5.1.0
          - django~=5.2.0
        exclude:
          # Python 3.13 supported only in Django >=5.1.3
          - python-version: "3.13"
            django-version: django~=4.2.0
          - python-version: "3.13"
            django-version: django~=5.0.0

    steps:
      - uses: actions/checkout@v6
        with:
          ref: ${{ github.event.pull_request.head.sha }}

      - name: Cache APT packages
        uses: actions/cache@v5
        with:
          path: /var/cache/apt/archives
          key: apt-${{ runner.os }}-${{ hashFiles('.github/workflows/ci.yml') }}
          restore-keys: |
            apt-${{ runner.os }}-

      - name: Disable man page auto-update
        run: |
          echo 'set man-db/auto-update false' | sudo debconf-communicate >/dev/null
          sudo dpkg-reconfigure man-db

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v6
        with:
          python-version: ${{ matrix.python-version }}
          cache: "pip"
          cache-dependency-path: |
            **/requirements*.txt

      - name: Install Dependencies
        id: deps
        run: |
          sudo apt update
          sudo apt-get -qq -y install \
               sqlite3 \
               libsqlite3-dev \
               libsqlite3-mod-spatialite \
               gdal-bin
          pip install -U -r requirements-test.txt
          sudo npm install -g prettier
          pip install -U -e .
          pip install -U ${{ matrix.django-version }}

      - name: QA checks
        run: ./run-qa-checks

      - name: Tests
        if: ${{ !cancelled() && steps.deps.conclusion == 'success' }}
        run: ./runtests
        env:
          SELENIUM_HEADLESS: 1
          GECKO_LOG: 1

      - name: Show gecko web driver log on failures
        if: ${{ failure() }}
        run: |
          [ -f geckodriver.log ] && cat geckodriver.log \
            || echo "No gecko web driver log to show"

      - name: Upload Coverage
        if: ${{ success() }}
        uses: coverallsapp/github-action@v2
        with:
          parallel: true
          format: cobertura
          flag-name: python-${{ matrix.python-version }}-${{ matrix.django-version }}
          github-token: ${{ secrets.GITHUB_TOKEN }}
          fail-on-error: false

  coveralls:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Coveralls Finished
        uses: coverallsapp/github-action@v2
        with:
          parallel-finished: true
          fail-on-error: false


================================================
FILE: .github/workflows/pypi.yml
================================================
name: Publish Python Package to Pypi.org

on:
  release:
    types: [published]

permissions:
  id-token: write

jobs:
  pypi-publish:
    name: Release Python Package on Pypi.org
    runs-on: ubuntu-latest
    environment:
      name: pypi
      url: https://pypi.org/p/django-loci
    permissions:
      id-token: write
    steps:
      - uses: actions/checkout@v6
      - name: Set up Python
        uses: actions/setup-python@v6
        with:
          python-version: "3.10"
      - name: Install dependencies
        run: |
          pip install -U pip
          pip install build
      - name: Build package
        run: python -m build
      - name: Publish package distributions to PyPI
        uses: pypa/gh-action-pypi-publish@v1.14.0


================================================
FILE: .github/workflows/version-branch.yml
================================================
name: Replicate Commits to Version Branch

on:
  push:
    branches:
      - master

jobs:
  version-branch:
    uses: openwisp/openwisp-utils/.github/workflows/reusable-version-branch.yml@master
    with:
      module_name: django_loci


================================================
FILE: .gitignore
================================================
# Byte-compiled / optimized / DLL files
__pycache__/
.pytest_cache/
*.py[cod]

# C extensions
*.so

# Distribution / packaging
.Python
env/
venv/
build/
develop-eggs/
dist/
downloads/
eggs/
/lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg

# PyInstaller
#  Usually these files are written by a python script from a template
#  before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.coverage*
.cache
nosetests.xml
coverage.xml

# Translations
*.mo
*.pot

# Django stuff:
*.log

# Sphinx documentation
docs/_build/

# PyBuilder
target/

# editors
*.komodoproject
.vscode/

# other
*.DS_Store*
*~
._*
local_settings.py
*.db
*.tar.gz


================================================
FILE: .prettierignore
================================================
*.min.js
*.min.css


================================================
FILE: CHANGES.rst
================================================
Changelog
=========

Version 1.3.0 [unreleased]
--------------------------

Work in progress.

Version 1.2.2 [2026-04-16]
--------------------------

Bugfixes
~~~~~~~~

- Updated higher bound of pillow range due to CVE `#215
  <https://github.com/openwisp/django-loci/issues/215>`_

Version 1.2.1 [2026-03-27]
--------------------------

Bugfixes
~~~~~~~~

- Fixed the width of Leaflet control labels in the map UI `#200
  <https://github.com/openwisp/django-loci/issues/200>`_.
- Fixed an issue preventing creation of mobile locations via the Django
  admin `#207 <https://github.com/openwisp/django-loci/issues/207>`_.
- Prevented JavaScript errors on pages where the map is not available or
  the user does not have permission to view it.
- Restored the default Django admin form-row label width to avoid layout
  issues.

Version 1.2.0 [2025-10-23]
--------------------------

Changes
~~~~~~~

Dependencies
++++++++++++

- Bumped ``django-leaflet~=0.32.0``.
- Bumped ``openwisp-utils~=1.2.0``.
- Bumped ``Pillow~=11.3.0``.
- Added support for Django ``5.x``.
- Added support for Python ``3.11``, ``3.12``, and ``3.13``.
- Dropped support for Django ``3.2`` and ``4.1``.
- Dropped support for Python ``3.8``.

Bugfixes
~~~~~~~~

- Added address field to real-time location updates `#169
  <https://github.com/openwisp/django-loci/issues/169>`_.

Version 1.1.4 [2025-08-01]
--------------------------

Bugfixes
~~~~~~~~

- Fixed ``test_add_outdoor_with_floorplan`` which was causing test
  failures in downstream projects.
- Refactored formset handling for outdoor locations in the admin
  interface: moved the logic rejecting floorplans for outdoor locations to
  ``AbstractLocationAdmin`` to improve reusability and extendability.

Version 1.1.3 [2025-07-31]
--------------------------

Bugfixes
~~~~~~~~

- Fixed `loading of map in ObjectLocation admin
  <https://github.com/openwisp/django-loci/issues/95>`_ when the user only
  has view permissions.
- `Fixed error when changing a location from indoor to outdoor
  <https://github.com/openwisp/django-loci/issues/156>`_. Changing the
  location type from indoor to outdoor will delete related floorplans.
  Added confirmation dialog to prevent accidental deletion of floorplans.
- Avoided underlining Leaflet controls in the admin interface.
- Fixed import of ``FileSystemStorage`` for compatibility with different
  Django versions.
- Fixed `JavaScript SyntaxError: redeclaration of const withForms
  <https://github.com/makinacorpus/django-leaflet/issues/389>`_ by
  overriding ``leaflet.draw.i18n.js`` in django-loci and wrapping the
  ``withForms`` declaration in a block scope.

Version 1.1.2 [2025-01-27]
--------------------------

- Refactored code to move logic to helper methods in
  AbstractObjectLocationForm

Version 1.1.1 [2024-11-20]
--------------------------

- [deps] Updated django-leaflet to ~=0.31.0.

Version 1.1.0 [2024-08-16]
--------------------------

Changes
~~~~~~~

- Use ``settings.DEFAULT_STORAGE_CLASS`` as base for OverwriteStorage,
  adapting the storage backend to project settings.

**Dependencies:**

- Bumped ``django-leaflet~=0.30.1``
- Bumped ``Pillow~=10.4.0``
- Bumped ``geopy~=2.4.1``
- Bumped ``openwisp-utils~=1.1.0``
- Added support for Python ``3.10``.
- Added support for Django ``4.2``.
- Dropped support for Python ``3.7``.
- Dropped support for Django ``3.0.x``, ``3.1.x`` and ``4.0.x``.

Bugfixes
~~~~~~~~

- Fixed an issue with deleting ``FloorPlan.image`` by using the
  appropriate storage backend method.
- Resolved a bug causing outdoor locations to incorrectly appear in the
  location list when creating floorplans.

Version 1.0.1 [2022-04-20]
--------------------------

Bugfixes
~~~~~~~~

- Updated Pillow to ~=9.1.0 to fix a security CVE
- Fixed channels deprecation warning

Version 1.0.0 [2022-02-25]
--------------------------

Changes
~~~~~~~

- Converted geocoding test to check `#90
  <https://github.com/openwisp/django-loci/issues/90>`_
- Use ``ReconnectingWebsocket`` to websocket connection `#101
  <https://github.com/openwisp/django-loci/issues/101>`_
- Dropped support for Python ``3.6``
- Added support for Python ``3.8`` and ``3.9``
- Added support for Django ``3.2.x`` and ``4.0.x``
- Migrated to ``channels~=3.0.4``
- Bumped ``Pillow~=9.0.0``
- Bumped ``geopy~=2.2.0``
- Bumped ``openwisp-utils~=1.0.0``
- Set lowest django version supported to ``django~=3.0.0``

Version 0.4.3 [2021-06-29]
--------------------------

- The dependency on the Pillow library was updated to a recent version
  which was patched for security vulnerabilities
- Several other dependencies and test dependencies were updated
  (django-leaflet, geopy, pytest-django, pytest-asyncio, pytest-cov,
  responses, openwisp-utils)

Version 0.4.2 [2021-03-16]
--------------------------

- Fixed broken UI in inline geo selection flow caused by a JS change in
  django (`issue #85
  <https://github.com/openwisp/django-loci/issues/85>`_)

Version 0.4.1 [2021-02-24]
--------------------------

Bugfixes
~~~~~~~~

- Fixed the ``DJANGO_LOCI_GEOCODE_STRICT_TEST`` setting, which internally
  was using a different name, therefore the documented setting was not
  working

Version 0.4.0 [2020-11-19]
--------------------------

Features
~~~~~~~~

- [ux] Automatically fetch map coordinates from address field and vice
  versa + configurable geocoding

Changes
~~~~~~~

- [deps] Increased Pillow range to allow new 8.0.0 version
- [deps] Updated openwisp-utils version range to support 0.6 and 0.7

Bugfixes
~~~~~~~~

- [fix] Fixed integrity error in ``floorplan.floor`` when
  ``is_mobile=True``
- [fix] Fixed corner case involving restoring ``is_mobile=False``

Version 0.3.4 [2020-08-16]
--------------------------

- [deps] Added support for django 3.1
- [deps] Updated to openwisp-utils 0.6

Version 0.3.3 [2020-07-25]
--------------------------

- [fix] Fixed websocket connect error for location change view
- [deps] Added support for Pillow~=7.2.0 & openwisp-utils~=0.5.1 and
  dropped their lower versions
- [deps] Added support for django-leaflet version 0.28

Version 0.3.2 [2020-07-01]
--------------------------

- [fix] Fixed bug in floorplan fields
- [fix] Fixed bug which caused geographic map to disappears on narrow
  screens
- [fix] Fixed bug in JS logic
- [change] Allow to create an indoor location without specifying indoor
  coordinates

Version 0.3.1 [2020-01-21]
--------------------------

- Added support to django 3.0, dropped support for django versions older
  than 2.2
- [admin] Fixed UX issue with ``is_mobile`` checkbox

Version 0.3.0 [2020-01-13]
--------------------------

- Upgraded django-channels to version 2
- Upgraded dependencies (django, django-leaflet, Pillow)
- Geometry shouldn't be allowed to be None if not mobile
- Fixed admin fields hidden by mistake in case of validation errors
- Fixed type ``KeyError`` exception during form validation

Version 0.2.1 [2018-09-02]
--------------------------

- [tests] Removed duplication of definition of floorplan test file

Version 0.2.0 [2018-02-19]
--------------------------

- [requirements] Added support for django 2.0

Version 0.1.1 [2017-12-06]
--------------------------

- [admin] Reusable foreign_key_raw_id template
- [js] Added client side validation for indoor position
- [js] Do not reset indoor form on first load
- [websockets] Do not attempt connection in location add page
- [websockets] Automatically determine ws protocol

Version 0.1.0 [2017-12-02]
--------------------------

- first release


================================================
FILE: CONTRIBUTING.rst
================================================
Please refer to the `OpenWISP Contribution Guidelines
<https://openwisp.io/docs/stable/developer/contributing.html>`_.


================================================
FILE: LICENSE
================================================
Copyright (c) 2017, Federico Capoano
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

* Redistributions of source code must retain the above copyright notice, this
  list of conditions and the following disclaimer.

* Redistributions in binary form must reproduce the above copyright notice,
  this list of conditions and the following disclaimer in the documentation
  and/or other materials provided with the distribution.

* Neither the name of django-loci, openwisp nor the names of its
  contributors may be used to endorse or promote products derived from
  this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.


================================================
FILE: MANIFEST.in
================================================
include LICENSE
include README.rst
include CHANGES.rst
include requirements.txt
recursive-include django_loci *
recursive-exclude * *.pyc
recursive-exclude * *.swp
recursive-exclude * __pycache__
recursive-exclude * *.db
recursive-exclude * local_settings.py


================================================
FILE: README.rst
================================================
django-loci
===========

.. image:: https://github.com/openwisp/django-loci/actions/workflows/ci.yml/badge.svg
    :target: https://github.com/openwisp/django-loci/actions/workflows/ci.yml
    :alt: CI build status

.. image:: https://coveralls.io/repos/openwisp/django-loci/badge.svg
    :target: https://coveralls.io/r/openwisp/django-loci

.. image:: https://img.shields.io/librariesio/release/github/openwisp/django-loci
    :target: https://libraries.io/github/openwisp/django-loci#repository_dependencies
    :alt: Dependency monitoring

.. image:: https://img.shields.io/gitter/room/nwjs/nw.js.svg?style=flat-square
    :target: https://gitter.im/openwisp/general

.. image:: https://badge.fury.io/py/django-loci.svg
    :target: http://badge.fury.io/py/django-loci

.. image:: https://pepy.tech/badge/django-loci
    :target: https://pepy.tech/project/django-loci
    :alt: downloads

.. image:: https://img.shields.io/badge/code%20style-black-000000.svg
    :target: https://pypi.org/project/black/
    :alt: code style: black

----

Reusable django-app for storing GIS and indoor coordinates of objects.

.. image:: https://raw.githubusercontent.com/openwisp/django-loci/master/docs/indoor.png
    :target: https://raw.githubusercontent.com/openwisp/django-loci/master/docs/indoor.png
    :alt: Indoor coordinates

.. image:: https://raw.githubusercontent.com/openwisp/django-loci/master/docs/map.png
    :target: https://raw.githubusercontent.com/openwisp/django-loci/master/docs/map.png
    :alt: Map coordinates

.. image:: https://raw.githubusercontent.com/openwisp/django-loci/master/docs/mobile.png
    :target: https://raw.githubusercontent.com/openwisp/django-loci/master/docs/mobile.png
    :alt: Mobile coordinates

----

.. contents:: **Table of Contents**:
    :backlinks: none
    :depth: 3

----

Dependencies
------------

- Python >= 3.10
- GeoDjango (`see GeoDjango Install Instructions
  <https://docs.djangoproject.com/en/dev/ref/contrib/gis/install/#requirements>`_)
- One of the databases supported by GeoDjango

Compatibility Table
-------------------

=========== ==============
django-loci Python version
0.2         2.7 or >=3.4
0.3 - 0.4   >=3.6
1.0         >=3.7
1.1         >=3.8
dev         >=3.10
=========== ==============

Install stable version from pypi
--------------------------------

Install from pypi:

.. code-block:: shell

    pip install django-loci

Install development version
---------------------------

First of all, install the dependencies of `GeoDjango
<https://docs.djangoproject.com/en/4.2/ref/contrib/gis/>`_:

- `Geospatial libraries
  <https://docs.djangoproject.com/en/4.2/ref/contrib/gis/install/geolibs/>`_
- `Spatial database
  <https://docs.djangoproject.com/en/4.2/ref/contrib/gis/install/spatialite/>`_,
  for development we use Spatialite, a spatial extension of `sqlite
  <https://www.sqlite.org/index.html>`_

Install tarball:

.. code-block:: shell

    pip install https://github.com/openwisp/django-loci/tarball/master

Alternatively you can install via pip using git:

.. code-block:: shell

    pip install -e git+git://github.com/openwisp/django-loci#egg=django_loci

If you want to contribute, install your cloned fork:

.. code-block:: shell

    git clone git@github.com:<your_fork>/django-loci.git
    cd django_loci
    python setup.py develop

Setup (integrate in an existing django project)
-----------------------------------------------

First of all, set up your database engine to `one of the spatial databases
suppported by GeoDjango
<https://docs.djangoproject.com/en/4.2/ref/contrib/gis/db-api/#spatial-backends>`_.

Add ``django_loci`` and its dependencies to ``INSTALLED_APPS`` in the
following order:

.. code-block:: python

    INSTALLED_APPS = [
        # ...
        "django.contrib.gis",
        "django_loci",
        "django.contrib.admin",
        "leaflet",
        "channels",
        # ...
    ]

Configure ``CHANNEL_LAYERS`` according to your needs, a sample
configuration can be:

.. code-block:: python

    ASGI_APPLICATION = "django_loci.channels.asgi.channel_routing"
    CHANNEL_LAYERS = {
        "default": {
            "BACKEND": "channels_redis.core.RedisChannelLayer",
            "CONFIG": {
                "hosts": [("127.0.0.1", 6379)],
            },
        },
    }

Now run migrations:

.. code-block:: shell

    ./manage.py migrate

Troubleshooting
---------------

Common issues and solutions when installing GeoDjango.

Unable to load the SpatiaLite library extension
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

If you get the following exception:

::

    django.core.exceptions.ImproperlyConfigured: Unable to load the SpatiaLite library extension

You need to specify the ``SPATIALITE_LIBRARY_PATH`` in your
``settings.py`` as explained in the `django documentation regarding how to
install and configure spatialte
<https://docs.djangoproject.com/en/4.2/ref/contrib/gis/install/spatialite/>`_.

Issues with other geospatial libraries
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Please refer to the `geodjango documentation on troubleshooting issues
related to geospatial libraries
<https://docs.djangoproject.com/en/4.2/ref/contrib/gis/install/#library-environment-settings>`_.

Settings
--------

``LOCI_FLOORPLAN_STORAGE``
~~~~~~~~~~~~~~~~~~~~~~~~~~

============ ========================================
**type**:    ``str``
**default**: ``django_loci.storage.OverwriteStorage``
============ ========================================

The django file storage class used for uploading floorplan images.

The filestorage can be changed to a different one as long as it has an
``upload_to`` class method which will be passed to
``FloorPlan.image.upload_to``.

To understand the details of this statement, take a look at the code of
`django_loci.storage.OverwriteStorage
<https://github.com/openwisp/django-loci/blob/master/django_loci/storage.py>`_.

``DJANGO_LOCI_GEOCODER``
~~~~~~~~~~~~~~~~~~~~~~~~

============ ==========
**type**:    ``str``
**default**: ``ArcGIS``
============ ==========

Service used for geocoding and reverse geocoding.

Supported geolocation services:

- ``ArcGIS``
- ``Nominatim``
- ``GoogleV3`` (Google Maps v3)

``DJANGO_LOCI_GEOCODE_FAILURE_DELAY``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

============ =======
**type**:    ``int``
**default**: ``1``
============ =======

Amount of seconds between geocoding retry API calls when geocoding
requests fail.

``DJANGO_LOCI_GEOCODE_RETRIES``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

============ =======
**type**:    ``int``
**default**: ``3``
============ =======

Amount of retry API calls when geocoding requests fail.

``DJANGO_LOCI_GEOCODE_API_KEY``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

============ ========
**type**:    ``str``
**default**: ``None``
============ ========

API key if required (eg: Google Maps).

System Checks
-------------

``geocoding``
~~~~~~~~~~~~~

Use to check if geocoding is working as expected or not.

Run this checks with:

::

    ./manage.py check --deploy --tag geocoding

Extending django-loci
---------------------

*django-loci* provides a set of models and admin classes which can be
imported, extended and reused by third party apps.

To extend *django-loci*, **you MUST NOT** add it to
``settings.INSTALLED_APPS``, but you must create your own app (which goes
into ``settings.INSTALLED_APPS``), import the base classes of django-loci
and add your customizations.

Extending models
~~~~~~~~~~~~~~~~

This example provides an example of how to extend the base models of
*django-loci* by adding a relation to another django model named
`Organization`.

.. code-block:: python

    # models.py of your app
    from django.db import models
    from django_loci.base.models import (
        AbstractFloorPlan,
        AbstractLocation,
        AbstractObjectLocation,
    )

    # the model ``organizations.Organization`` is omitted for brevity
    # if you are curious to see a real implementation, check out django-organizations


    class OrganizationMixin(models.Model):
        organization = models.ForeignKey("organizations.Organization")

        class Meta:
            abstract = True


    class Location(OrganizationMixin, AbstractLocation):
        class Meta(AbstractLocation.Meta):
            abstract = False

        def clean(self):
            # your own validation logic here...
            pass


    class FloorPlan(OrganizationMixin, AbstractFloorPlan):
        location = models.ForeignKey(Location)

        class Meta(AbstractFloorPlan.Meta):
            abstract = False

        def clean(self):
            # your own validation logic here...
            pass


    class ObjectLocation(OrganizationMixin, AbstractObjectLocation):
        location = models.ForeignKey(Location, models.PROTECT, blank=True, null=True)
        floorplan = models.ForeignKey(FloorPlan, models.PROTECT, blank=True, null=True)

        class Meta(AbstractObjectLocation.Meta):
            abstract = False

        def clean(self):
            # your own validation logic here...
            pass

Extending the admin
~~~~~~~~~~~~~~~~~~~

Following the previous `Organization` example, you can avoid duplicating
the admin code by importing the base admin classes and registering your
models with them.

But first you have to change a few settings in your ``settings.py``, these
are needed in order to load the admin templates and static files of
*django-loci* even if it's not listed in ``settings.INSTALLED_APPS``.

Add ``django.forms`` to ``INSTALLED_APPS``, now it should look like the
following:

.. code-block:: python

    INSTALLED_APPS = [
        # ...
        "django.contrib.gis",
        "django_loci",
        "django.contrib.admin",
        #      ↓
        "django.forms",  # <-- add this
        #      ↑
        "leaflet",
        "channels",
        # ...
    ]

Now add ``EXTENDED_APPS`` after ``INSTALLED_APPS``:

.. code-block:: python

    INSTALLED_APPS = [
        # ...
    ]

    EXTENDED_APPS = ("django_loci",)

Add ``openwisp_utils.staticfiles.DependencyFinder`` to
``STATICFILES_FINDERS``:

.. code-block:: python

    STATICFILES_FINDERS = [
        "django.contrib.staticfiles.finders.FileSystemFinder",
        "django.contrib.staticfiles.finders.AppDirectoriesFinder",
        "openwisp_utils.staticfiles.DependencyFinder",
    ]

Add ``openwisp_utils.loaders.DependencyLoader`` to ``TEMPLATES``:

.. code-block:: python

    TEMPLATES = [
        {
            "BACKEND": "django.template.backends.django.DjangoTemplates",
            "DIRS": [],
            "OPTIONS": {
                "loaders": [
                    "django.template.loaders.filesystem.Loader",
                    "django.template.loaders.app_directories.Loader",
                    # add the following line
                    "openwisp_utils.loaders.DependencyLoader",
                ],
                "context_processors": [
                    "django.template.context_processors.debug",
                    "django.template.context_processors.request",
                    "django.contrib.auth.context_processors.auth",
                    "django.contrib.messages.context_processors.messages",
                ],
            },
        }
    ]

Last step, add ``FORM_RENDERER``:

.. code-block:: python

    FORM_RENDERER = "django.forms.renderers.TemplatesSetting"

Then you can go ahead and create your ``admin.py`` file following the
example below:

.. code-block:: python

    # admin.py of your app
    from django.contrib import admin

    from django_loci.base.admin import (
        AbstractFloorPlanAdmin,
        AbstractFloorPlanForm,
        AbstractFloorPlanInline,
        AbstractLocationAdmin,
        AbstractLocationForm,
        AbstractObjectLocationForm,
        AbstractObjectLocationInline,
    )
    from django_loci.models import FloorPlan, Location, ObjectLocation


    class FloorPlanForm(AbstractFloorPlanForm):
        class Meta(AbstractFloorPlanForm.Meta):
            model = FloorPlan


    class FloorPlanAdmin(AbstractFloorPlanAdmin):
        form = FloorPlanForm


    class LocationForm(AbstractLocationForm):
        class Meta(AbstractLocationForm.Meta):
            model = Location


    class FloorPlanInline(AbstractFloorPlanInline):
        form = FloorPlanForm
        model = FloorPlan


    class LocationAdmin(AbstractLocationAdmin):
        form = LocationForm
        inlines = [FloorPlanInline]


    class ObjectLocationForm(AbstractObjectLocationForm):
        class Meta(AbstractObjectLocationForm.Meta):
            model = ObjectLocation


    class ObjectLocationInline(AbstractObjectLocationInline):
        model = ObjectLocation
        form = ObjectLocationForm


    admin.site.register(FloorPlan, FloorPlanAdmin)
    admin.site.register(Location, LocationAdmin)

Extending channel consumers
~~~~~~~~~~~~~~~~~~~~~~~~~~~

Extend the channel consumer of django-loci in this way:

.. code-block:: python

    from django_loci.channels.base import BaseLocationBroadcast
    from ..models import Location  # your own location model


    class LocationBroadcast(BaseLocationBroadcast):
        model = Location

Extend the broadcast consumer for all locations:

.. code-block:: python

    from django_loci.channels.base import BaseCommonLocationBroadcast
    from ..models import Location  # your own location model


    class CommonLocationBroadcast(BaseCommonLocationBroadcast):
        model = Location

Extending AppConfig
~~~~~~~~~~~~~~~~~~~

You may want to reuse the ``AppConfig`` class of *django-loci* too:

.. code-block:: python

    from django_loci.apps import LociConfig


    class MyConfig(LociConfig):
        name = "myapp"
        verbose_name = _("My custom app")

        def __setmodels__(self):
            from .models import Location

            self.location_model = Location

Installing for development
--------------------------

Install sqlite:

.. code-block:: shell

    sudo apt-get install sqlite3 libsqlite3-dev libsqlite3-mod-spatialite gdal-bin

Install your forked repo:

.. code-block:: shell

    git clone git://github.com/<your_fork>/django-loci
    cd django-loci/
    python setup.py develop

Install test requirements:

.. code-block:: shell

    pip install -r requirements-test.txt

Launch Redis:

.. code-block:: shell

    docker compose up -d redis

Create database:

.. code-block:: shell

    cd tests/
    ./manage.py migrate
    ./manage.py createsuperuser

Launch development server and SMTP debugging server:

.. code-block:: shell

    ./manage.py runserver

You can access the admin interface at http://127.0.0.1:8000/admin/.

Run tests with (make sure you have the `selenium dependencies
<https://openwisp.io/docs/dev/utils/developer/test-utilities.html#openwisp-utils-tests-seleniumtestmixin>`_
installed locally first):

.. code-block:: shell

    ./runtests

Contributing
------------

Please refer to the `OpenWISP Contribution Guidelines
<https://openwisp.io/docs/stable/developer/contributing.html>`_.

Questions
---------

See `Github Discussions
<https://github.com/openwisp/django-loci/discussions>`_.

Changelog
---------

See `CHANGES
<https://github.com/openwisp/django-loci/blob/master/CHANGES.rst>`_.

License
-------

See `LICENSE
<https://github.com/openwisp/django-loci/blob/master/LICENSE>`_.


================================================
FILE: conftest.py
================================================
import pytest


@pytest.fixture(scope="session")
def django_db_modify_db_settings():
    """used to speed up pytest with django"""
    pass


================================================
FILE: django_loci/__init__.py
================================================
VERSION = (1, 3, 0, "alpha")
__version__ = VERSION  # alias


def get_version():
    version = "%s.%s" % (VERSION[0], VERSION[1])
    if VERSION[2]:
        version = "%s.%s" % (version, VERSION[2])
    if VERSION[3:] == ("alpha", 0):
        version = "%s pre-alpha" % version
    else:
        if VERSION[3] != "final":
            try:
                rev = VERSION[4]
            except IndexError:
                rev = 0
            version = "%s%s%s" % (version, VERSION[3][0:1], rev)
    return version


================================================
FILE: django_loci/admin.py
================================================
from django.contrib import admin

from .base.admin import (
    AbstractFloorPlanAdmin,
    AbstractFloorPlanForm,
    AbstractFloorPlanInline,
    AbstractLocationAdmin,
    AbstractLocationForm,
    AbstractObjectLocationForm,
    AbstractObjectLocationInline,
)
from .models import FloorPlan, Location, ObjectLocation


class FloorPlanForm(AbstractFloorPlanForm):
    class Meta(AbstractFloorPlanForm.Meta):
        model = FloorPlan


class FloorPlanAdmin(AbstractFloorPlanAdmin):
    form = FloorPlanForm


class LocationForm(AbstractLocationForm):
    class Meta(AbstractLocationForm.Meta):
        model = Location


class FloorPlanInline(AbstractFloorPlanInline):
    form = FloorPlanForm
    model = FloorPlan


class LocationAdmin(AbstractLocationAdmin):
    form = LocationForm
    inlines = [FloorPlanInline]


class ObjectLocationForm(AbstractObjectLocationForm):
    class Meta(AbstractObjectLocationForm.Meta):
        model = ObjectLocation


class ObjectLocationInline(AbstractObjectLocationInline):
    model = ObjectLocation
    form = ObjectLocationForm


admin.site.register(FloorPlan, FloorPlanAdmin)
admin.site.register(Location, LocationAdmin)


================================================
FILE: django_loci/apps.py
================================================
import logging

from django.apps import AppConfig
from django.conf import settings
from django.core.checks import Warning, register
from django.utils.translation import gettext_lazy as _

from .base.geocoding_views import geocode
from .channels.receivers import load_location_receivers

logger = logging.getLogger(__name__)


@register("geocoding", deploy=True)
def test_geocoding(app_configs=None, **kwargs):
    warnings = []
    # do not run check during development, testing or if feature is disabled
    if not settings.DEBUG or not getattr(settings, "TESTING", False):
        location = geocode("Red Square")
        if not location:
            warnings.append(
                Warning(
                    "Geocoding service is experiencing issues or is not properly configured"
                )
            )
    return warnings


class LociConfig(AppConfig):
    name = "django_loci"
    verbose_name = _("django-loci")
    default_auto_field = "django.db.models.AutoField"

    def __setmodels__(self):
        """
        this method can be overridden in 3rd party apps
        """
        from .models import Location

        self.location_model = Location

    def ready(self):
        import leaflet

        leaflet.app_settings["NO_GLOBALS"] = False
        self.__setmodels__()
        self._load_receivers()

    def _load_receivers(self):
        load_location_receivers(sender=self.location_model)


================================================
FILE: django_loci/base/__init__.py
================================================


================================================
FILE: django_loci/base/admin.py
================================================
import json
from functools import partialmethod

from django import forms
from django.contrib import admin
from django.contrib.admin import widgets
from django.contrib.admin.sites import site
from django.contrib.contenttypes.admin import GenericStackedInline
from django.core.exceptions import ValidationError
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.urls import path
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from leaflet.admin import LeafletGeoAdmin

from openwisp_utils.admin import TimeReadonlyAdminMixin

from ..base.geocoding_views import geocode_view, reverse_geocode_view
from ..fields import GeometryField
from ..widgets import FloorPlanWidget, ImageWidget
from .models import AbstractFloorPlan, AbstractLocation


class ReadOnlyMixin:
    """Mixin for forms to handle field widgets for view-only users."""

    def set_readonly_attribute(self, user, fields):
        """
        This method sets the read_only attribute on widget for the fields
        which are required to be rendered as it is to view-only users. This is
        done as 'AdminReadonlyField' renders the widget if 'read_only' is set on
        the field's widget. Also the required field must be present in self.fields
        """
        app_label = self.Meta.model._meta.app_label
        model_name = self.Meta.model._meta.model_name
        if (
            user
            and user.has_perm(f"{app_label}.view_{model_name}")
            and not user.has_perm(f"{app_label}.change_{model_name}")
        ):
            for field in fields:
                if field in self.fields:
                    setattr(self.fields[field].widget, "read_only", True)
            # Return 'True' to allow any further handling for view-only users
            return True
        return False


class AbstractFloorPlanForm(ReadOnlyMixin, forms.ModelForm):
    # define the image field to add it in self.fields
    # to render it for view-only
    image = forms.ImageField(
        widget=ImageWidget(),
        help_text=AbstractFloorPlan._meta.get_field("image").help_text,
    )

    class Meta:
        exclude = tuple()

    class Media:
        css = {"all": ("django-loci/css/loci.css",)}

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if getattr(self, "_user", None):
            self.set_readonly_attribute(self._user, ["image"])
            # user is set on Form class which gets instantiated for each request
            del self.__class__._user


class LocationRawIdWidget(widgets.ForeignKeyRawIdWidget):
    """
    When selecting a location object
    via a popup window in the floorplan
    admin add view, display only indoor locations
    """

    def url_parameters(self):
        url_params = super().url_parameters()
        url_params["type__exact"] = "indoor"
        return url_params


class AbstractFloorPlanAdmin(TimeReadonlyAdminMixin, admin.ModelAdmin):
    list_display = ["__str__", "location", "floor", "created", "modified"]
    list_select_related = ["location"]
    search_fields = ["location__name"]
    raw_id_fields = ["location"]
    save_on_top = True

    def get_form(self, request, obj=None, **kwargs):
        form = super(AbstractFloorPlanAdmin, self).get_form(request, obj, **kwargs)
        permissions = self.get_model_perms(request)
        # location field is not in base_fields if user has only view-only permission
        if permissions["add"] and permissions["change"] and permissions["delete"]:
            form.base_fields["location"].widget = LocationRawIdWidget(
                rel=self.model._meta.get_field("location").remote_field, admin_site=site
            )
        # pass user to form for handling permissions for readonly view
        form._user = request.user
        return form


class AbstractLocationForm(ReadOnlyMixin, forms.ModelForm):
    # define the geometry field to add it in self.fields
    # to render it for view-only
    geometry = GeometryField(required=False)

    class Meta:
        exclude = tuple()

    class Media:
        js = (
            "admin/js/jquery.init.js",
            "django-loci/js/loci.js",
            "django-loci/js/floorplan-inlines.js",
            "django-loci/js/vendor/reconnecting-websocket.min.js",
        )
        css = {"all": ("django-loci/css/loci.css",)}

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if getattr(self, "_user", None):
            self.set_readonly_attribute(self._user, ["geometry"])
            # user is set on Form class which gets instantiated for each request
            del self.__class__._user


class AbstractFloorPlanInline(TimeReadonlyAdminMixin, admin.StackedInline):
    extra = 0
    ordering = ("floor",)


class AbstractLocationAdmin(TimeReadonlyAdminMixin, LeafletGeoAdmin):
    list_display = ["name", "short_type", "is_mobile", "created", "modified"]
    search_fields = ["name", "address"]
    list_filter = ["type", "is_mobile"]
    save_on_top = True

    # This allows apps which extend django-loci to load this template with less hacks
    change_form_template = "admin/django_loci/location_change_form.html"

    # override get_form method to pass user to form
    # for handling permissions for readonly view
    def get_form(self, request, obj=None, **kwargs):
        form = super().get_form(request, obj, **kwargs)
        form._user = request.user
        return form

    def get_urls(self):
        # hardcoding django_loci as the prefix for the
        # view names makes it much easier to extend
        # without having to change templates
        app_label = "django_loci"
        return [
            path(
                "<uuid:pk>/json/",
                self.admin_site.admin_view(self.json_view),
                name="{0}_location_json".format(app_label),
            ),
            path(
                "<uuid:pk>/floorplans/json/",
                self.admin_site.admin_view(self.floorplans_json_view),
                name="{0}_location_floorplans_json".format(app_label),
            ),
            path(
                "geocode/",
                self.admin_site.admin_view(geocode_view),
                name="{0}_location_geocode_api".format(app_label),
            ),
            path(
                "reverse-geocode/",
                self.admin_site.admin_view(reverse_geocode_view),
                name="{0}_location_reverse_geocode_api".format(app_label),
            ),
        ] + super().get_urls()

    def json_view(self, request, pk):
        instance = get_object_or_404(self.model, pk=pk)
        return JsonResponse(
            {
                "name": instance.name,
                "type": instance.type,
                "is_mobile": instance.is_mobile,
                "address": instance.address,
                "geometry": (
                    json.loads(instance.geometry.json) if instance.geometry else None
                ),
            }
        )

    def floorplans_json_view(self, request, pk):
        instance = get_object_or_404(self.model, pk=pk)
        choices = []
        for floorplan in instance.floorplan_set.all():
            choices.append(
                {
                    "id": floorplan.pk,
                    "str": str(floorplan),
                    "floor": floorplan.floor,
                    "image": floorplan.image.url,
                    "image_width": floorplan.image.width,
                    "image_height": floorplan.image.height,
                }
            )
        return JsonResponse({"choices": choices})

    def get_formset_kwargs(self, request, obj, inline, prefix):
        formset_kwargs = super().get_formset_kwargs(request, obj, inline, prefix)
        # manually set TOTAL_FORMS to 0 if the type is outdoor to avoid floorplan form creation
        if request.method == "POST" and formset_kwargs["data"]["type"] == "outdoor":
            formset_kwargs["data"]["floorplan_set-TOTAL_FORMS"] = "0"
        return formset_kwargs


class UnvalidatedChoiceField(forms.ChoiceField):
    """
    skips ChoiceField validation to allow custom options
    """

    def validate(self, value):
        super(forms.ChoiceField, self).validate(value)


_get_field = AbstractLocation._meta.get_field


class AbstractObjectLocationForm(ReadOnlyMixin, forms.ModelForm):
    FORM_CHOICES = (
        ("", _("--- Please select an option ---")),
        ("new", _("New")),
        ("existing", _("Existing")),
    )
    LOCATION_TYPES = (
        FORM_CHOICES[0],
        AbstractLocation.LOCATION_TYPES[0],
        AbstractLocation.LOCATION_TYPES[1],
    )
    location_selection = forms.ChoiceField(choices=FORM_CHOICES, required=False)
    name = forms.CharField(
        label=_("Location name"),
        max_length=75,
        required=False,
        help_text=_get_field("name").help_text,
    )
    address = forms.CharField(max_length=128, required=False)
    type = forms.ChoiceField(
        choices=LOCATION_TYPES, required=True, help_text=_get_field("type").help_text
    )
    is_mobile = forms.BooleanField(
        label=_get_field("is_mobile").verbose_name,
        help_text=_get_field("is_mobile").help_text,
        required=False,
    )
    geometry = GeometryField(required=False)
    floorplan_selection = forms.ChoiceField(required=False, choices=FORM_CHOICES)
    floorplan = UnvalidatedChoiceField(
        choices=((None, FORM_CHOICES[0][1]),), required=False
    )
    floor = forms.IntegerField(required=False)
    image = forms.ImageField(
        required=False,
        widget=ImageWidget(thumbnail=False),
        help_text=_("floor plan image"),
    )
    indoor = forms.CharField(
        max_length=64,
        required=False,
        label=_("indoor position"),
        widget=FloorPlanWidget,
    )

    class Meta:
        exclude = tuple()

    class Media:
        js = (
            "admin/js/jquery.init.js",
            "django-loci/js/loci.js",
            "django-loci/js/floorplan-widget.js",
            "django-loci/js/vendor/reconnecting-websocket.min.js",
        )
        css = {
            "all": ("django-loci/css/loci.css", "django-loci/css/floorplan-widget.css")
        }

    def __init__(self, *args, **kwargs):
        # user is passed via partialmethod in ObjectLocationInline
        user = kwargs.pop("user", None)
        super().__init__(*args, **kwargs)
        # set initial values for custom fields
        initial = {}
        location = self._get_initial_location()
        floorplan = self._get_initial_floorplan()
        if location:
            initial.update(
                {
                    "location_selection": "existing",
                    "type": location.type,
                    "is_mobile": location.is_mobile,
                    "name": location.name,
                    "address": location.address,
                    "geometry": location.geometry,
                }
            )
        if floorplan:
            initial.update(
                {
                    "floorplan_selection": "existing",
                    "floorplan": floorplan.pk,
                    "floor": floorplan.floor,
                    "image": floorplan.image,
                }
            )
            floorplan_choices = self.fields["floorplan"].choices
            self.fields["floorplan"].choices = floorplan_choices + [
                (floorplan.pk, floorplan)
            ]

        if self.set_readonly_attribute(user, ["geometry", "image", "indoor"]):
            # For view only permissions, 'AdminReadonlyField' reads from instance
            for field, value in initial.items():
                if field != "floorplan":
                    setattr(self.instance, field, value)
                else:
                    setattr(self.instance.floorplan, "pk", value)
            # Added id to indoor widget to display indoor position
            self.fields["indoor"].widget.attrs.update({"id": "id_indoor"})
        self.initial.update(initial)

    def _get_initial_location(self):
        return self.instance.location

    def _get_initial_floorplan(self):
        return self.instance.floorplan

    @cached_property
    def floorplan_model(self):
        return self.Meta.model.floorplan.field.remote_field.model

    @cached_property
    def location_model(self):
        return self.Meta.model.location.field.remote_field.model

    def clean_floorplan(self):
        floorplan_model = self.floorplan_model
        type_ = self.cleaned_data.get("type")
        floorplan_selection = self.cleaned_data.get("floorplan_selection")
        if type_ != "indoor" or floorplan_selection == "new" or not floorplan_selection:
            return None
        pk = self.cleaned_data["floorplan"]
        if not pk:
            raise ValidationError(_("No floorplan selected"))
        try:
            fl = floorplan_model.objects.get(pk=pk)
        except floorplan_model.DoesNotExist:
            raise ValidationError(_("Selected floorplan does not exist"))
        if fl.location != self.cleaned_data["location"]:
            raise ValidationError(
                _("This floorplan is associated to a different location")
            )
        return fl

    def clean(self):
        data = self.cleaned_data
        type_ = data.get("type")
        is_mobile = data["is_mobile"]
        msg = _("this field is required for locations of type %(type)s")
        fields = []
        if not is_mobile and type_ in ["outdoor", "indoor"]:
            fields += ["location_selection", "name", "address", "geometry"]
        # sync location, clean indoor field basis type
        if location := data.get("location"):
            location.type = type_
            data["indoor"] = None if type_ != "indoor" else data.get("indoor")
        if type_ == "indoor":
            if data.get("floorplan_selection") == "existing":
                fields.append("floorplan")
            if data.get("image"):
                fields += ["floor", "indoor"]
        elif is_mobile and not data.get("location"):
            data["name"] = ""
            data["address"] = ""
            data["geometry"] = ""
            data["location_selection"] = "new"
        for field in fields:
            if field in data and data[field] in [None, ""]:
                params = {"type": type_}
                err = ValidationError(msg, params=params)
                self.add_error(field, err)

    def _get_location_instance(self):
        data = self.cleaned_data
        location = data.get("location") or self.location_model()
        location.type = data.get("type") or location.type
        location.is_mobile = data.get("is_mobile", location.is_mobile)
        location.name = data.get("name") or location.name
        location.address = data.get("address") or location.address
        location.geometry = data.get("geometry") or location.geometry
        return location

    def _get_floorplan_instance(self):
        data = self.cleaned_data
        instance = self.instance
        floorplan = data.get("floorplan") or self.floorplan_model()
        floorplan.location = instance.location
        floorplan.floor = data.get("floor")
        # the image path is updated only during creation
        # or if the image has been actually changed
        if data.get("image") and self.initial.get("image") != data.get("image"):
            floorplan.image = data["image"]
        return floorplan

    def save(self, commit=True):
        instance = self.instance
        data = self.cleaned_data
        # create or update location
        instance.location = self._get_location_instance()
        # set name of mobile locations automatically
        if data["is_mobile"] and not instance.location.name:
            instance.location.name = str(self.instance.content_object)
        instance.location.save()
        # create or update floorplan
        floorplan = self._get_floorplan_instance()
        if data["type"] == "indoor" and floorplan.image:
            instance.floorplan = floorplan
            instance.floorplan.save()
        # call super
        return super().save(commit=True)


class ObjectLocationMixin(TimeReadonlyAdminMixin):
    """
    Base ObjectLocationInline logic, can be imported and
    mixed in with different inline classes (stacked, tabular).
    If you need the generic inline look below.
    """

    verbose_name = _("geographic information")
    verbose_name_plural = verbose_name
    raw_id_fields = ("location",)
    max_num = 1
    extra = 1
    template = "admin/django_loci/location_inline.html"
    fieldsets = (
        (None, {"fields": ("location_selection",)}),
        (
            "Geographic coordinates",
            {
                "classes": ("loci", "coords"),
                "fields": (
                    "location",
                    "type",
                    "is_mobile",
                    "name",
                    "address",
                    "geometry",
                ),
            },
        ),
        (
            "Indoor coordinates",
            {
                "classes": ("indoor", "coords"),
                "fields": (
                    "floorplan_selection",
                    "floorplan",
                    "floor",
                    "image",
                    "indoor",
                ),
            },
        ),
    )

    # override get_formset method to pass user to form
    def get_formset(self, request, obj=..., **kwargs):
        formset = super().get_formset(request, obj, **kwargs)
        formset._construct_form = partialmethod(
            formset._construct_form, user=request.user
        )
        return formset


class AbstractObjectLocationInline(ObjectLocationMixin, GenericStackedInline):
    """
    Generic Inline + ObjectLocationMixin
    """


================================================
FILE: django_loci/base/geocoding_views.py
================================================
from django.http import JsonResponse
from django.utils.module_loading import import_string
from geopy.extra.rate_limiter import RateLimiter

from ..settings import (
    DJANGO_LOCI_GEOCODE_API_KEY,
    DJANGO_LOCI_GEOCODE_FAILURE_DELAY,
    DJANGO_LOCI_GEOCODE_RETRIES,
    DJANGO_LOCI_GEOCODER,
)

geocoder = import_string(f"geopy.geocoders.{DJANGO_LOCI_GEOCODER}")
if DJANGO_LOCI_GEOCODER != "GoogleV3":
    geolocator = geocoder(user_agent="django_loci")
else:
    geolocator = geocoder(api_key=DJANGO_LOCI_GEOCODE_API_KEY)  # pragma: nocover
geocode = RateLimiter(
    geolocator.geocode,
    max_retries=DJANGO_LOCI_GEOCODE_RETRIES,
    error_wait_seconds=DJANGO_LOCI_GEOCODE_FAILURE_DELAY,
)
reverse_geocode = RateLimiter(
    geolocator.reverse,
    max_retries=DJANGO_LOCI_GEOCODE_RETRIES,
    error_wait_seconds=DJANGO_LOCI_GEOCODE_FAILURE_DELAY,
)


def geocode_view(request):
    address = request.GET.get("address")
    if address is None:
        return JsonResponse({"error": "Address parameter not defined"}, status=400)
    location = geocode(address)
    if location is None:
        return JsonResponse({"error": "Not found location with given name"}, status=404)
    return JsonResponse({"lat": location.latitude, "lng": location.longitude})


def reverse_geocode_view(request):
    lat = request.GET.get("lat")
    lng = request.GET.get("lng")
    if not lat or not lng:
        return JsonResponse({"error": "lat or lng parameter not defined"}, status=400)
    location = reverse_geocode((lat, lng))
    if location is None:
        return JsonResponse({"address": ""}, status=404)
    # if multiple locations are returned, use the most relevant result
    location = location[0] if isinstance(location, list) else location
    address = str(location.address)
    return JsonResponse({"address": address})


================================================
FILE: django_loci/base/models.py
================================================
import logging

from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.contrib.gis.db import models
from django.contrib.humanize.templatetags.humanize import ordinal
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _

from openwisp_utils.base import TimeStampedEditableModel

from .. import settings as app_settings

logger = logging.getLogger(__name__)


class AbstractLocation(TimeStampedEditableModel):
    LOCATION_TYPES = (
        ("outdoor", _("Outdoor environment (eg: street, square, garden, land)")),
        (
            "indoor",
            _("Indoor environment (eg: building, roofs, subway, large vehicles)"),
        ),
    )
    name = models.CharField(
        _("name"),
        max_length=75,
        help_text=_(
            "A descriptive name of the location " "(building name, company name, etc.)"
        ),
    )
    type = models.CharField(
        choices=LOCATION_TYPES,
        max_length=8,
        db_index=True,
        help_text=_("indoor locations can have floorplans associated to them"),
    )
    is_mobile = models.BooleanField(
        _("is mobile?"),
        default=False,
        db_index=True,
        help_text=_("is this location a moving object?"),
    )
    address = models.CharField(_("address"), db_index=True, max_length=256, blank=True)
    geometry = models.GeometryField(_("geometry"), blank=True, null=True)

    class Meta:
        abstract = True

    # overriding __init__ to store the initial type
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._initial_type = self.type

    def __str__(self):
        return self.name

    def clean(self):
        self._validate_geometry_if_not_mobile()

    def _validate_geometry_if_not_mobile(self):
        """
        geometry can be NULL, but only if_mobile is True
        otherwise raise a ValidationError
        """
        if not self.is_mobile and not self.geometry:
            raise ValidationError({"geometry": _("No geometry value provided.")})

    @property
    def short_type(self):
        return _(self.type.capitalize())

    # save method is automatically wrapped in atomic transaction
    def save(self, *args, **kwargs):
        # if location type is changed to outdoor, remove all associated floorplans
        if (
            self.type != self._initial_type
            and not self._state.adding
            and self.type == "outdoor"
            and self.floorplan_set.exists()
        ):
            self.objectlocation_set.update(floorplan=None, indoor=None)
            self.floorplan_set.all().delete()
        return super().save(*args, **kwargs)


class AbstractFloorPlan(TimeStampedEditableModel):
    location = models.ForeignKey("django_loci.Location", on_delete=models.CASCADE)
    floor = models.SmallIntegerField(_("floor"))
    image = models.ImageField(
        _("image"),
        upload_to=app_settings.FLOORPLAN_STORAGE.upload_to,
        storage=app_settings.FLOORPLAN_STORAGE(),
        help_text=_("floor plan image"),
    )

    class Meta:
        abstract = True
        unique_together = ("location", "floor")

    def __str__(self):
        if self.floor != 0:
            suffix = _("{0} floor").format(ordinal(self.floor))
        else:
            suffix = _("ground floor")
        return "{0} {1}".format(self.location.name, suffix)

    def clean(self):
        self._validate_location_type()

    def delete(self, *args, **kwargs):
        super().delete(*args, **kwargs)
        self._remove_image()

    def _validate_location_type(self):
        if not hasattr(self, "location") or not hasattr(self.location, "type"):
            return
        if self.location.type and self.location.type != "indoor":
            msg = "floorplans can only be associated " 'to locations of type "indoor"'
            raise ValidationError(msg)

    def _remove_image(self):
        path = self.image.name
        if self.image.storage.exists(path):
            self.image.delete(save=False)
        else:
            msg = "floorplan image not found while deleting {0}:\n{1}"
            logger.error(msg.format(self, path))


class AbstractObjectLocation(TimeStampedEditableModel):
    LOCATION_TYPES = (
        ("outdoor", _("Outdoor")),
        ("indoor", _("Indoor")),
        ("mobile", _("Mobile")),
    )
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
    object_id = models.CharField(max_length=36, db_index=True)
    content_object = GenericForeignKey("content_type", "object_id")
    location = models.ForeignKey(
        "django_loci.Location", models.PROTECT, blank=True, null=True
    )
    floorplan = models.ForeignKey(
        "django_loci.Floorplan", models.PROTECT, blank=True, null=True
    )
    indoor = models.CharField(
        _("indoor position"), max_length=64, blank=True, null=True
    )

    class Meta:
        abstract = True
        unique_together = ("content_type", "object_id")

    def _clean_indoor_location(self):
        """
        ensures related floorplan is not
        associated to a different location
        """
        # skip validation if the instance does not
        # have a floorplan assigned to it yet
        if not self.location or self.location.type != "indoor" or not self.floorplan:
            return
        if self.location != self.floorplan.location:
            raise ValidationError(
                _("Invalid floorplan (belongs to a different location)")
            )

    def _raise_invalid_indoor(self):
        raise ValidationError({"indoor": _("invalid value")})

    def _clean_indoor_position(self):
        """
        ensures invalid indoor position values
        cannot be inserted into the database
        """
        # stop here if location not defined yet
        # (other validation errors will be triggered)
        if not self.location:
            return
        # do not allow non empty values for outdoor locations
        if self.location.type != "indoor" and self.indoor not in [None, ""]:
            self._raise_invalid_indoor()
        # allow empty values for outdoor locations
        elif self.location.type != "indoor" and self.indoor in [None, ""]:
            return
        # allow empty values for indoor whose coordinates are not yet received
        elif (
            self.location.type == "indoor"
            and self.indoor in [None, ""]
            and not self.floorplan
        ):
            return
        # split indoor position
        position = []
        if self.indoor:
            position = self.indoor.split(",")
        # must have at least e elements
        if len(position) != 2:
            self._raise_invalid_indoor()
        # each member must be convertible to float
        else:
            for part in position:
                try:
                    float(part)
                except ValueError:
                    self._raise_invalid_indoor()

    def clean(self):
        self._clean_indoor_location()
        self._clean_indoor_position()


================================================
FILE: django_loci/channels/__init__.py
================================================


================================================
FILE: django_loci/channels/asgi.py
================================================
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from django.core.asgi import get_asgi_application
from django.urls import path

from django_loci.channels.base import (
    common_location_broadcast_path,
    location_broadcast_path,
)
from django_loci.channels.consumers import CommonLocationBroadcast, LocationBroadcast

channel_routing = ProtocolTypeRouter(
    {
        "websocket": AllowedHostsOriginValidator(
            AuthMiddlewareStack(
                URLRouter(
                    [
                        path(
                            location_broadcast_path,
                            LocationBroadcast.as_asgi(),
                            name="LocationChannel",
                        ),
                        path(
                            common_location_broadcast_path,
                            CommonLocationBroadcast.as_asgi(),
                            name="AllLocationChannel",
                        ),
                    ]
                )
            )
        ),
        "http": get_asgi_application(),
    }
)


================================================
FILE: django_loci/channels/base.py
================================================
from asgiref.sync import async_to_sync
from channels.generic.websocket import JsonWebsocketConsumer
from django.core.exceptions import ValidationError

location_broadcast_path = "ws/loci/location/<uuid:pk>/"
common_location_broadcast_path = "ws/loci/location/"


def _get_object_or_none(model, **kwargs):
    try:
        return model.objects.get(**kwargs)
    except (ValidationError, model.DoesNotExist):
        return None


class BaseLocationBroadcast(JsonWebsocketConsumer):
    """
    Base WebSocket consumer for broadcasting location coordinate changes
    to authorized users (superusers or organization operators).
    """

    def connect(self):
        """
        Handle WebSocket connection: authenticate user, validate location,
        and join the location-specific broadcast group.
        """
        self.pk = None
        try:
            user = self.scope["user"]
            self.pk = self.scope["url_route"]["kwargs"]["pk"]
        except KeyError:
            # Will fall here when the scope does not have
            # one of the variables, most commonly, user
            # (When a user tries to access without loggin in)
            self.close()
        else:
            location = _get_object_or_none(self.model, pk=self.pk)
            if not location or not self.is_authorized(user, location):
                self.close()
                return
            self.accept()
            # Create group name once
            self.group_name = "loci.mobile-location.{}".format(self.pk)
            async_to_sync(self.channel_layer.group_add)(
                self.group_name, self.channel_name
            )

    def is_authorized(self, user, location):
        """
        Check if the user has permission to receive location broadcasts.
        Requires authentication and change or view permissions on the location.
        """
        perm = "{0}.change_location".format(self.model._meta.app_label)
        # allow users with view permission
        readperm = "{0}.view_location".format(self.model._meta.app_label)
        authenticated = user.is_authenticated
        is_permitted = user.has_perm(perm) or user.has_perm(readperm)
        return authenticated and (user.is_superuser or (user.is_staff and is_permitted))

    def send_message(self, event):
        """
        Send JSON event data to the connected WebSocket client.
        """
        self.send_json(event["message"])

    def disconnect(self, close_code):
        """
        Handle cleanup on WebSocket disconnection.
        """
        # The group_name is set only when the connection is accepted.
        # Remove the user from the group, if it exists.
        if hasattr(self, "group_name"):
            async_to_sync(self.channel_layer.group_discard)(
                self.group_name, self.channel_name
            )


class BaseCommonLocationBroadcast(BaseLocationBroadcast):

    def connect(self):
        """
        Override connect to handle subscription to all locations
        without requiring a specific location PK.
        """
        try:
            user = self.scope["user"]
        except KeyError:
            self.close()
        else:
            if not self.is_authorized(user, None):
                self.close()
                return
            self.accept()
            self.join_groups(user)

    def join_groups(self, user):
        """
        Subscribe to broadcast groups.
        Subclasses can override to add user-specific groups (using the ``user`` argument).
        """
        self.group_name = "loci.mobile-location.common"
        async_to_sync(self.channel_layer.group_add)(self.group_name, self.channel_name)


================================================
FILE: django_loci/channels/consumers.py
================================================
from ..models import Location
from .base import BaseCommonLocationBroadcast, BaseLocationBroadcast


class LocationBroadcast(BaseLocationBroadcast):
    model = Location


class CommonLocationBroadcast(BaseCommonLocationBroadcast):
    model = Location


================================================
FILE: django_loci/channels/receivers.py
================================================
import json

import channels.layers
from asgiref.sync import async_to_sync
from django.db.models.signals import post_save
from django.dispatch import receiver


def update_mobile_location(sender, instance, **kwargs):
    """
    Sends WebSocket updates when a location record is updated.
    - Sends a message to the location specific group.
    - Sends a message to a common group for tracking all mobile location updates.
    """
    if not kwargs.get("created") and instance.geometry:
        channel_layer = channels.layers.get_channel_layer()

        # Send update to location specific group
        async_to_sync(channel_layer.group_send)(
            f"loci.mobile-location.{instance.pk}",
            {
                "type": "send_message",
                "message": {
                    "geometry": json.loads(instance.geometry.geojson),
                    "address": instance.address,
                },
            },
        )

        # Send update to common mobile location group
        async_to_sync(channel_layer.group_send)(
            "loci.mobile-location.common",
            {
                "type": "send_message",
                "message": {
                    "id": str(instance.pk),
                    "geometry": json.loads(instance.geometry.geojson),
                    "address": instance.address,
                    "name": instance.name,
                    "type": instance.type,
                    "is_mobile": instance.is_mobile,
                },
            },
        )


def load_location_receivers(sender):
    """
    enables signal listening when called
    designed to be called in AppConfig subclasses
    """
    # using decorator pattern with old syntax
    # in order to decorate an existing function
    receiver(post_save, sender=sender, dispatch_uid="ws_update_mobile_location")(
        update_mobile_location
    )


================================================
FILE: django_loci/fields.py
================================================
from leaflet.forms.fields import GeometryField as BaseGeometryField

from .widgets import LeafletWidget


class GeometryField(BaseGeometryField):
    widget = LeafletWidget


================================================
FILE: django_loci/migrations/0001_initial.py
================================================
# -*- coding: utf-8 -*-
# Generated by Django 1.11.7 on 2017-11-25 10:09
import uuid

import django.contrib.gis.db.models.fields
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
from django.db import migrations, models

import django_loci.storage


class Migration(migrations.Migration):
    initial = True

    dependencies = [("contenttypes", "0002_remove_content_type_name")]

    operations = [
        migrations.CreateModel(
            name="FloorPlan",
            fields=[
                (
                    "id",
                    models.UUIDField(
                        default=uuid.uuid4,
                        editable=False,
                        primary_key=True,
                        serialize=False,
                    ),
                ),
                (
                    "created",
                    model_utils.fields.AutoCreatedField(
                        default=django.utils.timezone.now,
                        editable=False,
                        verbose_name="created",
                    ),
                ),
                (
                    "modified",
                    model_utils.fields.AutoLastModifiedField(
                        default=django.utils.timezone.now,
                        editable=False,
                        verbose_name="modified",
                    ),
                ),
                ("floor", models.SmallIntegerField(verbose_name="floor")),
                (
                    "image",
                    models.ImageField(
                        help_text="floor plan image",
                        storage=django_loci.storage.OverwriteStorage(),
                        upload_to=django_loci.storage.OverwriteStorage.upload_to,
                        verbose_name="image",
                    ),
                ),
            ],
        ),
        migrations.CreateModel(
            name="Location",
            fields=[
                (
                    "id",
                    models.UUIDField(
                        default=uuid.uuid4,
                        editable=False,
                        primary_key=True,
                        serialize=False,
                    ),
                ),
                (
                    "created",
                    model_utils.fields.AutoCreatedField(
                        default=django.utils.timezone.now,
                        editable=False,
                        verbose_name="created",
                    ),
                ),
                (
                    "modified",
                    model_utils.fields.AutoLastModifiedField(
                        default=django.utils.timezone.now,
                        editable=False,
                        verbose_name="modified",
                    ),
                ),
                (
                    "name",
                    models.CharField(
                        help_text="A descriptive name of the location (building name, company name, etc.)",
                        max_length=75,
                        verbose_name="name",
                    ),
                ),
                (
                    "type",
                    models.CharField(
                        choices=[
                            (
                                "outdoor",
                                "Outdoor environment (eg: street, square, garden, land)",
                            ),
                            (
                                "indoor",
                                "Indoor environment (eg: building, roofs, subway, large vehicles)",
                            ),
                        ],
                        db_index=True,
                        help_text="indoor locations can have floorplans associated to them",
                        max_length=8,
                    ),
                ),
                (
                    "is_mobile",
                    models.BooleanField(
                        db_index=True,
                        default=False,
                        help_text="is this location a moving object?",
                        verbose_name="is mobile?",
                    ),
                ),
                (
                    "address",
                    models.CharField(
                        blank=True,
                        db_index=True,
                        max_length=256,
                        verbose_name="address",
                    ),
                ),
                (
                    "geometry",
                    django.contrib.gis.db.models.fields.GeometryField(
                        blank=True, null=True, srid=4326, verbose_name="geometry"
                    ),
                ),
            ],
            options={"abstract": False},
        ),
        migrations.CreateModel(
            name="ObjectLocation",
            fields=[
                (
                    "id",
                    models.UUIDField(
                        default=uuid.uuid4,
                        editable=False,
                        primary_key=True,
                        serialize=False,
                    ),
                ),
                (
                    "created",
                    model_utils.fields.AutoCreatedField(
                        default=django.utils.timezone.now,
                        editable=False,
                        verbose_name="created",
                    ),
                ),
                (
                    "modified",
                    model_utils.fields.AutoLastModifiedField(
                        default=django.utils.timezone.now,
                        editable=False,
                        verbose_name="modified",
                    ),
                ),
                ("object_id", models.CharField(db_index=True, max_length=36)),
                (
                    "indoor",
                    models.CharField(
                        blank=True,
                        max_length=64,
                        null=True,
                        verbose_name="indoor position",
                    ),
                ),
                (
                    "content_type",
                    models.ForeignKey(
                        on_delete=django.db.models.deletion.CASCADE,
                        to="contenttypes.ContentType",
                    ),
                ),
                (
                    "floorplan",
                    models.ForeignKey(
                        blank=True,
                        null=True,
                        on_delete=django.db.models.deletion.PROTECT,
                        to="django_loci.FloorPlan",
                    ),
                ),
                (
                    "location",
                    models.ForeignKey(
                        blank=True,
                        null=True,
                        on_delete=django.db.models.deletion.PROTECT,
                        to="django_loci.Location",
                    ),
                ),
            ],
        ),
        migrations.AddField(
            model_name="floorplan",
            name="location",
            field=models.ForeignKey(
                on_delete=django.db.models.deletion.CASCADE, to="django_loci.Location"
            ),
        ),
        migrations.AlterUniqueTogether(
            name="objectlocation", unique_together=set([("content_type", "object_id")])
        ),
        migrations.AlterUniqueTogether(
            name="floorplan", unique_together=set([("location", "floor")])
        ),
    ]


================================================
FILE: django_loci/migrations/__init__.py
================================================


================================================
FILE: django_loci/models.py
================================================
from .base.models import AbstractFloorPlan, AbstractLocation, AbstractObjectLocation


class Location(AbstractLocation):
    class Meta(AbstractLocation.Meta):
        abstract = False


class FloorPlan(AbstractFloorPlan):
    class Meta(AbstractFloorPlan.Meta):
        abstract = False


class ObjectLocation(AbstractObjectLocation):
    class Meta(AbstractObjectLocation.Meta):
        abstract = False


================================================
FILE: django_loci/settings.py
================================================
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string

DJANGO_LOCI_GEOCODER = getattr(settings, "DJANGO_LOCI_GEOCODER", "ArcGIS")
DJANGO_LOCI_GEOCODE_FAILURE_DELAY = getattr(
    settings, "DJANGO_LOCI_GEOCODE_FAILURE_DELAY", 1
)
DJANGO_LOCI_GEOCODE_RETRIES = getattr(settings, "DJANGO_LOCI_GEOCODE_RETRIES", 3)
DJANGO_LOCI_GEOCODE_API_KEY = getattr(
    settings, "DJANGO_LOCI_GEOCODE_GOOGLE_API_KEY", None
)
FLOORPLAN_STORAGE = getattr(
    settings, "LOCI_FLOORPLAN_STORAGE", "django_loci.storage.OverwriteStorage"
)

try:
    FLOORPLAN_STORAGE = import_string(FLOORPLAN_STORAGE)
except ImportError:  # pragma: nocover
    raise ImproperlyConfigured("Import of {0} failed".format(FLOORPLAN_STORAGE))


================================================
FILE: django_loci/static/django-loci/css/floorplan-widget.css
================================================
.floorplan-raw {
  display: none;
}
.floorplan-widget {
  width: 100%;
  height: 500px;
  padding: 0;
  margin: 0;
  background: #fff;
}
.floorplan-image img {
  width: 50%;
}
p.change-image {
  margin-left: 170px;
}
/* Set .readonly to flex to ensure the floorplan-widget inherits the full width of 
its parent flex container */
.field-indoor .readonly {
  display: flex;
  width: 100%;
}


================================================
FILE: django_loci/static/django-loci/css/loci.css
================================================
.coords,
.coords .form-row {
  display: none;
}
.coords .field-location_selection,
.coords .field-floorplan_selection {
  display: block;
}
/* hide redundant heading in objectlocationinline */
.loci.coords > h2,
.loci.coords > h4 {
  display: none;
}
/* improve raw_id_field look */
.coords .field-location input[type="text"] {
  display: none;
}
.field-location .related-lookup {
  width: auto;
  padding-left: 20px;
  background-position: left center;
}
.field-location .item-label {
  display: inline-block;
  height: 16px;
  font-weight: bold;
  margin-left: 6px;
}
#floorplan_form .field-location .item-label {
  vertical-align: middle;
}
.coords .field-location .item-label,
.field-location strong {
  position: relative;
  bottom: -3px;
}
.field-location strong {
  margin-left: 3px;
}
input[type="text"] {
  width: 320px;
}
/* simulate required fields */
.coords.loci .flex-container > label {
  font-weight: bold !important;
  color: #333;
}
.no-location {
  padding: 10px 0 10px 0;
  font-weight: bold;
  text-align: center;
  font-size: 14px;
}
.form-row.field-geometry .flex-container {
  display: block;
}
/* Ensures Layer Control labels in Leaflet are not stretched horizontally */
.form-row .leaflet-control label {
  min-width: 0;
  width: auto;
  display: inline-block;
}
.form-row .leaflet-control label:last-child {
  padding-right: 0;
}
.form-row .leaflet-control input[type="radio"] {
  margin-top: -6px;
}
.form-row .leaflet-control-layers-expanded {
  padding: 6px 12px;
}
#floorplan_set-group {
  display: none;
}
/* hide image file input for readonly view */
.field-image .readonly input {
  display: none;
}
/* hide geometry edit tools for readonly view */
.field-geometry .readonly .leaflet-draw.geometry {
  display: none;
}
/* avoid underlining leaflet controls */
.leaflet-bar a {
  text-decoration: none !important;
}
@media (max-width: 767px) {
  .field-geometry > div {
    flex-direction: column;
  }
}


================================================
FILE: django_loci/static/django-loci/js/floorplan-inlines.js
================================================
django.jQuery(function ($) {
  "use strict";
  var $type_field = $("#id_type"),
    $floorplan_set = $("#floorplan_set-group"),
    $floorplans_length = $floorplan_set.find(".inline-related.has_original").length,
    type_change_event = function (e) {
      var value = $type_field.val();
      // if value is undefined, check for readonly field
      if (typeof value === "undefined") {
        value = $(".field-type .readonly").text();
        if (value && value.startsWith("Indoor")) {
          $floorplan_set.show();
        } else {
          $floorplan_set.hide();
        }
      }
      if (value === "indoor") {
        $floorplan_set.show();
      } else if (value === "outdoor" && $floorplans_length === 0) {
        $floorplan_set.hide();
      } else if (value === "outdoor" && $floorplans_length > 0) {
        // Confirm deletion on switching indoor to outdoor, if floorplans exist
        var msg = gettext(
          "This location has floorplans associated to it. " +
            "Converting it to outdoor will remove all these floorplans, " +
            "affecting all devices related to this location. " +
            "Do you want to proceed?",
        );
        if (!confirm(msg)) {
          $type_field.val("indoor");
        } else {
          $floorplan_set.hide();
        }
      }
    };
  $type_field.change(type_change_event);
  type_change_event();
});


================================================
FILE: django_loci/static/django-loci/js/floorplan-widget.js
================================================
(function () {
  "use strict";
  django.loadFloorPlan = function (widgetName, imageUrl, imageW, imageH) {
    var $input = django.jQuery("#id_" + widgetName),
      $parent = $input.parents("fieldset").eq(0),
      url = imageUrl || $parent.find("a.floorplan-image").attr("href"),
      $dim = $parent.find("#id_" + widgetName.replace("indoor", "image") + "-dim"),
      $indoorPosition = $parent.find(".field-indoor"),
      mapId = "id_" + widgetName + "_map",
      w = imageW || $dim.data("width"),
      h = imageH || $dim.data("height"),
      coordinates,
      map;

    if (!url) {
      return;
    }
    $indoorPosition.show();

    map = L.map(mapId, {
      crs: L.CRS.Simple,
      minZoom: -1,
      maxZoom: 2,
    });

    // calculate the edges of the image, in coordinate space
    var bottomRight = map.unproject([0, h * 2], map.getMaxZoom() - 1),
      upperLeft = map.unproject([w * 2, 0], map.getMaxZoom() - 1),
      bounds = new L.LatLngBounds(bottomRight, upperLeft);
    L.imageOverlay(url, bounds).addTo(map);
    map.fitBounds(bounds);
    map.setMaxBounds(bounds);
    map.setView([0, 0], 0);

    function updateInput(e) {
      var latlng = e.latlng || e.target._latlng;
      $input.val(latlng.lat + "," + latlng.lng);
    }

    if ($input.val()) {
      var latlng = $input.val().split(",");
      coordinates = { lat: latlng[0], lng: latlng[1] };
    } else {
      coordinates = undefined;
    }
    var draggable = true;
    // if readonly field, don't allow dragging
    if ($indoorPosition.find(".readonly").length) {
      draggable = false;
    }

    var marker = new L.marker(coordinates, { draggable: draggable });
    marker.bindPopup(gettext("Drag to reposition"));
    marker.on("dragend", updateInput);
    if (coordinates) {
      marker.addTo(map);
    }

    map.on("click", function (e) {
      if (marker.getLatLng() === undefined) {
        marker.setLatLng(e.latlng);
        marker.addTo(map);
        updateInput(e);
      }
    });

    // clear indoor coordinates if map is removed
    map.on("unload", function () {
      $input.val("");
    });

    return map;
  };
})();


================================================
FILE: django_loci/static/django-loci/js/loci.js
================================================
/*global
alert, confirm, console, Debug, opera, prompt, WSH
*/
/*
this JS is shared between:
    - DeviceLocationForm
    - LocationForm
*/
django.jQuery(function ($) {
  "use strict";

  var $outdoor = $(".loci.coords"),
    $indoor = $(".indoor.coords"),
    $allSections = $(".coords"),
    $geoEdit = $(
      ".field-name, .field-type, .field-is_mobile, " +
        ".field-address, .field-geometry",
      ".loci.coords",
    ),
    $indoorRows = $(".indoor.coords .form-row:not(.field-indoor)"),
    $indoorEdit = $(".indoor.coords .form-row:not(.field-floorplan_selection)"),
    $indoorPositionRow = $(".indoor.coords .field-indoor"),
    geometryId = $(".field-geometry label").attr("for") || "geometry", // fallback for readonly
    mapName = "leafletmap" + geometryId + "-map",
    loadMapName = "loadmap" + geometryId + "-map",
    $typeRow = $(".inline-group .field-type"),
    $type = $typeRow.find("select"),
    $isMobile = $(
      ".coords .field-is_mobile input, #location_form .field-is_mobile input",
    ),
    $locationSelectionRow = $(".field-location_selection"),
    $locationSelection = $locationSelectionRow.find("select"),
    $locationRow = $(".loci.coords .field-location"),
    $location = $locationRow.find("select, input"),
    $locationLabel = $(".field-location .item-label"),
    $name = $(".field-name input", ".loci.coords"),
    $address = $(".coords .field-address input, #location_form .field-address input"),
    $geometryTextarea = $(".field-geometry textarea"),
    baseLocationJsonUrl = $("#loci-location-json-url").attr("data-url"),
    baseLocationFloorplansJsonUrl = $("#loci-location-floorplans-json-url").attr(
      "data-url",
    ),
    $geometryRow = $geometryTextarea.parents(".form-row"),
    msg = gettext("Location data not received yet"),
    $noLocationDiv = $(".no-location", ".loci.coords"),
    $floorplanSelectionRow = $(".indoor.coords .field-floorplan_selection"),
    $floorplanSelection = $floorplanSelectionRow.find("select"),
    $floorplanRow = $(".indoor .field-floorplan"),
    $floorplan = $floorplanRow.find("select").eq(0),
    $floorplanImage = $(".indoor.coords .field-image input"),
    $floorplanMap = $(".indoor.coords .floorplan-widget"),
    isNew = true,
    $addressInput = $(".field-address input"),
    $mapGeojsonTextarea = $(".django-leaflet-raw-textarea"),
    $oldLat,
    $oldLng,
    $coordsUrl = $("#loci-geocode-url").attr("data-url"),
    $addrUrl = $("#loci-reverse-geocode-url").attr("data-url");

  // define dummy gettext if django i18n is not enabled
  if (!gettext) {
    window.gettext = function (text) {
      return text;
    };
  }

  function getLocationJsonUrl(pk) {
    return baseLocationJsonUrl.replace("00000000-0000-0000-0000-000000000000", pk);
  }

  function getLocationFloorplansJsonUrl(pk) {
    return baseLocationFloorplansJsonUrl.replace(
      "00000000-0000-0000-0000-000000000000",
      pk,
    );
  }

  function getMap() {
    return window[mapName];
  }

  function invalidateMapSize() {
    var map = getMap();
    if (map) {
      map.invalidateSize();
    }
    return map;
  }

  function resetOutdoorForm(keepLocationSelection) {
    $locationSelectionRow.show();
    if (!keepLocationSelection) {
      $type.val("");
    }
    $location.val("");
    $locationLabel.text("");
    $isMobile.prop("checked", false);
    $name.val("");
    $address.val("");
    $geometryTextarea.val("");
    $geoEdit.hide();
    $locationRow.hide();
    $locationSelection.show();
    $noLocationDiv.hide();
  }

  function resetIndoorForm(keepFloorplanSelection) {
    if (!keepFloorplanSelection) {
      $indoor.hide();
      $floorplanSelection.val("");
    }
    $indoorRows.hide();
    $floorplanSelectionRow.show();
    // reset values
    $indoorEdit.find("input,select").val("");
  }

  function resetDeviceLocationForm() {
    resetOutdoorForm();
    resetIndoorForm();
  }

  function indoorForm(selection) {
    // fallbacks for view only users
    var type = $type.val() || $typeRow.find(".readonly").text(),
      floorplanValue =
        $floorplanSelection.val() || $floorplanSelectionRow.find(".readonly").text(),
      locationSelectionValue =
        $locationSelection.val() || $locationSelectionRow.find(".readonly").text();
    if (type !== "indoor") {
      return;
    }
    $indoorPositionRow.hide();
    $indoor.show();
    if (!selection) {
      $indoorRows.hide();
      $floorplanSelectionRow.show();
    } else if (selection === "new") {
      $indoorRows.show();
      $floorplan.val("");
      $floorplanRow.hide();
    }
    if (locationSelectionValue === "new") {
      $floorplanSelection.val("new");
      $floorplanSelectionRow.hide();
    }
    if (!floorplanValue) {
      $indoorRows.hide();
      $floorplanSelectionRow.show();
    }
  }

  function locationSelectionChange(e, initial) {
    // get value from 'readonly' in case of view only permissions
    var value =
      $locationSelection.val() || $locationSelectionRow.find(".readonly").text();
    $allSections.hide();
    if (!initial) {
      resetDeviceLocationForm();
    }
    if (value === "new") {
      $outdoor.show();
      $typeRow.show();
      indoorForm(value);
    } else if (value === "existing") {
      $outdoor.show();
      $locationRow.show();
    }
  }

  function isMobileChange() {
    var rows = [$address, $geometryTextarea];
    if ($isMobile.prop("checked")) {
      $(rows).each(function (i, el) {
        if (!$(el).val()) {
          $(el).parents(".form-row").hide();
        }
      });
      // name not relevant in mobile locations
      $name.parents(".form-row").hide();
      if (!$geometryTextarea.val()) {
        $(".no-location").show();
      }
    } else {
      $(rows).each(function (i, el) {
        $(el).parents(".form-row").show();
      });
      $name.parents(".form-row").show();
      $(".no-location").hide();
    }
  }

  function typeChange(e, initial) {
    // get value from 'readonly' in case of view only permissions
    var value = $type.val() || $typeRow.find(".readonly").text(),
      // floorplansLength for choice field includes the placeholder option so
      // need to subtract it.
      floorplansLength = $floorplan.find("option").length - 1;
    if (value) {
      $outdoor.show();
      $geoEdit.show();
      invalidateMapSize();
      isMobileChange();
    } else {
      $geoEdit.hide();
      $indoor.hide();
      $typeRow.show();
    }
    if (value === "indoor") {
      $indoor.show();
      indoorForm($locationSelection.val());
    } else if (value === "outdoor" && floorplansLength >= 1) {
      // Confirm deletion on switching indoor to outdoor, if floorplans exist
      var msg = gettext(
        "This location has floorplans associated to it. " +
          "Converting it to outdoor will remove all these floorplans, " +
          "affecting all devices related to this location. " +
          "Do you want to proceed?",
      );
      if (!confirm(msg)) {
        $type.val("indoor");
        return;
      }
      $indoor.hide();
    } else {
      $indoor.hide();
    }
  }

  function floorplanSelectionChange(e, initial) {
    // fallbacks for view only users
    var value =
        $floorplanSelection.val() || $floorplanSelectionRow.find(".readonly").text(),
      optionsLength =
        $floorplan.find("option").length ||
        $floorplanSelectionRow.find(".readonly").length;
    // do not reset indoor form at first load
    if (!initial) {
      resetIndoorForm(true);
    }
    indoorForm(value);
    // optionslength includes the placeholder option
    if (value === "existing" && optionsLength >= 1) {
      $floorplanRow.show();
      // if no floorplan available, make it obvious
    } else if (value === "existing" && optionsLength < 1) {
      alert(gettext("This location has no floorplans available yet"));
      $floorplanSelection.val("");
    }
  }

  // HACK to override `dismissRelatedLookupPopup()` and
  // `dismissAddAnotherPopup()` in Django's RelatedObjectLookups.js to
  // trigger change event when an ID is selected or added via popup.
  function triggerChangeOnField(win, chosenId) {
    // In Django 4.2, the popup index is appended to the window name.
    // Hence, we remove that before selecting the element.
    $(document.getElementById(win.name.replace(/__\d+$/, ""))).change();
  }
  window.ORIGINAL_dismissRelatedLookupPopup = window.dismissRelatedLookupPopup;
  window.dismissRelatedLookupPopup = function (win, chosenId) {
    window.ORIGINAL_dismissRelatedLookupPopup(win, chosenId);
    triggerChangeOnField(win, chosenId);
  };
  window.ORIGINAL_dismissAddAnotherPopup = window.dismissAddAnotherPopup;
  window.dismissAddAnotherPopup = function (win, chosenId) {
    window.ORIGINAL_dismissAddAnotherPopup(win, chosenId);
    triggerChangeOnField(win, chosenId);
  };

  $type.change(typeChange);
  typeChange(null, true);

  $locationSelection.change(locationSelectionChange);
  locationSelectionChange(null, true);

  function locationChange(e, initial) {
    function loadIndoor() {
      indoorForm();
      // fallback for view only users
      var type = $type.val() || $typeRow.find(".readonly").text();
      if (type !== "indoor") {
        $indoor.hide();
        return;
      }
      var floorplansUrl = getLocationFloorplansJsonUrl($location.val());
      $.getJSON(floorplansUrl, function (data) {
        var $current = $floorplan.find("option:selected"),
          currentValue = $current.val();
        $floorplan.find('option[value!=""]').remove();
        $(data.choices).each(function (i, el) {
          var o = $("<option></option>")
            .attr("value", el.id)
            .text(el.str)
            .data("floor", el.floor)
            .data("image", el.image)
            .data("image_width", el.image_width)
            .data("image_height", el.image_height);
          if (el.id === currentValue) {
            o.attr("selected", "selected");
          }
          $floorplan.append(o);
        });
      });
    }
    $typeRow.show();
    if (!initial) {
      // update location fields
      var url = getLocationJsonUrl($location.val());
      $.getJSON(url, function (data) {
        $locationLabel.text(data.name);
        $name.val(data.name);
        $type.val(data.type);
        $isMobile.prop("checked", data.is_mobile);
        $address.val(data.address);
        $geometryTextarea.val(data.geometry ? JSON.stringify(data.geometry) : "");
        var map = getMap();
        if (map) {
          map.remove();
        }
        $geoEdit.show();
        window[loadMapName]();
        isMobileChange();
        loadIndoor();
      });
    } else {
      loadIndoor();
    }
  }

  // listen to change events
  // although these events are being artificially triggered
  // see the override of dismissRelatedLookupPopup above
  $location.change(locationChange);
  // initial set up
  locationChange(null, true);

  $isMobile.change(isMobileChange);

  $floorplanSelection.change(floorplanSelectionChange);
  floorplanSelectionChange(null, true);

  $floorplan.change(function () {
    // reset floorplan data if no floorplan is chosen
    if (!$floorplan.val()) {
      resetIndoorForm(true);
      $indoorRows.show();
      $indoorEdit.hide();
      $floorplanRow.show();
      return;
    }
    var option = $floorplan.find("option:selected"),
      widgetName = $floorplanMap
        .parents(".field-indoor")
        .find(".floorplan-widget")
        .attr("id")
        .replace("id_", "")
        .replace("_map", ""),
      globalName = "django-loci-floorplan-" + widgetName,
      image = option.data("image"),
      $a = $indoor.find(".field-image a"),
      $aText = $a.text(),
      $aNewText = $aText.split(": ")[0] + ": " + image.split("/").slice(-1);
    $indoor.find(".field-floor input").val(option.data("floor"));
    $indoor.find(".form-row:not(.field-floorplan_selection)").show();
    $a.attr("href", image).text($aNewText);
    // remove previous indoor map if present
    if (window[globalName]) {
      window[globalName].remove();
    }
    window[globalName] = django.loadFloorPlan(
      widgetName,
      image,
      option.data("image_width"),
      option.data("image_height"),
    );
  });

  $floorplanImage.change(function () {
    var input = this,
      reader = new FileReader(),
      image = new Image(),
      $indoorRow = $floorplanMap.parents(".field-indoor"),
      widgetName = $indoorRow
        .find(".floorplan-widget")
        .attr("id")
        .replace("id_", "")
        .replace("_map", ""),
      globalName = "django-loci-floorplan-" + widgetName;
    if (!input.files || !input.files[0]) {
      return;
    }
    reader.onload = function (e) {
      image.src = e.target.result;
      image.onload = function () {
        $indoorRow.show();
        // remove previous indoor map if present
        if (window[globalName]) {
          window[globalName].remove();
        }
        window[globalName] = django.loadFloorPlan(
          widgetName,
          this.src,
          this.width,
          this.height,
        );
      };
    };
    reader.readAsDataURL(input.files[0]);
  });

  $("#content-main form").submit(function (e) {
    var indoorPosition = $(".field-indoor .floorplan-raw input").val(),
      typeSelect = $type.find("option").length
        ? $type
        : $(".module.aligned .field-type").find("select");
    if (isNew && $type.val() === "indoor" && !indoorPosition) {
      var message = gettext(
        "You have set this location as indoor but have " +
          "not specified indoor cordinates on a floorplan, " +
          "do you want to save anyway?",
      );
      if (!confirm(message)) {
        e.preventDefault();
      } else {
        $floorplanSelection.val("");
        indoorForm();
      }
    }
  });

  // websocket for mobile coords
  function listenForLocationUpdates(pk) {
    var host = window.location.host,
      protocol = window.location.protocol === "http:" ? "ws" : "wss",
      ws = new ReconnectingWebSocket(
        protocol + "://" + host + "/ws/loci/location/" + pk + "/",
      );
    ws.onmessage = function (e) {
      const data = JSON.parse(e.data);
      $geometryRow.show();
      $noLocationDiv.hide();
      $geometryTextarea.val(JSON.stringify(data.geometry));
      $address.val(data.address);
      getMap().remove();
      window[loadMapName]();
    };
  }

  // returns marker or featureGroup
  function getMarkerFeatureGroup(option) {
    var map = getMap(),
      layer;
    map.eachLayer(function (lay) {
      if (lay.hasOwnProperty(option)) {
        layer = lay;
      }
    });
    return layer;
  }
  // returns placed marker
  function getMarker() {
    return getMarkerFeatureGroup("_latlng");
  }

  // returns map's feature group
  function getFeatureGroup() {
    return getMarkerFeatureGroup("_layers");
  }

  // update lat and lng
  function updateLatLng(latlng) {
    $oldLat = latlng.lat.toString();
    $oldLng = latlng.lng.toString();
  }

  // update map view
  function updateMapView(data) {
    var geojson =
      '{ "type": "Point", "coordinates": [ ' + data.lng + ", " + data.lat + "] }";
    $mapGeojsonTextarea.val(geojson);
    getMap().setView([data.lat, data.lng], 15);
  }

  // update map
  function updateMap() {
    var addressValue = $addressInput.val(),
      message;
    if (!addressValue) {
      getFeatureGroup().clearLayers();
      return;
    }
    $.get($coordsUrl, { address: addressValue })
      .done(function (data) {
        var marker = getMarker(),
          featureGroup = getFeatureGroup();
        if (marker === undefined) {
          updateLatLng(data);
          featureGroup.addLayer(L.marker([data.lat, data.lng]));
        } else {
          var latlng = marker.getLatLng();
          if (latlng.lat !== data.lat || latlng.lng !== data.lng) {
            message = gettext(
              "The address was changed, would you like to " +
                "automatically update the location on the map?",
            );
            if (confirm(message)) {
              updateLatLng(data);
              featureGroup.removeLayer(marker);
              featureGroup.addLayer(L.marker([data.lat, data.lng]));
            }
          }
        }
      })
      .fail(function () {
        message = gettext(
          "Location with address: " + $addressInput.val() + "was not found.",
        );
        alert(message);
      });
  }

  function updateAdress() {
    var marker = getMarker(),
      message,
      latlng;
    if (marker === undefined) {
      return;
    }
    latlng = marker.getLatLng();
    if (latlng.lat.toString() === $oldLat && latlng.lng.toString() === $oldLng) {
      return;
    }
    updateLatLng(latlng);
    $.get($addrUrl, { lat: latlng.lat, lng: latlng.lng })
      .done(function (data) {
        if (!$addressInput.val()) {
          $addressInput.val(data.address);
        } else {
          message = gettext(
            "The location on the map was changed, would you " +
              "like to update the address to",
          );
          message += ' "' + data.address + '"?';
          if (confirm(message)) {
            $addressInput.val(data.address);
          }
        }
      })
      .fail(function () {
        message = gettext("Could not find any address related to this location.");
        alert(message);
      });
  }

  // triggers update of the address when the location on the map is changed
  function updateAddressOnMapChange() {
    var marker = getMarker();
    if (!marker) {
      return;
    }
    getMap().on("draw:edited", function (e) {
      updateAdress();
      updateMapView(marker.getLatLng());
    });
  }

  $addressInput.change(function () {
    updateMap();
  });

  function geometryListeners() {
    if (!getMap()) {
      return;
    }
    var featureGroup = getFeatureGroup(),
      marker = getMarker();
    featureGroup.on("layeradd", function () {
      updateAdress();
      updateAddressOnMapChange();
      marker = getMarker();
      if (!marker) {
        return;
      }
      updateMapView(marker.getLatLng());
    });
    if (marker !== undefined) {
      updateLatLng(marker.getLatLng());
      updateAddressOnMapChange();
    }
  }
  // some browsers fires load event before attaching listener
  // so we need to check if the document is ready
  if (document.readyState === "complete") {
    geometryListeners();
  } else {
    $(window).on("load", function () {
      geometryListeners();
    });
  }

  // show existing location
  var pk;
  if ($location.val()) {
    $locationSelectionRow.hide();
    $geoEdit.show();
    isNew = false;
    pk = $location.val();
  } else {
    pk = window.location.pathname.split("/").slice("-3", "-2")[0];
  }
  // fallback for view only users
  var typeLength = $type.length || $typeRow.find(".readonly").length;
  // show mobile map (hide not relevant fields)
  if ($isMobile.prop("checked")) {
    listenForLocationUpdates(pk);
    $outdoor.show();
    $locationSelection.parents(".form-row").hide();
    $locationRow.hide();
    $name.parents(".form-row").hide();
    if (!$address.val()) {
      $address.parents(".form-row").hide();
    }
    // if no location data yet
    if (!$geometryTextarea.val()) {
      $geometryRow.hide();
      $geometryRow.parent().append('<div class="no-location">' + msg + "</div>");
      $noLocationDiv = $(".no-location", ".loci.coords");
    }
    // this is triggered in the location form page
  } else if (!typeLength) {
    if (pk !== "location") {
      listenForLocationUpdates(pk);
    }
  }
  // show existing indoor
  // fallbacks for view only users
  if ($floorplan.val() || $floorplanSelectionRow.find(".readonly").text()) {
    $indoor.show();
    if ($floorplanSelection.val() || $floorplanSelectionRow.find(".readonly").text()) {
      $indoorRows.show();
      $floorplanSelectionRow.hide();
    }
    // adding indoor
  } else if (($type.val() || $typeRow.find(".readonly").text()) === "indoor") {
    $indoor.show();
    $indoorRows.show();
    indoorForm(
      $locationSelection.val() || $locationSelectionRow.find(".readonly").text(),
    );
  }
});


================================================
FILE: django_loci/storage.py
================================================
from django.core.files.storage import FileSystemStorage


class OverwriteMixin:
    floorplan_upload_dir = "floorplans"

    @classmethod
    def upload_to(cls, instance, filename):
        """
        passed to FloorPlan.image.upload_to
        """
        ext = filename.split(".")[-1]
        dir_ = cls.floorplan_upload_dir
        return "{0}/{1}.{2}".format(dir_, instance.id, ext)

    def get_available_name(self, name, max_length=None):
        """
        removes file if it already exists
        """
        if self.exists(name):
            self.delete(name)
        return name


class OverwriteStorage(OverwriteMixin, FileSystemStorage):
    """
    Adds the overwrite functionality to the file storage class
    currently in-use by the Django project.
    """

    pass


================================================
FILE: django_loci/templates/admin/django_loci/foreign_key_raw_id.html
================================================
{% include 'django/forms/widgets/input.html' %}{% load i18n %}
{% if related_url %}
    <a href="{{ related_url }}"
       class="related-lookup"
       id="lookup_id_{{ widget.name }}"
       title="{{ link_title }}">{% trans 'Select item' %}</a>
{% endif %}
<span class="item-label">
{% if link_label %}
    {% if link_url %}<a href="{{ link_url }}">{% endif %}
    {{ link_label }}
    {% if link_url %}</a>{% endif %}
{% endif %}
</span>


================================================
FILE: django_loci/templates/admin/django_loci/location_change_form.html
================================================
{% extends "admin/change_form.html" %}
{% block content %}
{{ block.super }}
{% comment %}
    We use django to generate URLs that are then
    read by javascript. This allows the JS features
    to work also when django-loci is used as a based
    app to create a new app with a different app_label
{% endcomment %}
<span id="loci-geocode-url"
      data-url="{% url "admin:django_loci_location_geocode_api" %}"></span>
<span id="loci-reverse-geocode-url"
      data-url="{% url "admin:django_loci_location_reverse_geocode_api" %}"></span>
{% endblock %}


================================================
FILE: django_loci/templates/admin/django_loci/location_inline.html
================================================
{% include "admin/edit_inline/stacked.html" %}
{% comment %}
    We use django to generate URLs that are then
    read by javascript. This allows the JS features
    to work also when django-loci is used as a based
    app to create a new app with a different app_label
{% endcomment %}
<span id="loci-location-json-url"
      data-url="{% url "admin:django_loci_location_json" "00000000-0000-0000-0000-000000000000" %}"></span>
<span id="loci-location-floorplans-json-url"
      data-url="{% url "admin:django_loci_location_floorplans_json" "00000000-0000-0000-0000-000000000000" %}"></span>
<span id="loci-geocode-url"
      data-url="{% url "admin:django_loci_location_geocode_api" %}"></span>
<span id="loci-reverse-geocode-url"
      data-url="{% url "admin:django_loci_location_reverse_geocode_api" %}"></span>


================================================
FILE: django_loci/templates/admin/widgets/floorplan.html
================================================
<div id="id_{{ widget.name }}_map" class="floorplan-widget"></div>
<div id="id_{{ widget.name }}_raw" class="floorplan-raw">
    {% include "django/forms/widgets/input.html" %}
</div>
{% if '__prefix__' not in widget.name %}
<script>
django.jQuery(function() {
    window['django-loci-floorplan-{{ widget.name }}'] = django.loadFloorPlan('{{ widget.name }}');
});
</script>
{% endif %}


================================================
FILE: django_loci/templates/admin/widgets/foreign_key_raw_id.html
================================================
{% include 'admin/django_loci/foreign_key_raw_id.html' %}


================================================
FILE: django_loci/templates/admin/widgets/image.html
================================================
{% load i18n %}
{% if url %}
<a target="_blank"
   href="{{ url }}"
   class="floorplan-image">
    {% if thumbnail %}
    <img src="{{ url }}" />
    {% else %}
    {% trans 'Currently' %}: {{ filename }}
    {% endif %}
</a>
<p class="change-image">
{% endif %}
{% include "django/forms/widgets/input.html" %}
{% if width and height %}
<span id="id_{{ widget.name }}-dim"
      data-width="{{ width }}"
      data-height="{{ height }}">
{% endif %}
{% if url %}</p>{% endif %}


================================================
FILE: django_loci/tests/__init__.py
================================================
"""
Reusable test helpers
"""

import importlib
import os

from channels.db import database_sync_to_async
from channels.testing import WebsocketCommunicator
from django.conf import settings
from django.contrib.auth import login
from django.core.files.uploadedfile import SimpleUploadedFile
from django.http.request import HttpRequest


class TestLociMixin(object):
    _object_kwargs = dict(name="test-object")
    _floorplan_path = os.path.join(settings.MEDIA_ROOT, "floorplan.jpg")

    def tearDown(self):
        if not hasattr(self, "floorplan_model"):
            return
        for fl in self.floorplan_model.objects.all():
            fl.objectlocation_set.all().delete()
            fl.delete()

    def _create_object(self, **kwargs):
        self._object_kwargs.update(kwargs)
        return self.object_model.objects.create(**self._object_kwargs)

    def _create_location(self, **kwargs):
        options = dict(
            name="test-location",
            address="Via del Corso, Roma, Italia",
            geometry="SRID=4326;POINT (12.512124 41.898903)",
            type="outdoor",
        )
        options.update(kwargs)
        location = self.location_model(**options)
        location.full_clean()
        location.save()
        return location

    def _get_simpleuploadedfile(self):
        with open(self._floorplan_path, "rb") as f:
            image = f.read()
        return SimpleUploadedFile(
            name="floorplan.jpg", content=image, content_type="image/jpeg"
        )

    def _create_floorplan(self, **kwargs):
        options = dict(floor=1)
        options.update(kwargs)
        if "image" not in options:
            options["image"] = self._get_simpleuploadedfile()
        if "location" not in options:
            options["location"] = self._create_location(type="indoor")
        fl = self.floorplan_model(**options)
        fl.full_clean()
        fl.save()
        return fl

    def _create_object_location(self, **kwargs):
        options = {}
        options.update(**kwargs)
        if "content_object" not in options:
            options["content_object"] = self._create_object()
        if "location" not in options:
            options["location"] = self._create_location()
        elif options["location"].type == "indoor":
            options["indoor"] = "-140.38620,40.369227"
        ol = self.object_location_model(**options)
        ol.full_clean()
        ol.save()
        return ol


class TestAdminMixin(object):
    @property
    def url_prefix(self):
        return "admin:{0}".format(self.location_model._meta.app_label)

    @property
    def object_url_prefix(self):
        return "admin:{0}".format(self.object_model._meta.app_label)

    def _create_admin(self, **kwargs):
        opts = dict(
            username="admin",
            password="admin",
            email="admin@email.org",
            is_superuser=True,
            is_staff=True,
        )
        opts.update(kwargs)
        return self.user_model.objects.create_user(**opts)

    def _login_as_admin(self):
        admin = self._create_admin()
        self.client.force_login(admin)
        return admin

    def _create_readonly_admin(self, **kwargs):
        """Creates a read-only admin user with view permissions for the specified models."""
        models = kwargs.pop("models", [])
        user = self._create_admin(is_superuser=False, **kwargs)
        if models:
            permission_codenames = []
            for model in models:
                permission_codenames.append(f"view_{model.__name__.lower()}")
            # assign view permissions to user
            view_permission = self.permission_model.objects.filter(
                codename__in=permission_codenames
            )
            user.user_permissions.add(*view_permission)
        return user

    def _load_content(self, file):
        d = os.path.dirname(os.path.abspath(__file__))
        return open(os.path.join(d, file)).read()


# Mixin for testing admin inline views
class TestAdminInlineMixin(TestAdminMixin):
    @classmethod
    def _get_prefix(cls):
        s = "{0}-{1}-content_type-object_id"
        return s.format(
            cls.location_model._meta.app_label,
            cls.object_location_model.__name__.lower(),
        )

    def _get_url_prefix(self):
        return "{0}_{1}".format(
            self.object_url_prefix, self.object_model.__name__.lower()
        )

    @property
    def add_url(self):
        return "{0}_add".format(self._get_url_prefix())

    @property
    def change_url(self):
        return "{0}_change".format(self._get_url_prefix())


class TestChannelsMixin(object):

    async def _force_login(self, user, backend=None):
        engine = importlib.import_module(settings.SESSION_ENGINE)
        request = HttpRequest()
        request.session = engine.SessionStore()
        await database_sync_to_async(login)(request, user, backend)
        await database_sync_to_async(request.session.save)()
        return request.session

    async def _get_location_request_dict(self, path, pk=None, user=None):
        if not pk:
            location = await database_sync_to_async(self._create_location)(
                is_mobile=True
            )
            await database_sync_to_async(self._create_object_location)(
                location=location
            )
            pk = location.pk
        session = None
        if user:
            session = await self._force_login(user)
        return {"pk": pk, "path": path, "session": session}

    async def _get_specific_location_request_dict(self, pk=None, user=None):
        result = await self._get_location_request_dict(
            path="/ws/loci/location/{0}/", pk=pk, user=user
        )
        result["path"] = result["path"].format(result["pk"])
        return result

    async def _get_common_location_request_dict(self, pk=None, user=None):
        return await self._get_location_request_dict(
            path="/ws/loci/location/", pk=pk, user=user
        )

    def _get_location_communicator(
        self, consumer, request_vars, user=None, include_pk=False
    ):
        communicator = WebsocketCommunicator(consumer.as_asgi(), request_vars["path"])
        if user:
            scope = {
                "user": user,
                "session": request_vars["session"],
            }
            if include_pk:
                scope["url_route"] = {"kwargs": {"pk": request_vars["pk"]}}
            communicator.scope.update(scope)
        return communicator

    def _get_specific_location_communicator(self, request_vars, user=None):
        return self._get_location_communicator(
            consumer=self.location_consumer,
            request_vars=request_vars,
            user=user,
            include_pk=True,
        )

    def _get_common_location_communicator(self, request_vars, user=None):
        return self._get_location_communicator(
            consumer=self.common_location_consumer,
            request_vars=request_vars,
            user=user,
            include_pk=False,
        )

    async def _save_location(self, pk):
        loc = await self.location_model.objects.aget(pk=pk)
        loc.geometry = "POINT (12.513124 41.897903)"
        await loc.asave()


================================================
FILE: django_loci/tests/base/__init__.py
================================================
# pytest_*.py files in this folder can run via pytest.


================================================
FILE: django_loci/tests/base/static/test-geocode-invalid-address.json
================================================
{
  "spatialReference": {
    "wkid": 4326,
    "latestWkid": 4326
  },
  "candidates": []
}


================================================
FILE: django_loci/tests/base/static/test-geocode.json
================================================
{
  "spatialReference": {
    "wkid": 4326,
    "latestWkid": 4326
  },
  "candidates": [
    {
      "address": "Red Square",
      "location": {
        "x": 37.620020000000068,
        "y": 55.754120000000057
      },
      "score": 100,
      "attributes": {},
      "extent": {
        "xmin": 37.615020000000065,
        "ymin": 55.749120000000055,
        "xmax": 37.62502000000007,
        "ymax": 55.75912000000006
      }
    }
  ]
}


================================================
FILE: django_loci/tests/base/static/test-reverse-geocode.json
================================================
{
  "address": {
    "Match_addr": "05-500",
    "LongLabel": "05-500, POL",
    "ShortLabel": "05-500",
    "Addr_type": "Postal",
    "Type": "",
    "PlaceName": "05-500",
    "AddNum": "",
    "Address": "",
    "Block": "",
    "Sector": "",
    "Neighborhood": "",
    "District": "",
    "City": "Piaseczno",
    "MetroArea": "",
    "Subregion": "Powiat Piaseczyński",
    "Region": "Woj. Mazowieckie",
    "Territory": "",
    "Postal": "05-500",
    "PostalExt": "",
    "CountryCode": "POL"
  },
  "location": {
    "x": 21,
    "y": 52,
    "spatialReference": {
      "wkid": 4326,
      "latestWkid": 4326
    }
  }
}


================================================
FILE: django_loci/tests/base/static/test-reverse-location-with-no-address.json
================================================
{
  "error": {
    "code": 400,
    "message": "Cannot perform query. Invalid query parameters.",
    "details": ["Unable to find address for the specified location."]
  }
}


================================================
FILE: django_loci/tests/base/test_admin.py
================================================
import json

import responses
from django.contrib.auth.models import Permission
from django.contrib.humanize.templatetags.humanize import ordinal
from django.urls import reverse

from .. import TestAdminMixin, TestLociMixin


class BaseTestAdmin(TestAdminMixin, TestLociMixin):
    app_label = "django_loci"
    geocode_url = "https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/"
    permission_model = Permission

    def test_location_list(self):
        self._login_as_admin()
        self._create_location(name="test-admin-location-1")
        url = reverse("{0}_location_changelist".format(self.url_prefix))
        r = self.client.get(url)
        self.assertContains(r, "test-admin-location-1")

    def test_floorplan_list(self):
        self._login_as_admin()
        self._create_floorplan()
        self._create_location()
        url = reverse("{0}_floorplan_changelist".format(self.url_prefix))
        r = self.client.get(url)
        self.assertContains(r, "1st floor")

    def test_location_json_view(self):
        self._login_as_admin()
        loc = self._create_location()
        r = self.client.get(reverse("admin:django_loci_location_json", args=[loc.pk]))
        expected = {
            "name": loc.name,
            "address": loc.address,
            "type": loc.type,
            "is_mobile": loc.is_mobile,
            "geometry": json.loads(loc.geometry.json),
        }
        self.assertDictEqual(r.json(), expected)

    def test_location_floorplan_json_view(self):
        self._login_as_admin()
        fl = self._create_floorplan()
        r = self.client.get(
            reverse("admin:django_loci_location_floorplans_json", args=[fl.location.pk])
        )
        expected = {
            "choices": [
                {
                    "id": str(fl.pk),
                    "str": str(fl),
                    "floor": fl.floor,
                    "image": fl.image.url,
                    "image_width": fl.image.width,
                    "image_height": fl.image.height,
                }
            ]
        }
        self.assertDictEqual(r.json(), expected)

    def test_location_change_image_removed(self):
        self._login_as_admin()
        loc = self._create_location(name="test-admin-location-1", type="indoor")
        fl = self._create_floorplan(location=loc)
        # remove floorplan image
        fl.image.delete(save=False)
        url = reverse("{0}_location_change".format(self.url_prefix), args=[loc.pk])
        r = self.client.get(url)
        self.assertContains(r, "test-admin-location-1")

    def test_floorplan_change_image_removed(self):
        self._login_as_admin()
        loc = self._create_location(name="test-admin-location-1", type="indoor")
        fl = self._create_floorplan(location=loc)
        # remove floorplan image
        fl.image.delete(save=False)
        url = reverse("{0}_floorplan_change".format(self.url_prefix), args=[fl.pk])
        r = self.client.get(url)
        self.assertContains(r, "test-admin-location-1")

    def test_floorplan_add_view_filters_indoor_location(self):
        self._login_as_admin()
        loc_indoor = self._create_location(
            name="test-admin-indoor-location", type="indoor"
        )
        loc_outdoor = self._create_location(
            name="test-admin-outdoor-location", type="outdoor"
        )
        url = reverse("{0}_floorplan_add".format(self.url_prefix))
        filter_url = (
            f"/admin/{self.app_label}/location/?_to_field=id&type__exact=indoor"
        )
        r1 = self.client.get(url)
        self.assertContains(
            r1,
            f"""
                <a href="{filter_url}"
                class="related-lookup" id="lookup_id_location" title="Lookup">
                    Select item
                </a>
            """,
            html=True,
        )
        # Ensure that when the user clicks on the
        # filter URL only indoor locations are displayed
        r2 = self.client.get(filter_url)
        self.assertContains(r2, f"{loc_indoor.name}</a>")
        self.assertNotContains(r2, f"{loc_outdoor.name}</a>")

    def test_is_mobile_location_json_view(self):
        self._login_as_admin()
        loc = self._create_location(is_mobile=True, geometry=None)
        response = self.client.get(
            reverse("admin:django_loci_location_json", args=[loc.pk])
        )
        self.assertEqual(response.status_code, 200)
        content = json.loads(response.content)
        self.assertEqual(content["geometry"], None)
        loc1 = self._create_location(
            name="location2", address="loc2 add", type="outdoor"
        )
        response1 = self.client.get(
            reverse("admin:django_loci_location_json", args=[loc1.pk])
        )
        self.assertEqual(response1.status_code, 200)
        content1 = json.loads(response1.content)
        expected = {
            "name": "location2",
            "address": "loc2 add",
            "type": "outdoor",
            "is_mobile": False,
            "geometry": {"type": "Point", "coordinates": [12.512124, 41.898903]},
        }
        self.assertEqual(content1, expected)

    @responses.activate
    def test_geocode(self):
        self._login_as_admin()
        address = "Red Square"
        url = "{0}?address={1}".format(
            reverse("admin:django_loci_location_geocode_api"), address
        )
        # Mock HTTP request to the URL to work offline
        responses.add(
            responses.GET,
            f"{self.geocode_url}findAddressCandidates?singleLine=Red+Square&f=json&maxLocations=1",
            body=self._load_content("base/static/test-geocode.json"),
            content_type="application/json",
        )
        response = self.client.get(url)
        response_lat = round(response.json()["lat"])
        response_lng = round(response.json()["lng"])
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response_lat, 56)
        self.assertEqual(response_lng, 38)

    def test_geocode_no_address(self):
        self._login_as_admin()
        url = reverse("admin:django_loci_location_geocode_api")
        response = self.client.get(url)
        expected = {"error": "Address parameter not defined"}
        self.assertEqual(response.status_code, 400)
        self.assertEqual(response.json(), expected)

    @responses.activate
    def test_geocode_invalid_address(self):
        self._login_as_admin()
        invalid_address = "thisaddressisnotvalid123abc"
        url = "{0}?address={1}".format(
            reverse("admin:django_loci_location_geocode_api"), invalid_address
        )
        responses.add(
            responses.GET,
            f"{self.geocode_url}findAddressCandidates?singleLine=thisaddressisnotvalid123abc"
            "&f=json&maxLocations=1",
            body=self._load_content("base/static/test-geocode-invalid-address.json"),
            content_type="application/json",
        )
        response = self.client.get(url)
        expected = {"error": "Not found location with given name"}
        self.assertEqual(response.status_code, 404)
        self.assertEqual(response.json(), expected)

    @responses.activate
    def test_reverse_geocode(self):
        self._login_as_admin()
        lat = 52
        lng = 21
        url = "{0}?lat={1}&lng={2}".format(
            reverse("admin:django_loci_location_reverse_geocode_api"), lat, lng
        )
        # Mock HTTP request to the URL to work offline
        responses.add(
            responses.GET,
            f"{self.geocode_url}reverseGeocode?location=21.0%2C52.0&f=json&outSR=4326",
            body=self._load_content("base/static/test-reverse-geocode.json"),
            content_type="application/json",
        )
        response = self.client.get(url)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "POL")

    @responses.activate
    def test_reverse_location_with_no_address(self):
        self._login_as_admin()
        lat = -30
        lng = -30
        url = "{0}?lat={1}&lng={2}".format(
            reverse("admin:django_loci_location_reverse_geocode_api"), lat, lng
        )
        responses.add(
            responses.GET,
            f"{self.geocode_url}reverseGeocode?location=-30.0%2C-30.0&f=json&outSR=4326",
            body=self._load_content(
                "base/static/test-reverse-location-with-no-address.json"
            ),
            content_type="application/json",
        )
        response = self.client.get(url)
        response_address = response.json()["address"]
        self.assertEqual(response.status_code, 404)
        self.assertEqual(response_address, "")

    def test_reverse_geocode_no_coords(self):
        self._login_as_admin()
        url = reverse("admin:django_loci_location_reverse_geocode_api")
        response = self.client.get(url)
        expected = {"error": "lat or lng parameter not defined"}
        self.assertEqual(response.status_code, 400)
        self.assertEqual(response.json(), expected)

    def _get_location_add_params(self, **kwargs):
        params = {
            "name": "test location",
            "type": "outdoor",
            "is_mobile": "",
            "address": "",
            "geometry": "",
            "floorplan_set-TOTAL_FORMS": "0",
            "floorplan_set-INITIAL_FORMS": "0",
            "floorplan_set-MIN_NUM_FORMS": "0",
            "floorplan_set-MAX_NUM_FORMS": "1000",
            "_save": "Save",
        }
        params.update(kwargs)
        return params

    def test_add_mobile_location(self):
        self._login_as_admin()
        url = reverse("{0}_location_add".format(self.url_prefix))
        params = self._get_location_add_params(is_mobile="on")
        response = self.client.post(url, params, follow=True)
        self.assertEqual(response.status_code, 200)
        self.assertEqual(self.location_model.objects.filter(is_mobile=True).count(), 1)

    def test_add_non_mobile_location_without_geometry(self):
        self._login_as_admin()
        url = reverse("{0}_location_add".format(self.url_prefix))
        params = self._get_location_add_params()
        response = self.client.post(url, params)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "No geometry value provided.")
        self.assertEqual(self.location_model.objects.count(), 0)

    # for users with view only permissions to floorplans
    def test_readonly_floorplans(self):
        user = self._create_readonly_admin(models=[self.floorplan_model])
        self.client.force_login(user)
        loc = self._create_location(name="test-admin-location-1", type="indoor")
        fl = self._create_floorplan(location=loc)
        url = reverse("{0}_floorplan_change".format(self.url_prefix), args=[fl.pk])
        r = self.client.get(url)
        self.assertEqual(r.status_code, 200)
        # assert if image is being rendered or not
        self.assertContains(r, 'img src="{0}"'.format(fl.image.url))
        self.assertContains(r, f"{loc.name} {ordinal(fl.floor)}")
        self.assertContains(r, fl.floor)
        self.assertContains(r, loc.name)


================================================
FILE: django_loci/tests/base/test_admin_inline.py
================================================
from django.contrib.auth.models import Permission
from django.contrib.gis.geos import GEOSGeometry
from django.contrib.humanize.templatetags.humanize import ordinal
from django.db.models.fields.files import ImageFieldFile
from django.urls import reverse

from .. import TestAdminInlineMixin, TestLociMixin


class BaseTestAdminInline(TestAdminInlineMixin, TestLociMixin):
    permission_model = Permission

    @classmethod
    def _get_params(cls):
        _p = cls._get_prefix()
        return {
            "{0}-0-is_mobile".format(_p): False,
            "{0}-0-name".format(_p): "Centro Piazza Venezia",
            "{0}-0-address".format(_p): "Piazza Venezia, Roma, Italia",
            "{0}-0-geometry".format(
                _p
            ): '{"type": "Point", "coordinates": [12.512124, 41.898903]}',
            "{0}-TOTAL_FORMS".format(_p): "1",
            "{0}-INITIAL_FORMS".format(_p): "0",
            "{0}-MIN_NUM_FORMS".format(_p): "0",
            "{0}-MAX_NUM_FORMS".format(_p): "1",
        }

    @property
    def params(self):
        return self.__class__._get_params()

    def test_json_urls(self):
        self._login_as_admin()
        r = self.client.get(reverse(self.add_url))
        placeholder_pk = "00000000-0000-0000-0000-000000000000"
        url = reverse("admin:django_loci_location_json", args=[placeholder_pk])
        self.assertContains(r, url)
        url = reverse(
            "admin:django_loci_location_floorplans_json", args=[placeholder_pk]
        )
        self.assertContains(r, url)

    def test_add_outdoor_new(self):
        self._login_as_admin()
        p = self._get_prefix()
        params = self.params
        params.update(
            {
                "name": "test-outdoor-add-new",
                "{0}-0-type".format(p): "outdoor",
                "{0}-0-location_selection".format(p): "new",
                "{0}-0-location".format(p): "",
                "{0}-0-floorplan_selection".format(p): "",
                "{0}-0-floorplan".format(p): "",
                "{0}-0-floor".format(p): "",
                "{0}-0-image".format(p): "",
                "{0}-0-indoor".format(p): "",
                "{0}-0-id".format(p): "",
            }
        )
        r = self.client.post(reverse(self.add_url), params, follow=True)
        self.assertNotContains(r, "errors")
        loc = self.location_model.objects.get(name=params["{0}-0-name".format(p)])
        self.assertEqual(loc.address, params["{0}-0-address".format(p)])
        self.assertEqual(
            loc.geometry.coords, GEOSGeometry(params["{0}-0-geometry".format(p)]).coords
        )
        self.assertEqual(loc.objectlocation_set.count(), 1)
        self.assertEqual(
            loc.objectlocation_set.first().content_object.name, params["name"]
        )

    def test_add_outdoor_existing(self):
        self._login_as_admin()
        p = self._get_prefix()
        pre_loc = self._create_location()
        params = self.params
        params.update(
            {
                "name": "test-outdoor-add-existing",
                "{0}-0-type".format(p): "outdoor",
                "{0}-0-location_selection".format(p): "existing",
                "{0}-0-location".format(p): pre_loc.id,
                "{0}-0-floorplan_selection".format(p): "",
                "{0}-0-floorplan".format(p): "",
                "{0}-0-floor".format(p): "",
                "{0}-0-image".format(p): "",
                "{0}-0-indoor".format(p): "",
                "{0}-0-id".format(p): "",
            }
        )
        r = self.client.post(reverse(self.add_url), params, follow=True)
        self.assertNotContains(r, "errors")
        loc = self.location_model.objects.get(name=params["{0}-0-name".format(p)])
        self.assertEqual(pre_loc.id, loc.id)
        self.assertEqual(loc.address, params["{0}-0-address".format(p)])
        self.assertEqual(
            loc.geometry.coords, GEOSGeometry(params["{0}-0-geometry".format(p)]).coords
        )
        self.assertEqual(loc.objectlocation_set.count(), 1)
        self.assertEqual(
            loc.objectlocation_set.first().content_object.name, params["name"]
        )
        self.assertEqual(self.location_model.objects.count(), 1)

    def test_change_outdoor(self):
        self._login_as_admin()
        p = self._get_prefix()
        obj = self._create_object(name="test-change-outdoor")
        pre_loc = self._create_location()
        ol = self._create_object_location(location=pre_loc, content_object=obj)
        # -- ensure change form doesn't raise any exception
        r = self.client.get(reverse(self.change_url, args=[obj.pk]))
        self.assertContains(r, obj.name)
        # -- post changes
        params = self.params
        params.update(
            {
                "name": obj.name,
                "{0}-0-type".format(p): "outdoor",
                "{0}-0-location_selection".format(p): "existing",
                "{0}-0-location".format(p): pre_loc.id,
                "{0}-0-floorplan_selection".format(p): "",
                "{0}-0-floorplan".format(p): "",
                "{0}-0-floor".format(p): "",
                "{0}-0-image".format(p): "",
                "{0}-0-indoor".format(p): "",
                "{0}-0-id".format(p): ol.id,
                "{0}-INITIAL_FORMS".format(p): "1",
            }
        )
        r = self.client.post(
            reverse(self.change_url, args=[obj.pk]), params, follow=True
        )
        self.assertNotContains(r, "errors")
        loc = self.location_model.objects.get(name=params["{0}-0-name".format(p)])
        self.assertEqual(pre_loc.id, loc.id)
        self.assertEqual(loc.address, params["{0}-0-address".format(p)])
        self.assertEqual(
            loc.geometry.coords, GEOSGeometry(params["{0}-0-geometry".format(p)]).coords
        )
        self.assertEqual(loc.objectlocation_set.count(), 1)
        self.assertEqual(
            loc.objectlocation_set.first().content_object.name, params["name"]
        )
        self.assertEqual(self.location_model.objects.count(), 1)

    def test_change_outdoor_to_different_location(self):
        self._login_as_admin()
        p = self._get_prefix()
        ol = self._create_object_location()
        new_loc = self._create_location(
            name="different-location",
            address="Piazza Venezia, Roma, Italia",
            geometry="SRID=4326;POINT (12.512324 41.898703)",
        )
        # -- post changes
        params = self.params
        changed_name = "{0} changed".format(new_loc.name)
        params.update(
            {
                "name": "test-outdoor-change-different",
                "{0}-0-type".format(p): "outdoor",
                "{0}-0-location_selection".format(p): "existing",
                "{0}-0-location".format(p): new_loc.id,
                "{0}-0-name".format(p): changed_name,
                "{0}-0-address".format(p): new_loc.address,
                "{0}-0-geometry".format(p): new_loc.geometry.geojson,
                "{0}-0-floorplan_selection".format(p): "",
                "{0}-0-floorplan".format(p): "",
                "{0}-0-floor".format(p): "",
                "{0}-0-image".format(p): "",
                "{0}-0-indoor".format(p): "",
                "{0}-0-id".format(p): ol.id,
                "{0}-INITIAL_FORMS".format(p): "1",
            }
        )
        r = self.client.post(
            reverse(self.change_url, args=[ol.content_object.pk]), params, follow=True
        )
        self.assertNotContains(r, "errors")
        loc = self.location_model.objects.get(name=changed_name)
        self.assertEqual(new_loc.id, loc.id)
        self.assertEqual(loc.address, params["{0}-0-address".format(p)])
        self.assertEqual(
            loc.geometry.coords, GEOSGeometry(params["{0}-0-geometry".format(p)]).coords
        )
        self.assertEqual(loc.objectlocation_set.count(), 1)
        self.assertEqual(
            loc.objectlocation_set.first().content_object.name, params["name"]
        )
        self.assertEqual(self.location_model.objects.count(), 2)

    def test_add_indoor_new_location_new_floorplan(self):
        self._login_as_admin()
        p = self._get_prefix()
        params = self.params
        params.update(
            {
                "name": "test-add-indoor-new-location-new-floorplan",
                "{0}-0-type".format(p): "indoor",
                "{0}-0-location_selection".format(p): "new",
                "{0}-0-location".format(p): "",
                "{0}-0-floorplan_selection".format(p): "new",
                "{0}-0-floorplan".format(p): "",
                "{0}-0-floor".format(p): "1",
                "{0}-0-image".format(p): self._get_simpleuploadedfile(),
                "{0}-0-indoor".format(p): "-100,100",
                "{0}-0-id".format(p): "",
            }
        )
        r = self.client.post(reverse(self.add_url), params, follow=True)
        self.assertNotContains(r, "errors")
        loc = self.location_model.objects.get(name=params["{0}-0-name".format(p)])
        self.assertEqual(loc.address, params["{0}-0-address".format(p)])
        self.assertEqual(
            loc.geometry.coords, GEOSGeometry(params["{0}-0-geometry".format(p)]).coords
        )
        self.assertEqual(loc.objectlocation_set.count(), 1)
        self.assertEqual(self.location_model.objects.count(), 1)
        self.assertEqual(self.floorplan_model.objects.count(), 1)
        ol = loc.objectlocation_set.first()
        self.assertEqual(ol.content_object.name, params["name"])
        self.assertEqual(ol.location.type, "indoor")
        self.assertEqual(ol.floorplan.floor, 1)
        self.assertIsInstance(ol.floorplan.image, ImageFieldFile)
        self.assertEqual(ol.indoor, "-100,100")

    def test_add_indoor_existing_location_new_floorplan(self):
        self._login_as_admin()
        pre_loc = self._create_location(type="indoor")
        p = self._get_prefix()
        params = self.params
        name = "test-add-indoor-existing-location-new-floorplan"
        params.update(
            {
                "name": name,
                "{0}-0-type".format(p): "indoor",
                "{0}-0-location_selection".format(p): "existing",
                "{0}-0-location".format(p): pre_loc.id,
                "{0}-0-name".format(p): pre_loc.name,
                "{0}-0-address".format(p): pre_loc.address,
                "{0}-0-geometry".format(p): pre_loc.geometry.geojson,
                "{0}-0-floorplan_selection".format(p): "new",
                "{0}-0-floorplan".format(p): "",
                "{0}-0-floor".format(p): "0",
                "{0}-0-image".format(p): self._get_simpleuploadedfile(),
                "{0}-0-indoor".format(p): "-100,100",
                "{0}-0-id".format(p): "",
            }
        )
        r = self.client.post(reverse(self.add_url), params, follow=True)
        # with open('test.html', 'w') as f:
        #     f.write(r.content.decode())
        self.assertNotContains(r, "errors")
        loc = self.location_model.objects.get(name=params["{0}-0-name".format(p)])
        self.assertEqual(loc.address, params["{0}-0-address".format(p)])
        self.assertEqual(
            loc.geometry.coords, GEOSGeometry(params["{0}-0-geometry".format(p)]).coords
        )
        self.assertEqual(loc.objectlocation_set.count(), 1)
        self.assertEqual(self.location_model.objects.count(), 1)
        self.assertEqual(self.floorplan_model.objects.count(), 1)
        ol = loc.objectlocation_set.first()
        self.assertEqual(ol.content_object.name, params["name"])
        self.assertEqual(ol.location.type, "indoor")
        self.assertEqual(ol.floorplan.floor, 0)
        self.assertIsInstance(ol.floorplan.image, ImageFieldFile)
        self.assertEqual(ol.indoor, "-100,100")

    def test_add_indoor_existing_location_existing_floorplan(self):
        self._login_as_admin()
        pre_loc = self._create_location(type="indoor")
        pre_fl = self._create_floorplan(location=pre_loc, floor=2)
        p = self._get_prefix()
        params = self.params
        name = "test-add-indoor-existing-location-new-floorplan"
        params.update(
            {
                "name": name,
                "{0}-0-type".format(p): "indoor",
                "{0}-0-location_selection".format(p): "existing",
                "{0}-0-location".format(p): pre_loc.id,
                "{0}-0-name".format(p): name,
                "{0}-0-address".format(p): pre_loc.address,
                "{0}-0-location-geometry".format(p): pre_loc.geometry,
                "{0}-0-floorplan_selection".format(p): "existing",
                "{0}-0-floorplan".format(p): pre_fl.id,
                "{0}-0-floor".format(p): 3,  # floor
                "{0}-0-image".format(p): "",
                "{0}-0-indoor".format(p): "-110,110",
                "{0}-0-id".format(p): "",
            }
        )
        r = self.client.post(reverse(self.add_url), params, follow=True)
        self.assertNotContains(r, "errors")
        loc = self.location_model.objects.get(name=name)
        self.assertEqual(loc.id, pre_loc.id)
        self.assertEqual(loc.address, params["{0}-0-address".format(p)])
        self.assertEqual(
            loc.geometry.coords, GEOSGeometry(params["{0}-0-geometry".format(p)]).coords
        )
        self.assertEqual(loc.objectlocation_set.count(), 1)
        self.assertEqual(self.location_model.objects.count(), 1)
        self.assertEqual(self.floorplan_model.objects.count(), 1)
        ol = loc.objectlocation_set.first()
        self.assertEqual(ol.content_object.name, params["name"])
        self.assertEqual(ol.location.type, "indoor")
        self.assertEqual(ol.floorplan.id, pre_fl.id)
        self.assertEqual(ol.floorplan.floor, 3)
        self.assertIsInstance(ol.floorplan.image, ImageFieldFile)
        self.assertEqual(ol.indoor, "-110,110")

    def test_change_indoor(self):
        self._login_as_admin()
        p = self._get_prefix()
        obj = self._create_object(name="test-change-indoor")
        pre_loc = self._create_location(type="indoor")
        pre_fl = self._create_floorplan(location=pre_loc)
        ol = self._create_object_location(
            content_object=obj, location=pre_loc, floorplan=pre_fl, indoor="-100,100"
        )
        # -- ensure change form doesn't raise any exception
        r = self.client.get(reverse(self.change_url, args=[obj.pk]))
        self.assertContains(r, obj.name)
        # -- post changes
        params = self.params
        changed_name = "{0} changed".format(pre_loc.name)
        params.update(
            {
                "name": obj.name,
                "{0}-0-type".format(p): "indoor",
                "{0}-0-location_selection".format(p): "existing",
                "{0}-0-location".format(p): pre_loc.id,
                "{0}-0-name".format(p): changed_name,
                "{0}-0-address".format(p): "changed-address",
                "{0}-0-location-geometry".format(p): pre_loc.geometry,
                "{0}-0-floorplan_selection".format(p): "existing",
                "{0}-0-floorplan".format(p): pre_fl.id,
                "{0}-0-floor".format(p): 3,  # floor
                "{0}-0-image".format(p): self._get_simpleuploadedfile(),
                "{0}-0-indoor".format(p): "-110,110",
                "{0}-0-id".format(p): ol.id,
                "{0}-INITIAL_FORMS".format(p): "1",
            }
        )
        r = self.client.post(
            reverse(self.change_url, args=[obj.pk]), params, follow=True
        )
        self.assertNotContains(r, "errors")
        loc = self.location_model.objects.get(name=changed_name)
        self.assertEqual(loc.id, pre_loc.id)
        self.assertEqual(loc.address, "changed-address")
        self.assertEqual(
            loc.geometry.coords, GEOSGeometry(params["{0}-0-geometry".format(p)]).coords
        )
        self.assertEqual(loc.objectlocation_set.count(), 1)
        self.assertEqual(self.location_model.objects.count(), 1)
        self.assertEqual(self.floorplan_model.objects.count(), 1)
        ol = loc.objectlocation_set.first()
        self.assertEqual(ol.content_object.name, params["name"])
        self.assertEqual(ol.location.type, "indoor")
        self.assertEqual(ol.floorplan.id, pre_fl.id)
        self.assertEqual(ol.floorplan.floor, 3)
        self.assertIsInstance(ol.floorplan.image, ImageFieldFile)
        self.assertEqual(ol.indoor, "-110,110")

    def test_change_indoor_missing_indoor_position(self):
        self._login_as_admin()
        p = self._get_prefix()
        obj = self._create_object(name="test-change-indoor")
        pre_loc = self._create_location(type="indoor")
        pre_fl = self._create_floorplan(location=pre_loc)
        ol = self._create_object_location(
            content_object=obj, location=pre_loc, floorplan=pre_fl, indoor="-100,100"
        )
        # -- post changes
        params = self.params
        params.update(
            {
                "name": obj.name,
                "{0}-0-type".format(p): "indoor",
                "{0}-0-location_selection".format(p): "existing",
                "{0}-0-location".format(p): pre_loc.id,
                "{0}-0-name".format(p): pre_loc.name,
                "{0}-0-address".format(p): pre_loc.address,
                "{0}-0-location-geometry".format(p): pre_loc.geometry,
                "{0}-0-floorplan_selection".format(p): "existing",
                "{0}-0-floorplan".format(p): pre_fl.id,
                "{0}-0-floor".format(p): pre_fl.floor,
                "{0}-0-indoor".format(p): "",
                "{0}-0-id".format(p): ol.id,
                "{0}-INITIAL_FORMS".format(p): "1",
            }
        )
        r = self.client.post(
            reverse(self.change_url, args=[obj.pk]), params, follow=True
        )
        self.assertContains(r, "errors field-indoor")

    def test_add_outdoor_invalid(self):
        self._login_as_admin()
        p = self._get_prefix()
        params = self.params
        params.update(
            {
                "name": "test-outdoor-invalid",
                "{0}-0-type".format(p): "outdoor",
                "{0}-0-location_selection".format(p): "new",
                "{0}-0-location".format(p): "",
                "{0}-0-name".format(p): "",
                "{0}-0-address".format(p): "",
                "{0}-0-geometry".format(p): "",
            }
        )
        r = self.client.post(reverse(self.add_url), params, follow=True)
        self.assertContains(r, "errors field-name")
        self.assertContains(r, "errors field-address")
        self.assertContains(r, "errors field-geometry")

    def test_add_outdoor_invalid_geometry(self):
        self._login_as_admin()
        p = self._get_prefix()
        params = self.params
        params.update(
            {
                "name": "test-outdoor-invalid-geometry",
                "{0}-0-type".format(p): "outdoor",
                "{0}-0-location_selection".format(p): "new",
                "{0}-0-location".format(p): "",
                "{0}-0-geometry".format(p): "INVALID",
            }
        )
        r = self.client.post(reverse(self.add_url), params, follow=True)
        self.assertContains(r, "errors field-geometry")

    def test_add_mobile(self):
        self._login_as_admin()
        p = self._get_prefix()
        params = self.params
        params.update(
            {
                "name": "test-add-mobile",
                "{0}-0-type".format(p): "outdoor",
                "{0}-0-is_mobile".format(p): True,
                "{0}-0-location_selection".format(p): "new",
                "{0}-0-name".format(p): "",
                "{0}-0-address".format(p): "",
                "{0}-0-geometry".format(p): "",
            }
        )
        self.assertEqual(self.location_model.objects.count(), 0)
        r = self.client.post(reverse(self.add_url), params, follow=True)
        self.assertNotContains(r, "errors")
        self.assertEqual(self.location_model.objects.filter(is_mobile=True).count(), 1)
        self.assertEqual(self.object_location_model.objects.count(), 1)
        loc = self.location_model.objects.first()
        self.assertEqual(
            loc.objectlocation_set.first().content_object.name, params["name"]
        )
        self.assertEqual(loc.name, params["name"])

    def test_change_mobile(self):
        self._login_as_admin()
        obj = self._create_object(name="test-change-mobile")
        pre_loc = self._create_location(name=obj.name, is_mobile=True)
        ol = self._create_object_location(content_object=obj, location=pre_loc)
        p = self._get_prefix()
        params = self.params
        params.update(
            {
                "name": "test-change-mobile",
                "{0}-0-type".format(p): "outdoor",
                "{0}-0-is_mobile".format(p): True,
                "{0}-0-location".format(p): pre_loc.id,
                "{0}-0-name".format(p): "",
                "{0}-0-address".format(p): "",
                "{0}-0-geometry".format(p): "",
                "{0}-0-location_selection".format(p): "new",
                "{0}-0-id".format(p): ol.id,
                "{0}-INITIAL_FORMS".format(p): "1",
            }
        )
        self.assertEqual(self.location_model.objects.count(), 1)
        r = self.client.post(
            reverse(self.change_url, args=[obj.pk]), params, follow=True
        )
        self.assertNotContains(r, "errors")
        self.assertEqual(self.object_location_model.objects.count(), 1)
        self.assertEqual(self.location_model.objects.filter(is_mobile=True).count(), 1)
        loc = self.location_model.objects.first()
        self.assertEqual(
            loc.objectlocation_set.first().content_object.name, params["name"]
        )

    def test_remove_mobile(self):
        self._login_as_admin()
        p = self._get_prefix()
        obj = self._create_object(name="test-remove-mobile")
        pre_loc = self._create_location(name=obj.name, is_mobile=True)
        ol = self._create_object_location(content_object=obj, location=pre_loc)
        # -- post changes
        params = self.params
        params.update(
            {
                "name": "test-remove-mobile",
                "{0}-0-type".format(p): "outdoor",
                "{0}-0-is_mobile".format(p): False,
                "{0}-0-location_selection".format(p): "existing",
                "{0}-0-location".format(p): pre_loc.id,
                "{0}-0-name".format(p): pre_loc.name,
                "{0}-0-address".format(p): pre_loc.address,
                "{0}-0-geometry".format(p): pre_loc.geometry.geojson,
                "{0}-0-id".format(p): ol.id,
                "{0}-INITIAL_FORMS".format(p): "1",
            }
        )
        r = self.client.post(
            reverse(self.change_url, args=[ol.content_object.pk]), params, follow=True
        )
        self.assertNotContains(r, "errors")
        self.assertEqual(self.location_model.objects.filter(is_mobile=False).count(), 1)
        self.assertEqual(self.location_model.objects.count(), 1)

    def test_change_indoor_missing_floorplan_pk(self):
        self._login_as_admin()
        p = self._get_prefix()
        obj = self._create_object(name="test-floorplan-error")
        pre_loc = self._create_location(type="indoor")
        pre_fl = self._create_floorplan(location=pre_loc)
        ol = self._create_object_location(
            content_object=obj, location=pre_loc, floorplan=pre_fl, indoor="-100,100"
        )
        # -- post changes
        params = self.params
        params.update(
            {
                "name": obj.name,
                "{0}-0-type".format(p): "indoor",
                "{0}-0-location_selection".format(p): "existing",
                "{0}-0-location".format(p): pre_loc.id,
                "{0}-0-name".format(p): pre_loc.name,
                "{0}-0-type".format(p): pre_loc.type,
                "{0}-0-is_mobile".format(p): pre_loc.is_mobile,
                "{0}-0-address".format(p): pre_loc.address,
                "{0}-0-location-geometry".format(p): pre_loc.geometry,
                "{0}-0-floorplan_selection".format(p): "existing",
                "{0}-0-floorplan".format(p): "",
                "{0}-0-floor".format(p): pre_fl.floor,
                "{0}-0-indoor".format(p): "-100,100",
                "{0}-0-id".format(p): ol.id,
                "{0}-INITIAL_FORMS".format(p): "1",
            }
        )
        r = self.client.post(
            reverse(self.change_url, args=[obj.pk]), params, follow=True
        )
        self.assertContains(r, "errors field-floorplan")
        self.assertContains(r, "No floorplan selected")

    def test_change_indoor_floorplan_doesnotexist(self):
        self._login_as_admin()
        p = self._get_prefix()
        obj = self._create_object(name="test-floorplan-error")
        pre_loc = self._create_location(type="indoor")
        pre_fl = self._create_floorplan(location=pre_loc)
        ol = self._create_object_location(
            content_object=obj, location=pre_loc, floorplan=pre_fl, indoor="-100,100"
        )
        # -- post changes
        params = self.params
        params.update(
            {
                "name": obj.name,
                "{0}-0-type".format(p): "indoor",
                "{0}-0-location_selection".format(p): "existing",
                "{0}-0-location".format(p): pre_loc.id,
                "{0}-0-name".format(p): pre_loc.name,
                "{0}-0-type".format(p): pre_loc.type,
                "{0}-0-is_mobile".format(p): pre_loc.is_mobile,
                "{0}-0-address".format(p): pre_loc.address,
                "{0}-0-location-geometry".format(p): pre_loc.geometry,
                "{0}-0-floorplan_selection".format(p): "existing",
                "{0}-0-floorplan".format(p): self.floorplan_model().id,
                "{0}-0-floor".format(p): pre_fl.floor,
                "{0}-0-indoor".format(p): "-100,100",
                "{0}-0-id".format(p): ol.id,
                "{0}-INITIAL_FORMS".format(p): "1",
            }
        )
        r = self.client.post(
            reverse(self.change_url, args=[obj.pk]), params, follow=True
        )
        self.assertContains(r, "errors field-floorplan")
        self.assertContains(r, "Selected floorplan does not exist")

    def test_change_indoor_floorplan_different_location(self):
        self._login_as_admin()
        p = self._get_prefix()
        obj = self._create_object(name="test-floorplan-error")
        pre_loc = self._create_location(type="indoor")
        pre_fl = self._create_floorplan(location=pre_loc)
        ol = self._create_object_location(
            content_object=obj, location=pre_loc, floorplan=pre_fl, indoor="-100,100"
        )
        fl = self._create_floorplan()
        # -- post changes
        params = self.params
        params.update(
            {
                "name": obj.name,
                "{0}-0-type".format(p): "indoor",
                "{0}-0-location_selection".format(p): "existing",
                "{0}-0-location".format(p): pre_loc.id,
                "{0}-0-name".format(p): pre_loc.name,
                "{0}-0-address".format(p): pre_loc.address,
                "{0}-0-location-geometry".format(p): pre_loc.geometry,
                "{0}-0-floorplan_selection".format(p): "existing",
                "{0}-0-floorplan".format(p): fl.id,
                "{0}-0-floor".format(p): pre_fl.floor,
                "{0}-0-indoor".format(p): "-100,100",
                "{0}-0-id".format(p): ol.id,
                "{0}-INITIAL_FORMS".format(p): "1",
            }
        )
        r = self.client.post(
            reverse(self.change_url, args=[obj.pk]), params, follow=True
        )
        self.assertContains(r, "errors field-floorplan")
        self.assertContains(r, "This floorplan is associated to a different location")

    def test_missing_type_error(self):
        self._login_as_admin()
        p = self._get_prefix()
        params = self.params
        params.update(
            {
                "name": "test-outdoor-add-new",
                "{0}-0-type".format(p): "",
                "{0}-0-location_selection".format(p): "new",
                "{0}-0-location".format(p): "",
                "{0}-0-floorplan_selection".format(p): "",
                "{0}-0-floorplan".format(p): "",
                "{0}-0-floor".format(p): "",
                "{0}-0-image".format(p): "",
                "{0}-0-indoor".format(p): "",
                "{0}-0-id".format(p): "",
            }
        )
        r = self.client.post(reverse(self.add_url), params, follow=True)
        self.assertContains(r, "errors field-type")

    def test_add_indoor_location_without_indoor_coords(self):
        self._login_as_admin()
        p = self._get_prefix()
        params = self.params
        params.update(
            {
                "name": "test-add-indoor-location-without-coords",
                "{0}-0-type".format(p): "indoor",
                "{0}-0-location_selection".format(p): "new",
                "{0}-0-location".format(p): "",
                "{0}-0-floorplan_selection".format(p): "new",
                "{0}-0-floorplan".format(p): "",
                "{0}-0-floor".format(p): "",
                "{0}-0-image".format(p): "",
                "{0}-0-indoor".format(p): "",
                "{0}-0-id".format(p): "",
            }
        )
        r = self.client.post(reverse(self.add_url), params, follow=True)
        self.assertNotContains(r, "errors")
        loc = self.location_model.objects.get(name=params["{0}-0-name".format(p)])
        self.assertEqual(loc.address, params["{0}-0-address".format(p)])
        self.assertEqual(loc.objectlocation_set.count(), 1)
        self.assertEqual(self.location_model.objects.count(), 1)
        self.assertEqual(self.floorplan_model.objects.count(), 0)
        ol = loc.objectlocation_set.first()
        self.assertEqual(ol.content_object.name, params["name"])
        self.assertEqual(ol.location.type, "indoor")
        self.assertEqual(ol.indoor, "")

    def test_add_indoor_mobile_location_without_floor(self):
        self._login_as_admin()
        p = self._get_prefix()
        params = self.params
        params.update(
            {
                "name": "test-add-indoor-mobile-location-without-floor",
                "{0}-0-type".format(p): "indoor",
                "{0}-0-location_selection".format(p): "new",
                "{0}-0-is_mobile".format(p): True,
                "{0}-0-location".format(p): "",
                "{0}-0-floorplan_selection".format(p): "new",
                "{0}-0-floorplan".format(p): "",
                "{0}-0-floor".format(p): "",
                "{0}-0-image".format(p): self._get_simpleuploadedfile(),
                "{0}-0-indoor".format(p): "",
                "{0}-0-id".format(p): "",
            }
        )
        r = self.client.post(reverse(self.add_url), params, follow=True)
        self.assertContains(r, "errors field-floor")
        loc = self.location_model.objects.filter(name=params["{0}-0-name".format(p)])
        self.assertEqual(loc.count(), 0)

    def test_add_indoor_location_without_coords(self):
        self._login_as_admin()
        p = self._get_prefix()
        params = self.params
        params.update(
            {
                "name": "test-add-indoor-location-without-coords",
                "{0}-0-type".format(p): "indoor",
                "{0}-0-location_selection".format(p): "new",
                "{0}-0-location".format(p): "",
                "{0}-0-floorplan_selection".format(p): "new",
                "{0}-0-floorplan".format(p): "",
                "{0}-0-floor".format(p): "1",
                "{0}-0-image".format(p): self._get_simpleuploadedfile(),
                "{0}-0-indoor".format(p): "",
                "{0}-0-id".format(p): "",
            }
        )
        r = self.client.post(reverse(self.add_url), params, follow=True)
        self.assertContains(r, "error")
        loc = self.location_model.objects.filter(name=params["{0}-0-name".format(p)])
        self.assertEqual(loc.count(), 0)

    def test_add_indoor_location_without_floor(self):
        p = self._get_prefix()
        params = self.params
        params.update(
            {
                "name": "test-add-indoor-location-without-coords",
                "{0}-0-type".format(p): "indoor",
                "{0}-0-location_selection".format(p): "new",
                "{0}-0-location".format(p): "",
                "{0}-0-floorplan_selection".format(p): "new",
                "{0}-0-floorplan".format(p): "",
                "{0}-0-floor".format(p): "",
                "{0}-0-image".format(p): self._get_simpleuploadedfile(),
                "{0}-0-indoor".format(p): "-100,100",
                "{0}-0-id".format(p): "",
            }
        )
        r = self.client.post(reverse(self.add_url), params)
        self.assertEqual(r.status_code, 302)

    # Test for ensuring that the floorplan is not created when the location is outdoor
    def test_add_outdoor_with_floorplan(self):
        self._login_as_admin()
        p = "floorplan_set"
        params = self.params
        params.update(
            {
                "name": "test-add-outdoor-with-floorplan",
                "type": "outdoor",
                "geometry": "SRID=4326;POINT (12.512324 41.898703)",
                "address": "Piazza Venezia, Roma, Italia",
                "{0}-0-floor".format(p): "1",
                "{0}-0-image".format(p): self._get_simpleuploadedfile(),
                "{0}-0-id".format(p): "",
                "{0}-0-location".format(p): "",
                "{0}-TOTAL_FORMS".format(p): "1",
                "{0}-INITIAL_FORMS".format(p): "0",
                "{0}-MIN_NUM_FORMS".format(p): "0",
                "{0}-MAX_NUM_FORMS".format(p): "1",
            }
        )
        location_url = "{0}_{1}_add".format(
            self.url_prefix, self.location_model.__name__.lower()
        )
        r = self.client.post(reverse(location_url), params, follow=True)
        self.assertNotContains(r, "errors")
        self.assertNotContains(r, "errornote")
        loc = self.location_model.objects.get(name=params["name"])
        self.assertEqual(loc.address, params["address"])
        self.assertEqual(loc.geometry.coords, GEOSGeometry(params["geometry"]).coords)
        self.assertEqual(self.location_model.objects.count(), 1)
        self.assertEqual(self.floorplan_model.objects.count(), 0)

    # Test for ensuring that location is changed from outdoor to indoor, with related floorplans created
    def test_device_change_location_from_outdoor_to_indoor(self):
        self._login_as_admin()
        p = self._get_prefix()
        pre_loc = self._create_location()
        obj = self._create_object()
        ol = self._create_object_location(location=pre_loc, content_object=obj)
        params = self.params
        params.update(
            {
                "name": obj.name,
                "{0}-0-type".format(p): "indoor",
                "{0}-0-location_selection".format(p): "existing",
                "{0}-0-location".format(p): pre_loc.id,
                "{0}-0-name".format(p): pre_loc.name,
                "{0}-0-address".format(p): pre_loc.address,
                "{0}-0-location-geometry".format(p): pre_loc.geometry,
                "{0}-0-floorplan_selection".format(p): "new",
                "{0}-0-floorplan".format(p): "",
                "{0}-0-floor".format(p): "1",
                "{0}-0-image".format(p): self._get_simpleuploadedfile(),
                "{0}-0-indoor".format(p): "-100,100",
                "{0}-0-id".format(p): ol.id,
                "{0}-INITIAL_FORMS".format(p): "1",
            }
        )
        r = self.client.post(
            reverse(self.change_url, args=[obj.pk]), params, follow=True
        )
        self.assertNotContains(r, "errors")
        self.assertEqual(self.location_model.objects.count(), 1)
        self.assertEqual(self.object_location_model.objects.count(), 1)
        self.assertEqual(self.location_model.objects.first().type, "indoor")
        self.assertEqual(self.location_model.objects.first().floorplan_set.count(), 1)
        self.assertIsNotNone(self.object_location_model.objects.first().floorplan)
        self.assertEqual(
            self.location_model.objects.first().objectlocation_set.count(), 1
        )
        self.assertEqual(
            self.location_model.objects.first()
            .objectlocation_set.first()
            .content_object,
            obj,
        )

    # Test for ensuring that location is changed from indoor to outdoor, with related floorplans removed
    def test_device_change_location_from_indoor_to_outdoor(self):
        self._login_as_admin()
        p = self._get_prefix()
        pre_loc = self._create_location(type="indoor")
        obj = self._create_object()
        ol = self._create_object_location(location=pre_loc, content_object=obj)
        params = self.params
        params.update(
            {
                "name": obj.name,
                "{0}-0-type".format(p): "outdoor",
                "{0}-0-location_selection".format(p): "existing",
                "{0}-0-location".format(p): pre_loc.id,
                "{0}-0-name".format(p): pre_loc.name,
                "{0}-0-address".format(p): pre_loc.address,
                "{0}-0-location-geometry".format(p): pre_loc.geometry,
                "{0}-0-id".format(p): ol.id,
                "{0}-INITIAL_FORMS".format(p): "1",
            }
        )
        r = self.client.post(
            reverse(self.change_url, args=[obj.pk]), params, follow=True
        )
        self.assertNotContains(r, "errors")
        self.assertEqual(self.location_model.objects.count(), 1)
        self.assertEqual(self.object_location_model.objects.count(), 1)
        self.assertEqual(self.location_model.objects.first().type, "outdoor")
        self.assertEqual(self.location_model.objects.first().floorplan_set.count(), 0)
        self.assertIsNone(self.object_location_model.objects.first().floorplan)
        self.assertEqual(
            self.location_model.objects.first().objectlocation_set.count(), 1
        )
        self.assertEqual(
            self.location_model.objects.first()
            .objectlocation_set.first()
            .content_object,
            obj,
        )

    # for users with view only permissions to location
    def test_readonly_indoor_location(self):
        user = self._create_readonly_admin(
            models=[self.location_model, self.floorplan_model]
        )
        self.client.force_login(user)
        loc = self._create_location(name="test-admin-location-1", type="indoor")
        fl = self._create_floorplan(location=loc)
        url = reverse("{0}_location_change".format(self.url_prefix), args=[loc.pk])
        r = self.client.get(url)
        self.assertEqual(r.status_code, 200)
        # assert if map is being rendered or not
        self.assertContains(r, "geometry-div-map")
        # assert if inline fields are visible
        self.assertContains(r, f"{loc.name} {ordinal(fl.floor)}")
        self.assertContains(r, fl.floor)
        self.assertContains(r, fl.image.url)
        self.assertContains(r, loc.name)
        self.assertContains(r, loc.address)
        self.assertContains(r, loc.type)
        self.assertContains(r, loc.is_mobile)

    # for users with view only permissions to objectlocation
    def test_readonly_indoor_object_location(self):
        user = self._create_readonly_admin(
            models=[self.object_model, self.object_location_model]
        )
        self.client.force_login(user)
        obj = self._create_object(name="test-admin-object-1")
        loc = self._create_location(name="test-admin-location-1", type="indoor")
        fl = self._create_floorplan(location=loc)
        ol = self._create_object_location(
            content_object=obj, location=loc, floorplan=fl
        )
        r = self.client.get(reverse(self.change_url, args=[obj.pk]))
        self.assertEqual(r.status_code, 200)
        # assert if map is being rendered or not
        self.assertContains(r, "geometry-div-map")
        self.assertContains(r, "id_indoor_map")
        # id is required for indoor map to render
        self.assertContains(r, 'id="id_indoor"')
        # assert if inline fields are visible
        self.assertContains(r, f"{loc.name} {ordinal(fl.floor)} floor")
        self.assertContains(r, fl.floor)
        self.assertContains(r, fl.image.url)
        self.assertContains(r, loc.name)
        self.assertContains(r, loc.address)
        self.assertContains(r, loc.type)
        self.assertContains(r, loc.is_mobile)
        self.assertContains(r, obj.name)
        self.assertContains(r, ol.indoor)


================================================
FILE: django_loci/tests/base/test_apps.py
================================================
from unittest.mock import patch

from django.conf import settings
from django.core.checks import Warning

from ...apps import test_geocoding
from .. import TestLociMixin


class BaseTestApps(TestLociMixin):
    @patch("django_loci.apps.geocode", return_value=None)
    @patch.object(settings, "TESTING", False)
    def test_geocode_strict(self, geocode_mocked):
        warning = test_geocoding()
        self.assertEqual(
            warning,
            [
                Warning(
                    "Geocoding service is experiencing issues or is not properly configured"
                )
            ],
        )
        geocode_mocked.assert_called_once()


================================================
FILE: django_loci/tests/base/test_channels.py
================================================
# use pytest
import pytest
from channels.db import database_sync_to_async
from channels.routing import ProtocolTypeRouter
from django.contrib.auth.models import Permission

from django_loci.channels.consumers import CommonLocationBroadcast, LocationBroadcast

from ...channels.base import _get_object_or_none
from .. import TestAdminMixin, TestChannelsMixin, TestLociMixin


class BaseTestChannels(TestAdminMixin, TestLociMixin, TestChannelsMixin):
    """
    In channels 2.x, Websockets can only be tested
    asynchronously, hence, pytest is used for these tests.
    """

    location_consumer = LocationBroadcast
    common_location_consumer = CommonLocationBroadcast

    @pytest.mark.django_db(transaction=True)
    def test_object_or_none(self):
        result = _get_object_or_none(self.location_model, pk=1)
        assert result is None
        plausible_pk = self.location_model().pk
        result = _get_object_or_none(self.location_model, pk=plausible_pk)
        assert result is None

    @pytest.mark.asyncio
    @pytest.mark.django_db(transaction=True)
    async def test_consumer_unauthenticated(self):
        request_vars = await self._get_specific_location_request_dict()
        communicator = self._get_specific_location_communicator(request_vars)
        connected, _ = await communicator.connect()
        assert not connected
        await communicator.disconnect()

    @pytest.mark.asyncio
    @pytest.mark.django_db(transaction=True)
    async def test_common_location_consumer_unauthenticated(self):
        request_vars = await self._get_common_location_request_dict()
        communicator = self._get_common_location_communicator(request_vars)
        connected, _ = await communicator.connect()
        assert not connected
        await communicator.disconnect()

    @pytest.mark.asyncio
    @pytest.mark.django_db(transaction=True)
    async def test_connect_admin(self):
        test_user = await database_sync_to_async(self._create_admin)()
        request_vars = await self._get_specific_location_request_dict(user=test_user)
        communicator = self._get_specific_location_communicator(request_vars, test_user)
        connected, _ = await communicator.connect()
        assert connected
        await communicator.disconnect()

    @pytest.mark.asyncio
    @pytest.mark.django_db(transaction=True)
    async def test_common_location_connect_admin(self):
        test_user = await database_sync_to_async(self._create_admin)()
        request_vars = await self._get_common_location_request_dict(user=test_user)
        communicator = self._get_common_location_communicator(request_vars, test_user)
        connected, _ = await communicator.connect()
        assert connected
        await communicator.disconnect()

    @pytest.mark.asyncio
    @pytest.mark.django_db(transaction=True)
    async def test_consumer_not_staff(self):
        test_user = await database_sync_to_async(self.user_model.objects.create_user)(
            username="user", password="password", email="test@test.org"
        )
        request_vars = await self._get_specific_location_request_dict(user=test_user)
        communicator = self._get_specific_location_communicator(request_vars, test_user)
        connected, _ = await communicator.connect()
        assert not connected
        await communicator.disconnect()

    @pytest.mark.asyncio
    @pytest.mark.django_db(transaction=True)
    async def test_common_location_consumer_not_staff(self):
        test_user = await database_sync_to_async(self.user_model.objects.create_user)(
            username="user", password="password", email="test@test.org"
        )
        request_vars = await self._get_common_location_request_dict(user=test_user)
        communicator = self._get_common_location_communicator(request_vars, test_user)
        connected, _ = await communicator.connect()
        assert not connected
        await communicator.disconnect()

    @pytest.mark.asyncio
    @pytest.mark.django_db(transaction=True)
    async def test_consumer_404(self):
        pk = self.location_model().pk
        admin = await database_sync_to_async(self._create_admin)()
        request_vars = await self._get_specific_location_request_dict(pk=pk, user=admin)
        communicator = self._get_specific_location_communicator(request_vars, admin)
        connected, _ = await communicator.connect()
        assert not connected

    @pytest.mark.asyncio
    @pytest.mark.django_db(transaction=True)
    async def test_consumer_staff_but_no_change_permission(self):
        test_user = await database_sync_to_async(self.user_model.objects.create_user)(
            username="user", password="password", email="test@test.org", is_staff=True
        )
        location = await database_sync_to_async(self._create_location)(is_mobile=True)
        ol = await database_sync_to_async(self._create_object_location)(
            location=location
        )
        pk = ol.location.pk
        request_vars = await self._get_specific_location_request_dict(
            pk=pk, user=test_user
        )
        communicator = self._get_specific_location_communicator(request_vars, test_user)
        connected, _ = await communicator.connect()
        assert not connected
        await communicator.disconnect()

        # add permission to change location and repeat
        loc_perm = await Permission.objects.filter(
            codename=f"change_{self.location_model._meta.model_name}"
        ).afirst()
        await test_user.user_permissions.aadd(loc_perm)
        test_user = await self.user_model.objects.aget(pk=test_user.pk)
        communicator = self._get_specific_location_communicator(request_vars, test_user)
        connected, _ = await communicator.connect()
        assert connected
        await communicator.disconnect()

    @pytest.mark.asyncio
    @pytest.mark.django_db(transaction=True)
    async def test_common_location_consumer_staff_but_no_change_permission(self):
        test_user = await database_sync_to_async(self.user_model.objects.create_user)(
            username="user", password="password", email="test@test.org", is_staff=True
        )
        location = await database_sync_to_async(self._create_location)(is_mobile=True)
        await database_sync_to_async(self._create_object_location)(location=location)
        request_vars = await self._get_common_location_request_dict(user=test_user)
        communicator = self._get_common_location_communicator(request_vars, test_user)
        connected, _ = await communicator.connect()
        assert not connected
        await communicator.disconnect()
        # add permission to change location and repeat
        loc_perm = await Permission.objects.filter(
            codename=f"change_{self.location_model._meta.model_name}"
        ).afirst()
        await test_user.user_permissions.aadd(loc_perm)
        test_user = await self.user_model.objects.aget(pk=test_user.pk)
        communicator = self._get_common_location_communicator(request_vars, test_user)
        connected, _ = await communicator.connect()
        assert connected
        await communicator.disconnect()

    @pytest.mark.asyncio
    @pytest.mark.django_db(transaction=True)
    async def test_location_update(self):
        test_user = await database_sync_to_async(self._create_admin)()
        request_vars = await self._get_specific_location_request_dict(user=test_user)
        communicator = self._get_specific_location_communicator(request_vars, test_user)
        connected, _ = await communicator.connect()
        assert connected
        await self._save_location(request_vars["pk"])
        response = await communicator.receive_json_from()
        assert response == {
            "geometry": {"type": "Point", "coordinates": [12.513124, 41.897903]},
            "address": "Via del Corso, Roma, Italia",
        }
        await communicator.disconnect()

    @pytest.mark.asyncio
    @pytest.mark.django_db(transaction=True)
    async def test_common_location_update(self):
        test_user = await database_sync_to_async(self._create_admin)()
        location1 = await database_sync_to_async(self._create_location)(is_mobile=True)
        await database_sync_to_async(self._create_object_location)(location=location1)
        location2 = await database_sync_to_async(self._create_location)(is_mobile=True)
        await database_sync_to_async(self._create_object_location)(location=location2)
        request_vars = await self._get_common_location_request_dict(
            pk=location1.pk, user=test_user
        )
        communicator = self._get_common_location_communicator(request_vars, test_user)
        connected, _ = await communicator.connect()
        assert connected
        await self._save_location(request_vars["pk"])
        response = await communicator.receive_json_from()
        assert response == {
            "id": str(location1.pk),
            "geometry": {"type": "Point", "coordinates": [12.513124, 41.897903]},
            "address": "Via del Corso, Roma, Italia",
            "name": location1.name,
            "type": location1.type,
            "is_mobile": True,
        }
        await self._save_location(location2.pk)
        response = await communicator.receive_json_from()
        assert response == {
            "id": str(location2.pk),
            "geometry": {"type": "Point", "coordinates": [12.513124, 41.897903]},
            "address": "Via del Corso, Roma, Italia",
            "name": location2.name,
            "type": location2.type,
            "is_mobile": True,
        }
        await communicator.disconnect()

    def test_routing(self):
        from django_loci.channels.asgi import channel_routing

        assert isinstance(channel_routing, ProtocolTypeRouter)


================================================
FILE: django_loci/tests/base/test_models.py
================================================
from django.core.exceptions import ValidationError

from .. import TestLociMixin


class BaseTestModels(TestLociMixin):
    def test_location_str(self):
        loc = self.location_model(name="test-location")
        self.assertEqual(str(loc), loc.name)

    def test_floorplan_str(self):
        loc = self._create_location()
        fl = self.floorplan_model(location=loc, floor=2)
        self.assertEqual(str(fl), "test-location 2nd floor")
        fl.floor = 0
        self.assertEqual(str(fl), "test-location ground floor")

    def test_object_location_clean_location(self):
        l1 = self._create_location(type="indoor")
        l2 = self._create_location(type="indoor")
        fl2 = self._create_floorplan(location=l2)
        obj = self._create_object()
        ol = self.object_location_model(content_object=obj, location=l1, floorplan=fl2)
        try:
            ol.full_clean()
        except ValidationError as e:
            self.assertIn("__all__", e.message_dict)
            self.assertEqual(
                e.message_dict.get("__all__")[0],
                "Invalid floorplan (belongs to a different location)",
            )
        else:
            self.fail("ValidationError not raised")

    def test_floorplan_image(self):
        fl = self._create_floorplan()
        path = fl.image.file.name.split("/")
        name = path[-1]
        dir_ = path[-2]
        self.assertEqual(name, "{0}.jpg".format(fl.id))
        self.assertEqual(dir_, "floorplans")
        # overwrite
        fl.image = self._get_simpleuploadedfile()
        fl.full_clean()
        fl.save()
        path = fl.image.file.name.split("/")
        name = path[-1]
        dir_ = path[-2]
        self.assertEqual(name, "{0}.jpg".format(fl.id))
        self.assertEqual(dir_, "floorplans")
        # delete
        image_path = fl.image.file.name
        fl.delete()
        self.assertFalse(fl.image.storage.exists(image_path))

    def test_floorplan_delete_corner_case(self):
        fl = self._create_floorplan()
        fl.image.storage.delete(fl.image.file.name)
        # there should be no failure
        fl.delete()

    def test_floorplan_association_validation(self):
        outdoor = self._create_location(type="outdoor")
        try:
            self._create_floorplan(location=outdoor)
        except ValidationError as e:
            err_str = str(e)
            self.assertIn("floorplans can only be associated to", err_str)
            self.assertIn("indoor", err_str)
        else:
            self.fail("ValidationError not raised")

    def test_geometry_if_not_mobile(self):
        try:
            self._create_location(geometry=None)
        except ValidationError as e:
            err_str = str(e)
            self.assertIn("No geometry value provided.", err_str)
        else:
            self.fail("ValidationError not raised")

    def test_geometry_if_mobile(self):
        try:
            self._create_location(is_mobile=True, geometry=None)
        except ValidationError:
            self.fail("Unexpected ValidationError raised")

    # changing location type from indoor to outdoor, deletes floorplans
    def test_location_change_indoor_to_outdoor(self):
        fl = self._create_floorplan()
        location = fl.location
        location.type = "outdoor"
        location.save()
        self.assertEqual(location.floorplan_set.count(), 0)

    # similar to 'test_location_change_indoor_to_outdoor' but with object location
    def test_object_location_change_indoor_to_outdoor(self):
        l1 = self._create_location(type="indoor")
        fl = self._create_floorplan(location=l1)
        obj = self._create_object()
        ol = self.object_location_model(
            content_object=obj, location=l1, floorplan=fl, indoor="-100,100"
        )
        ol.full_clean()
        ol.save()
        l1.type = "outdoor"
        l1.save()
        # refetching again to check if object location is updated
        ol = self.object_location_model.objects.get(pk=ol.pk)
        self.assertIsNone(ol.floorplan)
        self.assertIsNone(ol.indoor)
        self.assertEqual(ol.location.type, "outdoor")
        self.assertEqual(ol.location.floorplan_set.count(), 0)

    def _test_indoor_position_validation_error(self, ol):
        try:
            ol.full_clean()
        except ValidationError as e:
            self.assertIn("indoor", e.message_dict)
            self.assertIn("invalid value", e.message_dict["indoor"])
        else:
            self.fail("ValidationError not raised")

    def test_invalid_indoor_position(self):
        loc = self._create_location(type="indoor")
        ol = self._create_object_location(location=loc)
        ol.indoor = "TOTALLYWRONG"
        self._test_indoor_position_validation_error(ol)
        ol.indoor = "WRONG,WRONG"
        self._test_indoor_position_validation_error(ol)
        ol.indoor = "10,WRONG"
        self._test_indoor_position_validation_error(ol)
        ol.indoor = "WRONG,10"
        self._test_indoor_position_validation_error(ol)
        ol.indoor = "10,10.10,100"
        self._test_indoor_position_validation_error(ol)
        ol.indoor = "TOTALLY.WRONG"
        self._test_indoor_position_validation_error(ol)
        ol.indoor = "100.2300,-45.23454"
        ol.full_clean()
        # allow empty indoor for location whose indoor coordinates are not yet received
        ol.indoor = ""
        ol.full_clean()
        ol.save()
        ol.indoor = None
        ol.full_clean()
        ol.save()
        # outdoor allows empty but not invalid values
        loc.type = "outdoor"
        loc.full_clean()
        loc.save()
        ol.indoor = None
        ol.full_clean()
        ol.indoor = ""
        ol.full_clean()
        ol.indoor = "TOTALLY.WRONG"
        self._test_indoor_position_validation_error(ol)
        # outdoor does not allow valid indoor positions
        ol.indoor = "100.2300,-45.23454"
        self._test_indoor_position_validation_error(ol)


================================================
FILE: django_loci/tests/base/test_selenium.py
================================================
from time import sleep

from django.urls.base import reverse
from selenium.webdriver import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import Select, WebDriverWait

from openwisp_utils.tests import SeleniumTestMixin

from .. import TestAdminInlineMixin, TestLociMixin


class BaseTestDeviceAdminSelenium(
    SeleniumTestMixin, TestAdminInlineMixin, TestLociMixin
):
    app_label = "django_loci"

    def _fill_device_form(self):
        """
        This method can be extended by downstream implementations
        needing more complex logic to fill the device form
        """
        self.find_element(by=By.NAME, value="name").send_keys("11:22:33:44:55:66")

    def test_create_new_device(self):
        self.login()
        self.open(reverse(self.add_url))
        self._fill_device_form()
        select = Select(
            self.find_element(
                by=By.NAME, value=f"{self._get_prefix()}-0-location_selection"
            )
        )
        select.select_by_value("new")

        select = Select(
            self.find_element(by=By.NAME, value=f"{self._get_prefix()}-0-type")
        )
        select.select_by_value("outdoor")

        self.find_element(by=By.NAME, value=f"{self._get_prefix()}-0-name").send_keys(
            "Test Location"
        )
        # use the marker button to select location on map
        self.find_element(
            by=By.XPATH, value='//a[@class="leaflet-draw-draw-marker"]'
        ).click()
        action = ActionChains(self.web_driver)
        # place the marker on the map at a random location
        elem = self.find_element(
            by=By.ID, value=f"id_{self._get_prefix()}-0-geometry-map"
        )
        # (15, 5) is a random offset from the top left corner of the map
        action.move_to_element(elem).move_by_offset(15, 5).click().perform()
        # Wait until address field gets populated with the location marked above
        WebDriverWait(self.web_driver, 5).until(
            lambda x: x.find_element(
                by=By.XPATH, value=f'//input[@name="{self._get_prefix()}-0-address"]'
            )
            .get_attribute("value")
            .strip()
            not in ("", None)
        )

        self.find_element(by=By.NAME, value="_save").click()
        self.wait_for_presence(By.CSS_SELECTOR, ".messagelist .success")
        # device model verbose name is dynamic
        object_verbose_name = self.object_model._meta.verbose_name
        self.assertEqual(
            self.find_elements(by=By.CLASS_NAME, value="success")[0].text,
            f"The {object_verbose_name} “11:22:33:44:55:66” was added successfully.",
        )

    def test_real_time_update_address_field(self):
        location = self._create_location()
        self.login()
        url = reverse(f"admin:{self.app_label}_location_change", args=[location.id])
        self.open(url)
        # Changing the address in tab 1 should update it in tab 0 in real time without a page reload
        self.web_driver.switch_to.new_window("tab")
        tabs = self.web_driver.window_handles
        # Swtich to last tab
        self.web_driver.switch_to.window(tabs[-1])
        self.open(url)
        address_input = self.find_element(by=By.ID, value="id_address")
        self.assertEqual(address_input.get_attribute("value"), location.address)
        self.find_element(
            by=By.XPATH, value='//a[@class="leaflet-draw-draw-marker"]'
        ).click()
        elem = self.find_element(by=By.ID, value="id_geometry-map")
        # Updating the marker to a random new location
        ActionChains(self.web_driver).move_to_element(elem).move_by_offset(
            30, 15
        ).click().perform()
        alert = WebDriverWait(self.web_driver, 2).until(EC.alert_is_present())
        alert.accept()
        sleep(0.05)
        new_address = "Lazio 00185, ITA"
        address_input = self.find_element(by=By.ID, value="id_address")
        self.assertIn(new_address, address_input.get_attribute("value"))
        self.wait_for("element_to_be_clickable", by=By.NAME, value="_continue").c
Download .txt
gitextract_phsp6pke/

├── .github/
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.md
│   │   ├── feature_request.md
│   │   └── question.md
│   ├── dependabot.yml
│   ├── pull_request_template.md
│   └── workflows/
│       ├── backport.yml
│       ├── ci.yml
│       ├── pypi.yml
│       └── version-branch.yml
├── .gitignore
├── .prettierignore
├── CHANGES.rst
├── CONTRIBUTING.rst
├── LICENSE
├── MANIFEST.in
├── README.rst
├── conftest.py
├── django_loci/
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── base/
│   │   ├── __init__.py
│   │   ├── admin.py
│   │   ├── geocoding_views.py
│   │   └── models.py
│   ├── channels/
│   │   ├── __init__.py
│   │   ├── asgi.py
│   │   ├── base.py
│   │   ├── consumers.py
│   │   └── receivers.py
│   ├── fields.py
│   ├── migrations/
│   │   ├── 0001_initial.py
│   │   └── __init__.py
│   ├── models.py
│   ├── settings.py
│   ├── static/
│   │   └── django-loci/
│   │       ├── css/
│   │       │   ├── floorplan-widget.css
│   │       │   └── loci.css
│   │       └── js/
│   │           ├── floorplan-inlines.js
│   │           ├── floorplan-widget.js
│   │           └── loci.js
│   ├── storage.py
│   ├── templates/
│   │   └── admin/
│   │       ├── django_loci/
│   │       │   ├── foreign_key_raw_id.html
│   │       │   ├── location_change_form.html
│   │       │   └── location_inline.html
│   │       └── widgets/
│   │           ├── floorplan.html
│   │           ├── foreign_key_raw_id.html
│   │           └── image.html
│   ├── tests/
│   │   ├── __init__.py
│   │   ├── base/
│   │   │   ├── __init__.py
│   │   │   ├── static/
│   │   │   │   ├── test-geocode-invalid-address.json
│   │   │   │   ├── test-geocode.json
│   │   │   │   ├── test-reverse-geocode.json
│   │   │   │   └── test-reverse-location-with-no-address.json
│   │   │   ├── test_admin.py
│   │   │   ├── test_admin_inline.py
│   │   │   ├── test_apps.py
│   │   │   ├── test_channels.py
│   │   │   ├── test_models.py
│   │   │   └── test_selenium.py
│   │   ├── pytest_channels.py
│   │   ├── test_admin.py
│   │   ├── test_admin_inline.py
│   │   ├── test_apps.py
│   │   ├── test_models.py
│   │   ├── test_selenium.py
│   │   └── testdeviceapp/
│   │       ├── __init__.py
│   │       ├── admin.py
│   │       ├── migrations/
│   │       │   ├── 0001_initial.py
│   │       │   └── __init__.py
│   │       ├── models.py
│   │       ├── templates/
│   │       │   └── admin/
│   │       │       ├── location_broadcast_listener.html
│   │       │       └── testdeviceapp/
│   │       │           └── change_list.html
│   │       └── tests/
│   │           ├── __init__.py
│   │           └── test_selenium.py
│   └── widgets.py
├── docker-compose.yml
├── pyproject.toml
├── pytest.ini
├── requirements-test.txt
├── requirements.txt
├── run-qa-checks
├── runtests
├── runtests.py
├── setup.cfg
├── setup.py
└── tests/
    ├── __init__.py
    ├── manage.py
    └── openwisp2/
        ├── __init__.py
        ├── local_settings.example.py
        ├── media/
        │   └── .gitignore
        ├── settings.py
        └── urls.py
Download .txt
SYMBOL INDEX (260 symbols across 35 files)

FILE: conftest.py
  function django_db_modify_db_settings (line 5) | def django_db_modify_db_settings():

FILE: django_loci/__init__.py
  function get_version (line 5) | def get_version():

FILE: django_loci/admin.py
  class FloorPlanForm (line 15) | class FloorPlanForm(AbstractFloorPlanForm):
    class Meta (line 16) | class Meta(AbstractFloorPlanForm.Meta):
  class FloorPlanAdmin (line 20) | class FloorPlanAdmin(AbstractFloorPlanAdmin):
  class LocationForm (line 24) | class LocationForm(AbstractLocationForm):
    class Meta (line 25) | class Meta(AbstractLocationForm.Meta):
  class FloorPlanInline (line 29) | class FloorPlanInline(AbstractFloorPlanInline):
  class LocationAdmin (line 34) | class LocationAdmin(AbstractLocationAdmin):
  class ObjectLocationForm (line 39) | class ObjectLocationForm(AbstractObjectLocationForm):
    class Meta (line 40) | class Meta(AbstractObjectLocationForm.Meta):
  class ObjectLocationInline (line 44) | class ObjectLocationInline(AbstractObjectLocationInline):

FILE: django_loci/apps.py
  function test_geocoding (line 15) | def test_geocoding(app_configs=None, **kwargs):
  class LociConfig (line 29) | class LociConfig(AppConfig):
    method __setmodels__ (line 34) | def __setmodels__(self):
    method ready (line 42) | def ready(self):
    method _load_receivers (line 49) | def _load_receivers(self):

FILE: django_loci/base/admin.py
  class ReadOnlyMixin (line 25) | class ReadOnlyMixin:
    method set_readonly_attribute (line 28) | def set_readonly_attribute(self, user, fields):
  class AbstractFloorPlanForm (line 50) | class AbstractFloorPlanForm(ReadOnlyMixin, forms.ModelForm):
    class Meta (line 58) | class Meta:
    class Media (line 61) | class Media:
    method __init__ (line 64) | def __init__(self, *args, **kwargs):
  class LocationRawIdWidget (line 72) | class LocationRawIdWidget(widgets.ForeignKeyRawIdWidget):
    method url_parameters (line 79) | def url_parameters(self):
  class AbstractFloorPlanAdmin (line 85) | class AbstractFloorPlanAdmin(TimeReadonlyAdminMixin, admin.ModelAdmin):
    method get_form (line 92) | def get_form(self, request, obj=None, **kwargs):
  class AbstractLocationForm (line 105) | class AbstractLocationForm(ReadOnlyMixin, forms.ModelForm):
    class Meta (line 110) | class Meta:
    class Media (line 113) | class Media:
    method __init__ (line 122) | def __init__(self, *args, **kwargs):
  class AbstractFloorPlanInline (line 130) | class AbstractFloorPlanInline(TimeReadonlyAdminMixin, admin.StackedInline):
  class AbstractLocationAdmin (line 135) | class AbstractLocationAdmin(TimeReadonlyAdminMixin, LeafletGeoAdmin):
    method get_form (line 146) | def get_form(self, request, obj=None, **kwargs):
    method get_urls (line 151) | def get_urls(self):
    method json_view (line 179) | def json_view(self, request, pk):
    method floorplans_json_view (line 193) | def floorplans_json_view(self, request, pk):
    method get_formset_kwargs (line 209) | def get_formset_kwargs(self, request, obj, inline, prefix):
  class UnvalidatedChoiceField (line 217) | class UnvalidatedChoiceField(forms.ChoiceField):
    method validate (line 222) | def validate(self, value):
  class AbstractObjectLocationForm (line 229) | class AbstractObjectLocationForm(ReadOnlyMixin, forms.ModelForm):
    class Meta (line 274) | class Meta:
    class Media (line 277) | class Media:
    method __init__ (line 288) | def __init__(self, *args, **kwargs):
    method _get_initial_location (line 332) | def _get_initial_location(self):
    method _get_initial_floorplan (line 335) | def _get_initial_floorplan(self):
    method floorplan_model (line 339) | def floorplan_model(self):
    method location_model (line 343) | def location_model(self):
    method clean_floorplan (line 346) | def clean_floorplan(self):
    method clean (line 365) | def clean(self):
    method _get_location_instance (line 393) | def _get_location_instance(self):
    method _get_floorplan_instance (line 403) | def _get_floorplan_instance(self):
    method save (line 415) | def save(self, commit=True):
  class ObjectLocationMixin (line 433) | class ObjectLocationMixin(TimeReadonlyAdminMixin):
    method get_formset (line 478) | def get_formset(self, request, obj=..., **kwargs):
  class AbstractObjectLocationInline (line 486) | class AbstractObjectLocationInline(ObjectLocationMixin, GenericStackedIn...

FILE: django_loci/base/geocoding_views.py
  function geocode_view (line 29) | def geocode_view(request):
  function reverse_geocode_view (line 39) | def reverse_geocode_view(request):

FILE: django_loci/base/models.py
  class AbstractLocation (line 17) | class AbstractLocation(TimeStampedEditableModel):
    class Meta (line 47) | class Meta:
    method __init__ (line 51) | def __init__(self, *args, **kwargs):
    method __str__ (line 55) | def __str__(self):
    method clean (line 58) | def clean(self):
    method _validate_geometry_if_not_mobile (line 61) | def _validate_geometry_if_not_mobile(self):
    method short_type (line 70) | def short_type(self):
    method save (line 74) | def save(self, *args, **kwargs):
  class AbstractFloorPlan (line 87) | class AbstractFloorPlan(TimeStampedEditableModel):
    class Meta (line 97) | class Meta:
    method __str__ (line 101) | def __str__(self):
    method clean (line 108) | def clean(self):
    method delete (line 111) | def delete(self, *args, **kwargs):
    method _validate_location_type (line 115) | def _validate_location_type(self):
    method _remove_image (line 122) | def _remove_image(self):
  class AbstractObjectLocation (line 131) | class AbstractObjectLocation(TimeStampedEditableModel):
    class Meta (line 150) | class Meta:
    method _clean_indoor_location (line 154) | def _clean_indoor_location(self):
    method _raise_invalid_indoor (line 168) | def _raise_invalid_indoor(self):
    method _clean_indoor_position (line 171) | def _clean_indoor_position(self):
    method clean (line 208) | def clean(self):

FILE: django_loci/channels/base.py
  function _get_object_or_none (line 9) | def _get_object_or_none(model, **kwargs):
  class BaseLocationBroadcast (line 16) | class BaseLocationBroadcast(JsonWebsocketConsumer):
    method connect (line 22) | def connect(self):
    method is_authorized (line 48) | def is_authorized(self, user, location):
    method send_message (line 60) | def send_message(self, event):
    method disconnect (line 66) | def disconnect(self, close_code):
  class BaseCommonLocationBroadcast (line 78) | class BaseCommonLocationBroadcast(BaseLocationBroadcast):
    method connect (line 80) | def connect(self):
    method join_groups (line 96) | def join_groups(self, user):

FILE: django_loci/channels/consumers.py
  class LocationBroadcast (line 5) | class LocationBroadcast(BaseLocationBroadcast):
  class CommonLocationBroadcast (line 9) | class CommonLocationBroadcast(BaseCommonLocationBroadcast):

FILE: django_loci/channels/receivers.py
  function update_mobile_location (line 9) | def update_mobile_location(sender, instance, **kwargs):
  function load_location_receivers (line 47) | def load_location_receivers(sender):

FILE: django_loci/fields.py
  class GeometryField (line 6) | class GeometryField(BaseGeometryField):

FILE: django_loci/migrations/0001_initial.py
  class Migration (line 14) | class Migration(migrations.Migration):

FILE: django_loci/models.py
  class Location (line 4) | class Location(AbstractLocation):
    class Meta (line 5) | class Meta(AbstractLocation.Meta):
  class FloorPlan (line 9) | class FloorPlan(AbstractFloorPlan):
    class Meta (line 10) | class Meta(AbstractFloorPlan.Meta):
  class ObjectLocation (line 14) | class ObjectLocation(AbstractObjectLocation):
    class Meta (line 15) | class Meta(AbstractObjectLocation.Meta):

FILE: django_loci/static/django-loci/js/floorplan-widget.js
  function updateInput (line 35) | function updateInput(e) {

FILE: django_loci/static/django-loci/js/loci.js
  function getLocationJsonUrl (line 67) | function getLocationJsonUrl(pk) {
  function getLocationFloorplansJsonUrl (line 71) | function getLocationFloorplansJsonUrl(pk) {
  function getMap (line 78) | function getMap() {
  function invalidateMapSize (line 82) | function invalidateMapSize() {
  function resetOutdoorForm (line 90) | function resetOutdoorForm(keepLocationSelection) {
  function resetIndoorForm (line 107) | function resetIndoorForm(keepFloorplanSelection) {
  function resetDeviceLocationForm (line 118) | function resetDeviceLocationForm() {
  function indoorForm (line 123) | function indoorForm(selection) {
  function locationSelectionChange (line 153) | function locationSelectionChange(e, initial) {
  function isMobileChange (line 171) | function isMobileChange() {
  function typeChange (line 193) | function typeChange(e, initial) {
  function floorplanSelectionChange (line 230) | function floorplanSelectionChange(e, initial) {
  function triggerChangeOnField (line 255) | function triggerChangeOnField(win, chosenId) {
  function locationChange (line 277) | function locationChange(e, initial) {
  function listenForLocationUpdates (line 433) | function listenForLocationUpdates(pk) {
  function getMarkerFeatureGroup (line 451) | function getMarkerFeatureGroup(option) {
  function getMarker (line 462) | function getMarker() {
  function getFeatureGroup (line 467) | function getFeatureGroup() {
  function updateLatLng (line 472) | function updateLatLng(latlng) {
  function updateMapView (line 478) | function updateMapView(data) {
  function updateMap (line 486) | function updateMap() {
  function updateAdress (line 523) | function updateAdress() {
  function updateAddressOnMapChange (line 557) | function updateAddressOnMapChange() {
  function geometryListeners (line 572) | function geometryListeners() {

FILE: django_loci/storage.py
  class OverwriteMixin (line 4) | class OverwriteMixin:
    method upload_to (line 8) | def upload_to(cls, instance, filename):
    method get_available_name (line 16) | def get_available_name(self, name, max_length=None):
  class OverwriteStorage (line 25) | class OverwriteStorage(OverwriteMixin, FileSystemStorage):

FILE: django_loci/tests/__init__.py
  class TestLociMixin (line 16) | class TestLociMixin(object):
    method tearDown (line 20) | def tearDown(self):
    method _create_object (line 27) | def _create_object(self, **kwargs):
    method _create_location (line 31) | def _create_location(self, **kwargs):
    method _get_simpleuploadedfile (line 44) | def _get_simpleuploadedfile(self):
    method _create_floorplan (line 51) | def _create_floorplan(self, **kwargs):
    method _create_object_location (line 63) | def _create_object_location(self, **kwargs):
  class TestAdminMixin (line 78) | class TestAdminMixin(object):
    method url_prefix (line 80) | def url_prefix(self):
    method object_url_prefix (line 84) | def object_url_prefix(self):
    method _create_admin (line 87) | def _create_admin(self, **kwargs):
    method _login_as_admin (line 98) | def _login_as_admin(self):
    method _create_readonly_admin (line 103) | def _create_readonly_admin(self, **kwargs):
    method _load_content (line 118) | def _load_content(self, file):
  class TestAdminInlineMixin (line 124) | class TestAdminInlineMixin(TestAdminMixin):
    method _get_prefix (line 126) | def _get_prefix(cls):
    method _get_url_prefix (line 133) | def _get_url_prefix(self):
    method add_url (line 139) | def add_url(self):
    method change_url (line 143) | def change_url(self):
  class TestChannelsMixin (line 147) | class TestChannelsMixin(object):
    method _force_login (line 149) | async def _force_login(self, user, backend=None):
    method _get_location_request_dict (line 157) | async def _get_location_request_dict(self, path, pk=None, user=None):
    method _get_specific_location_request_dict (line 171) | async def _get_specific_location_request_dict(self, pk=None, user=None):
    method _get_common_location_request_dict (line 178) | async def _get_common_location_request_dict(self, pk=None, user=None):
    method _get_location_communicator (line 183) | def _get_location_communicator(
    method _get_specific_location_communicator (line 197) | def _get_specific_location_communicator(self, request_vars, user=None):
    method _get_common_location_communicator (line 205) | def _get_common_location_communicator(self, request_vars, user=None):
    method _save_location (line 213) | async def _save_location(self, pk):

FILE: django_loci/tests/base/test_admin.py
  class BaseTestAdmin (line 11) | class BaseTestAdmin(TestAdminMixin, TestLociMixin):
    method test_location_list (line 16) | def test_location_list(self):
    method test_floorplan_list (line 23) | def test_floorplan_list(self):
    method test_location_json_view (line 31) | def test_location_json_view(self):
    method test_location_floorplan_json_view (line 44) | def test_location_floorplan_json_view(self):
    method test_location_change_image_removed (line 64) | def test_location_change_image_removed(self):
    method test_floorplan_change_image_removed (line 74) | def test_floorplan_change_image_removed(self):
    method test_floorplan_add_view_filters_indoor_location (line 84) | def test_floorplan_add_view_filters_indoor_location(self):
    method test_is_mobile_location_json_view (line 113) | def test_is_mobile_location_json_view(self):
    method test_geocode (line 140) | def test_geocode(self):
    method test_geocode_no_address (line 160) | def test_geocode_no_address(self):
    method test_geocode_invalid_address (line 169) | def test_geocode_invalid_address(self):
    method test_reverse_geocode (line 188) | def test_reverse_geocode(self):
    method test_reverse_location_with_no_address (line 207) | def test_reverse_location_with_no_address(self):
    method test_reverse_geocode_no_coords (line 227) | def test_reverse_geocode_no_coords(self):
    method _get_location_add_params (line 235) | def _get_location_add_params(self, **kwargs):
    method test_add_mobile_location (line 251) | def test_add_mobile_location(self):
    method test_add_non_mobile_location_without_geometry (line 259) | def test_add_non_mobile_location_without_geometry(self):
    method test_readonly_floorplans (line 269) | def test_readonly_floorplans(self):

FILE: django_loci/tests/base/test_admin_inline.py
  class BaseTestAdminInline (line 10) | class BaseTestAdminInline(TestAdminInlineMixin, TestLociMixin):
    method _get_params (line 14) | def _get_params(cls):
    method params (line 30) | def params(self):
    method test_json_urls (line 33) | def test_json_urls(self):
    method test_add_outdoor_new (line 44) | def test_add_outdoor_new(self):
    method test_add_outdoor_existing (line 74) | def test_add_outdoor_existing(self):
    method test_change_outdoor (line 107) | def test_change_outdoor(self):
    method test_change_outdoor_to_different_location (line 149) | def test_change_outdoor_to_different_location(self):
    method test_add_indoor_new_location_new_floorplan (line 195) | def test_add_indoor_new_location_new_floorplan(self):
    method test_add_indoor_existing_location_new_floorplan (line 230) | def test_add_indoor_existing_location_new_floorplan(self):
    method test_add_indoor_existing_location_existing_floorplan (line 272) | def test_add_indoor_existing_location_existing_floorplan(self):
    method test_change_indoor (line 315) | def test_change_indoor(self):
    method test_change_indoor_missing_indoor_position (line 369) | def test_change_indoor_missing_indoor_position(self):
    method test_add_outdoor_invalid (line 402) | def test_add_outdoor_invalid(self):
    method test_add_outdoor_invalid_geometry (line 422) | def test_add_outdoor_invalid_geometry(self):
    method test_add_mobile (line 438) | def test_add_mobile(self):
    method test_change_mobile (line 464) | def test_change_mobile(self):
    method test_remove_mobile (line 497) | def test_remove_mobile(self):
    method test_change_indoor_missing_floorplan_pk (line 526) | def test_change_indoor_missing_floorplan_pk(self):
    method test_change_indoor_floorplan_doesnotexist (line 562) | def test_change_indoor_floorplan_doesnotexist(self):
    method test_change_indoor_floorplan_different_location (line 598) | def test_change_indoor_floorplan_different_location(self):
    method test_missing_type_error (line 633) | def test_missing_type_error(self):
    method test_add_indoor_location_without_indoor_coords (line 654) | def test_add_indoor_location_without_indoor_coords(self):
    method test_add_indoor_mobile_location_without_floor (line 684) | def test_add_indoor_mobile_location_without_floor(self):
    method test_add_indoor_location_without_coords (line 708) | def test_add_indoor_location_without_coords(self):
    method test_add_indoor_location_without_floor (line 731) | def test_add_indoor_location_without_floor(self):
    method test_add_outdoor_with_floorplan (line 752) | def test_add_outdoor_with_floorplan(self):
    method test_device_change_location_from_outdoor_to_indoor (line 785) | def test_device_change_location_from_outdoor_to_indoor(self):
    method test_device_change_location_from_indoor_to_outdoor (line 830) | def test_device_change_location_from_indoor_to_outdoor(self):
    method test_readonly_indoor_location (line 870) | def test_readonly_indoor_location(self):
    method test_readonly_indoor_object_location (line 892) | def test_readonly_indoor_object_location(self):

FILE: django_loci/tests/base/test_apps.py
  class BaseTestApps (line 10) | class BaseTestApps(TestLociMixin):
    method test_geocode_strict (line 13) | def test_geocode_strict(self, geocode_mocked):

FILE: django_loci/tests/base/test_channels.py
  class BaseTestChannels (line 13) | class BaseTestChannels(TestAdminMixin, TestLociMixin, TestChannelsMixin):
    method test_object_or_none (line 23) | def test_object_or_none(self):
    method test_consumer_unauthenticated (line 32) | async def test_consumer_unauthenticated(self):
    method test_common_location_consumer_unauthenticated (line 41) | async def test_common_location_consumer_unauthenticated(self):
    method test_connect_admin (line 50) | async def test_connect_admin(self):
    method test_common_location_connect_admin (line 60) | async def test_common_location_connect_admin(self):
    method test_consumer_not_staff (line 70) | async def test_consumer_not_staff(self):
    method test_common_location_consumer_not_staff (line 82) | async def test_common_location_consumer_not_staff(self):
    method test_consumer_404 (line 94) | async def test_consumer_404(self):
    method test_consumer_staff_but_no_change_permission (line 104) | async def test_consumer_staff_but_no_change_permission(self):
    method test_common_location_consumer_staff_but_no_change_permission (line 134) | async def test_common_location_consumer_staff_but_no_change_permission...
    method test_location_update (line 158) | async def test_location_update(self):
    method test_common_location_update (line 174) | async def test_common_location_update(self):
    method test_routing (line 208) | def test_routing(self):

FILE: django_loci/tests/base/test_models.py
  class BaseTestModels (line 6) | class BaseTestModels(TestLociMixin):
    method test_location_str (line 7) | def test_location_str(self):
    method test_floorplan_str (line 11) | def test_floorplan_str(self):
    method test_object_location_clean_location (line 18) | def test_object_location_clean_location(self):
    method test_floorplan_image (line 35) | def test_floorplan_image(self):
    method test_floorplan_delete_corner_case (line 56) | def test_floorplan_delete_corner_case(self):
    method test_floorplan_association_validation (line 62) | def test_floorplan_association_validation(self):
    method test_geometry_if_not_mobile (line 73) | def test_geometry_if_not_mobile(self):
    method test_geometry_if_mobile (line 82) | def test_geometry_if_mobile(self):
    method test_location_change_indoor_to_outdoor (line 89) | def test_location_change_indoor_to_outdoor(self):
    method test_object_location_change_indoor_to_outdoor (line 97) | def test_object_location_change_indoor_to_outdoor(self):
    method _test_indoor_position_validation_error (line 115) | def _test_indoor_position_validation_error(self, ol):
    method test_invalid_indoor_position (line 124) | def test_invalid_indoor_position(self):

FILE: django_loci/tests/base/test_selenium.py
  class BaseTestDeviceAdminSelenium (line 14) | class BaseTestDeviceAdminSelenium(
    method _fill_device_form (line 19) | def _fill_device_form(self):
    method test_create_new_device (line 26) | def test_create_new_device(self):
    method test_real_time_update_address_field (line 75) | def test_real_time_update_address_field(self):

FILE: django_loci/tests/pytest_channels.py
  class TestChannels (line 8) | class TestChannels(BaseTestChannels):

FILE: django_loci/tests/test_admin.py
  class TestAdmin (line 9) | class TestAdmin(BaseTestAdmin, TestCase):

FILE: django_loci/tests/test_admin_inline.py
  class TestAdminInline (line 9) | class TestAdminInline(BaseTestAdminInline, TestCase):

FILE: django_loci/tests/test_apps.py
  class TestApps (line 6) | class TestApps(BaseTestApps, TestCase):

FILE: django_loci/tests/test_models.py
  class TestModels (line 8) | class TestModels(BaseTestModels, TestCase):

FILE: django_loci/tests/test_selenium.py
  class TestDeviceAdminSelenium (line 11) | class TestDeviceAdminSelenium(BaseTestDeviceAdminSelenium, ChannelsLiveS...

FILE: django_loci/tests/testdeviceapp/admin.py
  class DeviceAdmin (line 11) | class DeviceAdmin(TimeReadonlyAdminMixin, admin.ModelAdmin):
    method get_urls (line 16) | def get_urls(self):
    method location_broadcast_listener (line 27) | def location_broadcast_listener(self, request):

FILE: django_loci/tests/testdeviceapp/migrations/0001_initial.py
  class Migration (line 10) | class Migration(migrations.Migration):

FILE: django_loci/tests/testdeviceapp/models.py
  class Device (line 7) | class Device(TimeStampedEditableModel):
    method __str__ (line 10) | def __str__(self):

FILE: django_loci/tests/testdeviceapp/tests/test_selenium.py
  class TestCommonLocationWebsocket (line 15) | class TestCommonLocationWebsocket(
    method test_common_location_broadcast_ws (line 22) | def test_common_location_broadcast_ws(self):

FILE: django_loci/widgets.py
  class ImageWidget (line 9) | class ImageWidget(forms.FileInput):
    method __init__ (line 18) | def __init__(self, *args, **kwargs):
    method get_context (line 22) | def get_context(self, name, value, attrs):
  class FloorPlanWidget (line 36) | class FloorPlanWidget(forms.TextInput):
  class LeafletWidget (line 44) | class LeafletWidget(BaseLeafletWidget):

FILE: setup.py
  function get_install_requires (line 7) | def get_install_requires():
Condensed preview — 92 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (227K chars).
[
  {
    "path": ".github/FUNDING.yml",
    "chars": 820,
    "preview": "# These are supported funding model platforms\n\ngithub: [openwisp]\npatreon: # Replace with a single Patreon username\nopen"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "chars": 689,
    "preview": "---\nname: Bug report\nabout: Open a bug report\ntitle: \"[bug] \"\nlabels: bug\nassignees: \"\"\n---\n\n**Describe the bug**\nA clea"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "chars": 613,
    "preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: \"[feature] \"\nlabels: enhancement\nassignees: \"\"\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/question.md",
    "chars": 322,
    "preview": "---\nname: Question\nabout: Please use the Discussion Forum to ask questions\ntitle: \"[question] \"\nlabels: question\nassigne"
  },
  {
    "path": ".github/dependabot.yml",
    "chars": 828,
    "preview": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where "
  },
  {
    "path": ".github/pull_request_template.md",
    "chars": 643,
    "preview": "## Checklist\n\n- [ ] I have read the [OpenWISP Contributing Guidelines](http://openwisp.io/docs/developer/contributing.ht"
  },
  {
    "path": ".github/workflows/backport.yml",
    "chars": 1288,
    "preview": "name: Backport fixes to stable branch\n\non:\n  push:\n    branches:\n      - master\n      - main\n  issue_comment:\n    types:"
  },
  {
    "path": ".github/workflows/ci.yml",
    "chars": 3109,
    "preview": "name: Django Loci Build\n\non:\n  push:\n    branches:\n      - master\n      - \"1.2\"\n  pull_request:\n    branches:\n      - ma"
  },
  {
    "path": ".github/workflows/pypi.yml",
    "chars": 746,
    "preview": "name: Publish Python Package to Pypi.org\n\non:\n  release:\n    types: [published]\n\npermissions:\n  id-token: write\n\njobs:\n "
  },
  {
    "path": ".github/workflows/version-branch.yml",
    "chars": 237,
    "preview": "name: Replicate Commits to Version Branch\n\non:\n  push:\n    branches:\n      - master\n\njobs:\n  version-branch:\n    uses: o"
  },
  {
    "path": ".gitignore",
    "chars": 794,
    "preview": "# Byte-compiled / optimized / DLL files\n__pycache__/\n.pytest_cache/\n*.py[cod]\n\n# C extensions\n*.so\n\n# Distribution / pac"
  },
  {
    "path": ".prettierignore",
    "chars": 19,
    "preview": "*.min.js\n*.min.css\n"
  },
  {
    "path": "CHANGES.rst",
    "chars": 7505,
    "preview": "Changelog\n=========\n\nVersion 1.3.0 [unreleased]\n--------------------------\n\nWork in progress.\n\nVersion 1.2.2 [2026-04-16"
  },
  {
    "path": "CONTRIBUTING.rst",
    "chars": 119,
    "preview": "Please refer to the `OpenWISP Contribution Guidelines\n<https://openwisp.io/docs/stable/developer/contributing.html>`_.\n"
  },
  {
    "path": "LICENSE",
    "chars": 1495,
    "preview": "Copyright (c) 2017, Federico Capoano\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or wi"
  },
  {
    "path": "MANIFEST.in",
    "chars": 259,
    "preview": "include LICENSE\ninclude README.rst\ninclude CHANGES.rst\ninclude requirements.txt\nrecursive-include django_loci *\nrecursiv"
  },
  {
    "path": "README.rst",
    "chars": 15294,
    "preview": "django-loci\n===========\n\n.. image:: https://github.com/openwisp/django-loci/actions/workflows/ci.yml/badge.svg\n    :targ"
  },
  {
    "path": "conftest.py",
    "chars": 140,
    "preview": "import pytest\n\n\n@pytest.fixture(scope=\"session\")\ndef django_db_modify_db_settings():\n    \"\"\"used to speed up pytest with"
  },
  {
    "path": "django_loci/__init__.py",
    "chars": 511,
    "preview": "VERSION = (1, 3, 0, \"alpha\")\n__version__ = VERSION  # alias\n\n\ndef get_version():\n    version = \"%s.%s\" % (VERSION[0], VE"
  },
  {
    "path": "django_loci/admin.py",
    "chars": 1168,
    "preview": "from django.contrib import admin\n\nfrom .base.admin import (\n    AbstractFloorPlanAdmin,\n    AbstractFloorPlanForm,\n    A"
  },
  {
    "path": "django_loci/apps.py",
    "chars": 1422,
    "preview": "import logging\n\nfrom django.apps import AppConfig\nfrom django.conf import settings\nfrom django.core.checks import Warnin"
  },
  {
    "path": "django_loci/base/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "django_loci/base/admin.py",
    "chars": 17887,
    "preview": "import json\nfrom functools import partialmethod\n\nfrom django import forms\nfrom django.contrib import admin\nfrom django.c"
  },
  {
    "path": "django_loci/base/geocoding_views.py",
    "chars": 1828,
    "preview": "from django.http import JsonResponse\nfrom django.utils.module_loading import import_string\nfrom geopy.extra.rate_limiter"
  },
  {
    "path": "django_loci/base/models.py",
    "chars": 7145,
    "preview": "import logging\n\nfrom django.contrib.contenttypes.fields import GenericForeignKey\nfrom django.contrib.contenttypes.models"
  },
  {
    "path": "django_loci/channels/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "django_loci/channels/asgi.py",
    "chars": 1190,
    "preview": "from channels.auth import AuthMiddlewareStack\nfrom channels.routing import ProtocolTypeRouter, URLRouter\nfrom channels.s"
  },
  {
    "path": "django_loci/channels/base.py",
    "chars": 3658,
    "preview": "from asgiref.sync import async_to_sync\nfrom channels.generic.websocket import JsonWebsocketConsumer\nfrom django.core.exc"
  },
  {
    "path": "django_loci/channels/consumers.py",
    "chars": 253,
    "preview": "from ..models import Location\nfrom .base import BaseCommonLocationBroadcast, BaseLocationBroadcast\n\n\nclass LocationBroad"
  },
  {
    "path": "django_loci/channels/receivers.py",
    "chars": 1881,
    "preview": "import json\n\nimport channels.layers\nfrom asgiref.sync import async_to_sync\nfrom django.db.models.signals import post_sav"
  },
  {
    "path": "django_loci/fields.py",
    "chars": 173,
    "preview": "from leaflet.forms.fields import GeometryField as BaseGeometryField\n\nfrom .widgets import LeafletWidget\n\n\nclass Geometry"
  },
  {
    "path": "django_loci/migrations/0001_initial.py",
    "chars": 7667,
    "preview": "# -*- coding: utf-8 -*-\n# Generated by Django 1.11.7 on 2017-11-25 10:09\nimport uuid\n\nimport django.contrib.gis.db.model"
  },
  {
    "path": "django_loci/migrations/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "django_loci/models.py",
    "chars": 406,
    "preview": "from .base.models import AbstractFloorPlan, AbstractLocation, AbstractObjectLocation\n\n\nclass Location(AbstractLocation):"
  },
  {
    "path": "django_loci/settings.py",
    "chars": 792,
    "preview": "from django.conf import settings\nfrom django.core.exceptions import ImproperlyConfigured\nfrom django.utils.module_loadin"
  },
  {
    "path": "django_loci/static/django-loci/css/floorplan-widget.css",
    "chars": 390,
    "preview": ".floorplan-raw {\n  display: none;\n}\n.floorplan-widget {\n  width: 100%;\n  height: 500px;\n  padding: 0;\n  margin: 0;\n  bac"
  },
  {
    "path": "django_loci/static/django-loci/css/loci.css",
    "chars": 1937,
    "preview": ".coords,\n.coords .form-row {\n  display: none;\n}\n.coords .field-location_selection,\n.coords .field-floorplan_selection {\n"
  },
  {
    "path": "django_loci/static/django-loci/js/floorplan-inlines.js",
    "chars": 1388,
    "preview": "django.jQuery(function ($) {\n  \"use strict\";\n  var $type_field = $(\"#id_type\"),\n    $floorplan_set = $(\"#floorplan_set-g"
  },
  {
    "path": "django_loci/static/django-loci/js/floorplan-widget.js",
    "chars": 2135,
    "preview": "(function () {\n  \"use strict\";\n  django.loadFloorPlan = function (widgetName, imageUrl, imageW, imageH) {\n    var $input"
  },
  {
    "path": "django_loci/static/django-loci/js/loci.js",
    "chars": 20144,
    "preview": "/*global\nalert, confirm, console, Debug, opera, prompt, WSH\n*/\n/*\nthis JS is shared between:\n    - DeviceLocationForm\n  "
  },
  {
    "path": "django_loci/storage.py",
    "chars": 786,
    "preview": "from django.core.files.storage import FileSystemStorage\n\n\nclass OverwriteMixin:\n    floorplan_upload_dir = \"floorplans\"\n"
  },
  {
    "path": "django_loci/templates/admin/django_loci/foreign_key_raw_id.html",
    "chars": 442,
    "preview": "{% include 'django/forms/widgets/input.html' %}{% load i18n %}\n{% if related_url %}\n    <a href=\"{{ related_url }}\"\n    "
  },
  {
    "path": "django_loci/templates/admin/django_loci/location_change_form.html",
    "chars": 556,
    "preview": "{% extends \"admin/change_form.html\" %}\n{% block content %}\n{{ block.super }}\n{% comment %}\n    We use django to generate"
  },
  {
    "path": "django_loci/templates/admin/django_loci/location_inline.html",
    "chars": 817,
    "preview": "{% include \"admin/edit_inline/stacked.html\" %}\n{% comment %}\n    We use django to generate URLs that are then\n    read b"
  },
  {
    "path": "django_loci/templates/admin/widgets/floorplan.html",
    "chars": 386,
    "preview": "<div id=\"id_{{ widget.name }}_map\" class=\"floorplan-widget\"></div>\n<div id=\"id_{{ widget.name }}_raw\" class=\"floorplan-r"
  },
  {
    "path": "django_loci/templates/admin/widgets/foreign_key_raw_id.html",
    "chars": 58,
    "preview": "{% include 'admin/django_loci/foreign_key_raw_id.html' %}\n"
  },
  {
    "path": "django_loci/templates/admin/widgets/image.html",
    "chars": 479,
    "preview": "{% load i18n %}\n{% if url %}\n<a target=\"_blank\"\n   href=\"{{ url }}\"\n   class=\"floorplan-image\">\n    {% if thumbnail %}\n "
  },
  {
    "path": "django_loci/tests/__init__.py",
    "chars": 7248,
    "preview": "\"\"\"\nReusable test helpers\n\"\"\"\n\nimport importlib\nimport os\n\nfrom channels.db import database_sync_to_async\nfrom channels."
  },
  {
    "path": "django_loci/tests/base/__init__.py",
    "chars": 55,
    "preview": "# pytest_*.py files in this folder can run via pytest.\n"
  },
  {
    "path": "django_loci/tests/base/static/test-geocode-invalid-address.json",
    "chars": 93,
    "preview": "{\n  \"spatialReference\": {\n    \"wkid\": 4326,\n    \"latestWkid\": 4326\n  },\n  \"candidates\": []\n}\n"
  },
  {
    "path": "django_loci/tests/base/static/test-geocode.json",
    "chars": 444,
    "preview": "{\n  \"spatialReference\": {\n    \"wkid\": 4326,\n    \"latestWkid\": 4326\n  },\n  \"candidates\": [\n    {\n      \"address\": \"Red Sq"
  },
  {
    "path": "django_loci/tests/base/static/test-reverse-geocode.json",
    "chars": 632,
    "preview": "{\n  \"address\": {\n    \"Match_addr\": \"05-500\",\n    \"LongLabel\": \"05-500, POL\",\n    \"ShortLabel\": \"05-500\",\n    \"Addr_type\""
  },
  {
    "path": "django_loci/tests/base/static/test-reverse-location-with-no-address.json",
    "chars": 174,
    "preview": "{\n  \"error\": {\n    \"code\": 400,\n    \"message\": \"Cannot perform query. Invalid query parameters.\",\n    \"details\": [\"Unabl"
  },
  {
    "path": "django_loci/tests/base/test_admin.py",
    "chars": 11180,
    "preview": "import json\n\nimport responses\nfrom django.contrib.auth.models import Permission\nfrom django.contrib.humanize.templatetag"
  },
  {
    "path": "django_loci/tests/base/test_admin_inline.py",
    "chars": 40863,
    "preview": "from django.contrib.auth.models import Permission\nfrom django.contrib.gis.geos import GEOSGeometry\nfrom django.contrib.h"
  },
  {
    "path": "django_loci/tests/base/test_apps.py",
    "chars": 663,
    "preview": "from unittest.mock import patch\n\nfrom django.conf import settings\nfrom django.core.checks import Warning\n\nfrom ...apps i"
  },
  {
    "path": "django_loci/tests/base/test_channels.py",
    "chars": 9772,
    "preview": "# use pytest\nimport pytest\nfrom channels.db import database_sync_to_async\nfrom channels.routing import ProtocolTypeRoute"
  },
  {
    "path": "django_loci/tests/base/test_models.py",
    "chars": 5969,
    "preview": "from django.core.exceptions import ValidationError\n\nfrom .. import TestLociMixin\n\n\nclass BaseTestModels(TestLociMixin):\n"
  },
  {
    "path": "django_loci/tests/base/test_selenium.py",
    "chars": 4636,
    "preview": "from time import sleep\n\nfrom django.urls.base import reverse\nfrom selenium.webdriver import ActionChains\nfrom selenium.w"
  },
  {
    "path": "django_loci/tests/pytest_channels.py",
    "chars": 357,
    "preview": "from django.contrib.auth import get_user_model\n\nfrom ..models import Location, ObjectLocation\nfrom .base.test_channels i"
  },
  {
    "path": "django_loci/tests/test_admin.py",
    "chars": 431,
    "preview": "from django.contrib.auth import get_user_model\nfrom django.test import TestCase\n\nfrom ..models import FloorPlan, Locatio"
  },
  {
    "path": "django_loci/tests/test_admin_inline.py",
    "chars": 456,
    "preview": "from django.contrib.auth import get_user_model\nfrom django.test import TestCase\n\nfrom ..models import FloorPlan, Locatio"
  },
  {
    "path": "django_loci/tests/test_apps.py",
    "chars": 126,
    "preview": "from django.test import TestCase\n\nfrom .base.test_apps import BaseTestApps\n\n\nclass TestApps(BaseTestApps, TestCase):\n   "
  },
  {
    "path": "django_loci/tests/test_models.py",
    "chars": 354,
    "preview": "from django.test import TestCase\n\nfrom ..models import FloorPlan, Location, ObjectLocation\nfrom .base.test_models import"
  },
  {
    "path": "django_loci/tests/test_selenium.py",
    "chars": 525,
    "preview": "from channels.testing import ChannelsLiveServerTestCase\nfrom django.contrib.auth import get_user_model\nfrom django.test "
  },
  {
    "path": "django_loci/tests/testdeviceapp/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "django_loci/tests/testdeviceapp/admin.py",
    "chars": 1018,
    "preview": "from django.contrib import admin\nfrom django.shortcuts import render\nfrom django.urls import path\n\nfrom django_loci.admi"
  },
  {
    "path": "django_loci/tests/testdeviceapp/migrations/0001_initial.py",
    "chars": 1405,
    "preview": "# -*- coding: utf-8 -*-\n# Generated by Django 1.11.5 on 2017-11-14 10:36\nimport uuid\n\nimport django.utils.timezone\nimpor"
  },
  {
    "path": "django_loci/tests/testdeviceapp/migrations/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "django_loci/tests/testdeviceapp/models.py",
    "chars": 287,
    "preview": "from django.db import models\nfrom django.utils.translation import gettext_lazy as _\n\nfrom openwisp_utils.base import Tim"
  },
  {
    "path": "django_loci/tests/testdeviceapp/templates/admin/location_broadcast_listener.html",
    "chars": 1235,
    "preview": "{% extends \"admin/base_site.html\" %}\n\n{% load static %}\n{% load i18n %}\n\n{% block content %}\n<p class=\"hidden\" id=\"ws-co"
  },
  {
    "path": "django_loci/tests/testdeviceapp/templates/admin/testdeviceapp/change_list.html",
    "chars": 271,
    "preview": "{% extends \"admin/change_list.html\" %}\n{% load i18n %}\n\n{% block object-tools-items %}\n    <li>\n        <a href=\"{% url "
  },
  {
    "path": "django_loci/tests/testdeviceapp/tests/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "django_loci/tests/testdeviceapp/tests/test_selenium.py",
    "chars": 2164,
    "preview": "from channels.testing import ChannelsLiveServerTestCase\nfrom django.contrib.auth import get_user_model\nfrom django.test "
  },
  {
    "path": "django_loci/widgets.py",
    "chars": 1413,
    "preview": "import logging\n\nfrom django import forms\nfrom leaflet.admin import LeafletAdminWidget as BaseLeafletWidget\n\nlogger = log"
  },
  {
    "path": "docker-compose.yml",
    "chars": 126,
    "preview": "---\nservices:\n  redis:\n    image: redis:8-alpine\n    ports:\n      - \"6379:6379\"\n    entrypoint: redis-server --appendonl"
  },
  {
    "path": "pyproject.toml",
    "chars": 830,
    "preview": "[tool.coverage.run]\nsource = [\"django_loci\"]\nparallel = true\n# To ensure correct coverage, we need to include both\n# \"mu"
  },
  {
    "path": "pytest.ini",
    "chars": 146,
    "preview": "[pytest]\naddopts = --create-db --reuse-db --nomigrations django_loci/tests\nDJANGO_SETTINGS_MODULE = openwisp2.settings\np"
  },
  {
    "path": "requirements-test.txt",
    "chars": 162,
    "preview": "pytest-cov~=7.1.0\nresponses~=0.26.0\nopenwisp-utils[qa,selenium,channels,channels-test] @ https://github.com/openwisp/ope"
  },
  {
    "path": "requirements.txt",
    "chars": 180,
    "preview": "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://githu"
  },
  {
    "path": "run-qa-checks",
    "chars": 224,
    "preview": "#!/bin/bash\nset -e\nopenwisp-qa-check \\\n    --migration-path \\\n        \"./django_loci/migrations \\\n        ./django_loci/"
  },
  {
    "path": "runtests",
    "chars": 178,
    "preview": "#!/bin/bash\nset -e\n\ncoverage run runtests.py --parallel --exclude-tag=selenium_tests\ncoverage run runtests.py --tag=sele"
  },
  {
    "path": "runtests.py",
    "chars": 721,
    "preview": "#!/usr/bin/env python\n# -*- coding: utf-8 -*-\n\nimport os\nimport sys\n\nsys.path.insert(0, \"tests\")\nos.environ.setdefault(\""
  },
  {
    "path": "setup.cfg",
    "chars": 292,
    "preview": "[bdist_wheel]\nuniversal=1\n\n[flake8]\nexclude = migrations,\n\t  ./tests/*settings*.py\nmax-line-length = 110\n# W503: line br"
  },
  {
    "path": "setup.py",
    "chars": 1641,
    "preview": "#!/usr/bin/env python\nfrom setuptools import find_packages, setup\n\nfrom django_loci import get_version\n\n\ndef get_install"
  },
  {
    "path": "tests/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "tests/manage.py",
    "chars": 252,
    "preview": "#!/usr/bin/env python\nimport os\nimport sys\n\nif __name__ == \"__main__\":\n    os.environ.setdefault(\"DJANGO_SETTINGS_MODULE"
  },
  {
    "path": "tests/openwisp2/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "tests/openwisp2/local_settings.example.py",
    "chars": 319,
    "preview": "# RENAME THIS FILE TO local_settings.py IF YOU NEED TO CUSTOMIZE SOME SETTINGS\n# BUT DO NOT COMMIT\n\n# DATABASES = {\n#   "
  },
  {
    "path": "tests/openwisp2/media/.gitignore",
    "chars": 30,
    "preview": "*\n!.gitignore\n!.floorplan.jpg\n"
  },
  {
    "path": "tests/openwisp2/settings.py",
    "chars": 2927,
    "preview": "import os\nimport sys\n\nBASE_DIR = os.path.dirname(os.path.abspath(__file__))\nTESTING = \"test\" in sys.argv\n\nDEBUG = True\n\n"
  },
  {
    "path": "tests/openwisp2/urls.py",
    "chars": 548,
    "preview": "from django.conf import settings\nfrom django.conf.urls.static import static\nfrom django.contrib import admin\nfrom django"
  }
]

About this extraction

This page contains the full source code of the openwisp/django-loci GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 92 files (205.8 KB), approximately 50.1k tokens, and a symbol index with 260 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!