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
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
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.