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 #. 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 `_ Version 1.2.1 [2026-03-27] -------------------------- Bugfixes ~~~~~~~~ - Fixed the width of Leaflet control labels in the map UI `#200 `_. - Fixed an issue preventing creation of mobile locations via the Django admin `#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 `_. 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 `_ when the user only has view permissions. - `Fixed error when changing a location from indoor to outdoor `_. 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 `_ 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 `_ - Use ``ReconnectingWebsocket`` to websocket connection `#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 `_) 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 `_. ================================================ 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 `_) - 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 `_: - `Geospatial libraries `_ - `Spatial database `_, for development we use Spatialite, a spatial extension of `sqlite `_ 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:/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 `_. 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 `_. Issues with other geospatial libraries ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Please refer to the `geodjango documentation on troubleshooting issues related to geospatial libraries `_. 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 `_. ``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//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 `_ installed locally first): .. code-block:: shell ./runtests Contributing ------------ Please refer to the `OpenWISP Contribution Guidelines `_. Questions --------- See `Github Discussions `_. Changelog --------- See `CHANGES `_. License ------- See `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( "/json/", self.admin_site.admin_view(self.json_view), name="{0}_location_json".format(app_label), ), path( "/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//" 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 = $("") .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('
' + msg + "
"); $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 %} {% trans 'Select item' %} {% endif %} {% if link_label %} {% if link_url %}{% endif %} {{ link_label }} {% if link_url %}{% endif %} {% endif %} ================================================ 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 %} {% 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 %} ================================================ FILE: django_loci/templates/admin/widgets/floorplan.html ================================================
{% include "django/forms/widgets/input.html" %}
{% if '__prefix__' not in widget.name %} {% 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 %} {% if thumbnail %} {% else %} {% trans 'Currently' %}: {{ filename }} {% endif %}

{% endif %} {% include "django/forms/widgets/input.html" %} {% if width and height %} {% endif %} {% if url %}

{% 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""" Select item """, 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}") self.assertNotContains(r2, f"{loc_outdoor.name}") 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").click() # Close tab[1] so other tests are not affected self.web_driver.close() # on some systems the zero tab may be an empty tab # hence we open the tab before the last one initial_tab = tabs.index(tabs[-1]) - 1 self.web_driver.switch_to.window(tabs[initial_tab]) address_input = self.find_element(by=By.ID, value="id_address") self.assertIn(new_address, address_input.get_attribute("value")) ================================================ FILE: django_loci/tests/pytest_channels.py ================================================ from django.contrib.auth import get_user_model from ..models import Location, ObjectLocation from .base.test_channels import BaseTestChannels from .testdeviceapp.models import Device class TestChannels(BaseTestChannels): object_model = Device location_model = Location object_location_model = ObjectLocation user_model = get_user_model() ================================================ FILE: django_loci/tests/test_admin.py ================================================ from django.contrib.auth import get_user_model from django.test import TestCase from ..models import FloorPlan, Location, ObjectLocation from .base.test_admin import BaseTestAdmin from .testdeviceapp.models import Device class TestAdmin(BaseTestAdmin, TestCase): object_model = Device location_model = Location floorplan_model = FloorPlan object_location_model = ObjectLocation user_model = get_user_model() ================================================ FILE: django_loci/tests/test_admin_inline.py ================================================ from django.contrib.auth import get_user_model from django.test import TestCase from ..models import FloorPlan, Location, ObjectLocation from .base.test_admin_inline import BaseTestAdminInline from .testdeviceapp.models import Device class TestAdminInline(BaseTestAdminInline, TestCase): object_model = Device location_model = Location floorplan_model = FloorPlan object_location_model = ObjectLocation user_model = get_user_model() ================================================ FILE: django_loci/tests/test_apps.py ================================================ from django.test import TestCase from .base.test_apps import BaseTestApps class TestApps(BaseTestApps, TestCase): pass ================================================ FILE: django_loci/tests/test_models.py ================================================ from django.test import TestCase from ..models import FloorPlan, Location, ObjectLocation from .base.test_models import BaseTestModels from .testdeviceapp.models import Device class TestModels(BaseTestModels, TestCase): object_model = Device location_model = Location floorplan_model = FloorPlan object_location_model = ObjectLocation ================================================ FILE: django_loci/tests/test_selenium.py ================================================ from channels.testing import ChannelsLiveServerTestCase from django.contrib.auth import get_user_model from django.test import tag from ..models import Location, ObjectLocation from .base.test_selenium import BaseTestDeviceAdminSelenium from .testdeviceapp.models import Device @tag("selenium_tests") class TestDeviceAdminSelenium(BaseTestDeviceAdminSelenium, ChannelsLiveServerTestCase): user_model = get_user_model() object_model = Device location_model = Location object_location_model = ObjectLocation ================================================ FILE: django_loci/tests/testdeviceapp/__init__.py ================================================ ================================================ FILE: django_loci/tests/testdeviceapp/admin.py ================================================ from django.contrib import admin from django.shortcuts import render from django.urls import path from django_loci.admin import ObjectLocationInline from openwisp_utils.admin import TimeReadonlyAdminMixin from .models import Device class DeviceAdmin(TimeReadonlyAdminMixin, admin.ModelAdmin): list_display = ("name", "created", "modified") save_on_top = True inlines = [ObjectLocationInline] def get_urls(self): urls = super().get_urls() urls = [ path( "location-broadcast-listener/", self.admin_site.admin_view(self.location_broadcast_listener), name="location-broadcast-listener", ), ] + urls return urls def location_broadcast_listener(self, request): return render( request, "admin/location_broadcast_listener.html", {"title": "Location Broadcast Listener", "site_title": "OpenWISP 2"}, ) admin.site.register(Device, DeviceAdmin) ================================================ FILE: django_loci/tests/testdeviceapp/migrations/0001_initial.py ================================================ # -*- coding: utf-8 -*- # Generated by Django 1.11.5 on 2017-11-14 10:36 import uuid import django.utils.timezone import model_utils.fields from django.db import migrations, models class Migration(migrations.Migration): initial = True dependencies = [] operations = [ migrations.CreateModel( name="Device", 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(max_length=75, verbose_name="name")), ], options={"abstract": False}, ) ] ================================================ FILE: django_loci/tests/testdeviceapp/migrations/__init__.py ================================================ ================================================ FILE: django_loci/tests/testdeviceapp/models.py ================================================ from django.db import models from django.utils.translation import gettext_lazy as _ from openwisp_utils.base import TimeStampedEditableModel class Device(TimeStampedEditableModel): name = models.CharField(_("name"), max_length=75) def __str__(self): return self.name ================================================ FILE: django_loci/tests/testdeviceapp/templates/admin/location_broadcast_listener.html ================================================ {% extends "admin/base_site.html" %} {% load static %} {% load i18n %} {% block content %}
    {% endblock content %} {% block footer %} {{ block.super }} {% endblock footer %} ================================================ FILE: django_loci/tests/testdeviceapp/templates/admin/testdeviceapp/change_list.html ================================================ {% extends "admin/change_list.html" %} {% load i18n %} {% block object-tools-items %}
  • {% trans "View Broadcast Listener" %}
  • {{ block.super }} {% endblock %} ================================================ FILE: django_loci/tests/testdeviceapp/tests/__init__.py ================================================ ================================================ FILE: django_loci/tests/testdeviceapp/tests/test_selenium.py ================================================ from channels.testing import ChannelsLiveServerTestCase from django.contrib.auth import get_user_model from django.test import tag from django.urls import reverse from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait from django_loci.models import Location, ObjectLocation from django_loci.tests import TestAdminMixin, TestLociMixin from openwisp_utils.tests.selenium import SeleniumTestMixin @tag("selenium_tests") class TestCommonLocationWebsocket( SeleniumTestMixin, TestLociMixin, TestAdminMixin, ChannelsLiveServerTestCase ): location_model = Location object_location_model = ObjectLocation user_model = get_user_model() def test_common_location_broadcast_ws(self): self.login() location1 = self._create_location(is_mobile=True, name="Location 1") location2 = self._create_location(is_mobile=True, name="Location 2") self.open(reverse("admin:location-broadcast-listener")) WebDriverWait(self.web_driver, 3).until( EC.visibility_of_element_located( (By.CSS_SELECTOR, "#ws-connected"), ) ) # Update location to trigger websocket message location1.geometry = ( '{ "type": "Point", "coordinates": [ 77.218791, 28.6324252 ] }' ) location1.address = "Delhi, India" location1.full_clean() location1.save() # Wait for websocket message to be received WebDriverWait(self.web_driver, 3).until( EC.text_to_be_present_in_element( (By.CSS_SELECTOR, "#location-updates li"), "77.218791", ) ) location2.geometry = ( '{ "type": "Point", "coordinates": [72.877656, 19.075984] }' ) location2.address = "Mumbai, India" location2.full_clean() location2.save() WebDriverWait(self.web_driver, 3).until( EC.text_to_be_present_in_element( (By.CSS_SELECTOR, "#location-updates"), "72.877656", ) ) ================================================ FILE: django_loci/widgets.py ================================================ import logging from django import forms from leaflet.admin import LeafletAdminWidget as BaseLeafletWidget logger = logging.getLogger(__name__) class ImageWidget(forms.FileInput): """ Image widget which can show a thumbnail and carries information regarding the image width and height """ template_name = "admin/widgets/image.html" def __init__(self, *args, **kwargs): self.thumbnail = kwargs.pop("thumbnail", True) super().__init__(*args, **kwargs) def get_context(self, name, value, attrs): c = super().get_context(name, value, attrs) if value and hasattr(value, "url"): c.update( {"filename": value.name, "url": value.url, "thumbnail": self.thumbnail} ) try: c.update({"width": value.width, "height": value.height}) except IOError: msg = "floorplan image not found while showing floorplan:\n{0}" logger.error(msg.format(value.name)) return c class FloorPlanWidget(forms.TextInput): """ widget that allows to manage indoor coordinates """ template_name = "admin/widgets/floorplan.html" class LeafletWidget(BaseLeafletWidget): include_media = True geom_type = "GEOMETRY" template_name = "leaflet/admin/widget.html" modifiable = True display_raw = False settings_overrides = {} ================================================ FILE: docker-compose.yml ================================================ --- services: redis: image: redis:8-alpine ports: - "6379:6379" entrypoint: redis-server --appendonly yes ================================================ FILE: pyproject.toml ================================================ [tool.coverage.run] source = ["django_loci"] parallel = true # To ensure correct coverage, we need to include both # "multiprocessing" and "thread" in the concurrency list. # This is because Django test suite incorrectly reports coverage # when "multiprocessing" is omitted and the "--parallel" flag # is used. Similarly, coverage for websocket consumers is # incorrect when "thread" is omitted and pytest is used. concurrency = ["multiprocessing", "thread"] omit = [ "django_loci/__init__.py", "*/tests/*", "*/migrations/*", ] [tool.docstrfmt] extend_exclude = ["**/*.py"] [tool.isort] known_third_party = ["django"] known_first_party = ["django_loci", "openwisp_utils"] default_section = "THIRDPARTY" line_length = 88 multi_line_output = 3 use_parentheses = true include_trailing_comma = true force_grid_wrap = 0 ================================================ FILE: pytest.ini ================================================ [pytest] addopts = --create-db --reuse-db --nomigrations django_loci/tests DJANGO_SETTINGS_MODULE = openwisp2.settings python_files = pytest_*.py ================================================ FILE: requirements-test.txt ================================================ pytest-cov~=7.1.0 responses~=0.26.0 openwisp-utils[qa,selenium,channels,channels-test] @ https://github.com/openwisp/openwisp-utils/archive/refs/heads/1.3.tar.gz ================================================ FILE: requirements.txt ================================================ django>=4.2.0,<5.3.0 django-leaflet~=0.33.0 Pillow>=12.2.0,<12.3.0 geopy~=2.4.1 openwisp-utils[channels] @ https://github.com/openwisp/openwisp-utils/archive/refs/heads/1.3.tar.gz ================================================ FILE: run-qa-checks ================================================ #!/bin/bash set -e openwisp-qa-check \ --migration-path \ "./django_loci/migrations \ ./django_loci/tests/testdeviceapp/migrations" \ --migration-module django_loci \ --csslinter \ --jslinter ================================================ FILE: runtests ================================================ #!/bin/bash set -e coverage run runtests.py --parallel --exclude-tag=selenium_tests coverage run runtests.py --tag=selenium_tests --exclude-pytest coverage combine coverage xml ================================================ FILE: runtests.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- import os import sys sys.path.insert(0, "tests") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "openwisp2.settings") if __name__ == "__main__": import pytest from django.core.management import execute_from_command_line args = sys.argv exclude_pytest = "--exclude-pytest" in args if exclude_pytest: args.pop(args.index("--exclude-pytest")) args.insert(1, "test") args.insert(2, "django_loci") django_tests = execute_from_command_line(args) if not exclude_pytest: # pytests is used to test django-channels sys.exit(pytest.main([os.path.join("django_loci", "tests")])) else: sys.exit(django_tests) ================================================ FILE: setup.cfg ================================================ [bdist_wheel] universal=1 [flake8] exclude = migrations, ./tests/*settings*.py max-line-length = 110 # W503: line break before or after operator # W504: line break after or after operator # W605: invalid escape sequence # E231 missing whitespace after ',' ignore = W605, W503, W504, E231 ================================================ FILE: setup.py ================================================ #!/usr/bin/env python from setuptools import find_packages, setup from django_loci import get_version def get_install_requires(): """ parse requirements.txt, ignore links, exclude comments """ requirements = [] for line in open("requirements.txt").readlines(): # skip to next iteration if comment or empty line if ( line.startswith("#") or line == "" or line.startswith("http") or line.startswith("git") ): continue # add line to requirements requirements.append(line) return requirements setup( name="django-loci", version=get_version(), license="BSD", author="Federico Capoano", author_email="support@openwisp.io", description="Reusable django-app for outdoor and indoor mapping", long_description=open("README.rst").read(), url="http://openwisp.org", download_url="https://github.com/openwisp/django-loci/releases", platforms=["Platform Independent"], keywords=["django", "gis"], packages=find_packages(exclude=["tests*", "docs*"]), include_package_data=True, zip_safe=False, install_requires=get_install_requires(), classifiers=[ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Topic :: Internet :: WWW/HTTP", "Topic :: Scientific/Engineering :: GIS", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Framework :: Django", "Programming Language :: Python :: 3", ], ) ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/manage.py ================================================ #!/usr/bin/env python import os import sys if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "openwisp2.settings") from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) ================================================ FILE: tests/openwisp2/__init__.py ================================================ ================================================ FILE: tests/openwisp2/local_settings.example.py ================================================ # RENAME THIS FILE TO local_settings.py IF YOU NEED TO CUSTOMIZE SOME SETTINGS # BUT DO NOT COMMIT # DATABASES = { # 'default': { # 'ENGINE': 'django.db.backends.sqlite3', # 'NAME': 'netjsonconfig.db', # 'USER': '', # 'PASSWORD': '', # 'HOST': '', # 'PORT': '' # }, # } ================================================ FILE: tests/openwisp2/media/.gitignore ================================================ * !.gitignore !.floorplan.jpg ================================================ FILE: tests/openwisp2/settings.py ================================================ import os import sys BASE_DIR = os.path.dirname(os.path.abspath(__file__)) TESTING = "test" in sys.argv DEBUG = True ALLOWED_HOSTS = [] DATABASES = { "default": { "ENGINE": "openwisp_utils.db.backends.spatialite", "NAME": "django-loci.db", } } if TESTING and "--exclude-tag=selenium_tests" not in sys.argv: DATABASES["default"]["TEST"] = { "NAME": os.path.join(BASE_DIR, "django-loci-test.db"), } SPATIALITE_LIBRARY_PATH = "mod_spatialite.so" SECRET_KEY = "fn)t*+$)ugeyip6-#txyy$5wf2ervc0d2n#h)qb)y5@ly$t*@w" INSTALLED_APPS = [ "daphne", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.gis", "openwisp_utils.admin_theme", # django-loci "django_loci", # admin "django.contrib.admin", # other dependencies "leaflet", # channels "channels", # test app "django_loci.tests.testdeviceapp", ] STATICFILES_FINDERS = [ "django.contrib.staticfiles.finders.FileSystemFinder", "django.contrib.staticfiles.finders.AppDirectoriesFinder", ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] ROOT_URLCONF = "openwisp2.urls" ASGI_APPLICATION = "django_loci.channels.asgi.channel_routing" CHANNEL_LAYERS = { "default": { "BACKEND": "channels_redis.core.RedisChannelLayer", "CONFIG": { "hosts": [("127.0.0.1", 6379)], }, }, } TIME_ZONE = "Europe/Rome" LANGUAGE_CODE = "en-gb" USE_TZ = True USE_I18N = False STATIC_URL = "/static/" MEDIA_URL = "/media/" MEDIA_ROOT = "{0}/media/".format(BASE_DIR) TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [], "OPTIONS": { "loaders": [ "django.template.loaders.filesystem.Loader", "django.template.loaders.app_directories.Loader", ], "context_processors": [ "django.template.context_processors.debug", "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", ], }, } ] LEAFLET_CONFIG = {"RESET_VIEW": False} # local settings must be imported before test runner otherwise they'll be ignored try: from .local_settings import * except (SystemError, ImportError): try: from local_settings import * except ImportError: pass ================================================ FILE: tests/openwisp2/urls.py ================================================ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from django.contrib.staticfiles.urls import staticfiles_urlpatterns from django.urls import include, path urlpatterns = [path("admin/", admin.site.urls)] urlpatterns += staticfiles_urlpatterns() urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) if settings.DEBUG and "debug_toolbar" in settings.INSTALLED_APPS: import debug_toolbar urlpatterns.append(path("__debug__/", include(debug_toolbar.urls)))