diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..b142a6f55 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,33 @@ +--- +name: build_docker_image + +on: + push: + branches: [dev] + paths: + - 'Dockerfile' + - 'requirements.txt' + pull_request: + branches: [dev] + paths: + - 'Dockerfile' + - 'requirements.txt' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Login to GitHub Packages + uses: docker/login-action@v1 + with: + registry: docker.pkg.github.com + username: ${{ github.actor }} + password: ${{ secrets.GH_TOKEN }} + - name: Build and push Docker image + uses: docker/build-push-action@v2 + with: + context: . + push: true + tags: docker.pkg.github.com/edwardchalstrey1/seshat/tests-image:latest \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..13b2c5912 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,31 @@ +--- +name: test_code + +on: + push: + branches: [dev] + pull_request: + branches: [dev] + +jobs: + test_core: + runs-on: ['ubuntu-latest'] + steps: + - uses: actions/checkout@v3 + - name: Login to GitHub Packages + uses: docker/login-action@v1 + with: + registry: docker.pkg.github.com + username: ${{ github.actor }} + password: ${{ secrets.GH_TOKEN }} + - name: Pull and run Docker image + run: | + docker pull docker.pkg.github.com/edwardchalstrey1/seshat/tests-image:latest + docker run -d -p 5432:5432 -v ${{ github.workspace }}:/seshat -e DJANGO_SETTINGS_MODULE=seshat.settings.local -e POSTGRES_PASSWORD=postgres -e PGDATA=/var/lib/postgresql/data/db-files/ -e GITHUB_ACTIONS='true' --name seshat_testing docker.pkg.github.com/edwardchalstrey1/seshat/tests-image:latest + - name: Sleep, then check PostgreSQL connectivity + run: | + sleep 10 + docker exec seshat_testing psql -h localhost -U postgres -c 'SELECT 1' + - name: Run tests + run: | + docker exec seshat_testing python3 /seshat/manage.py test seshat.apps.core diff --git a/.gitignore b/.gitignore index 87d289ecf..4b5a0be17 100644 --- a/.gitignore +++ b/.gitignore @@ -211,3 +211,16 @@ cython_debug/ #.idea/ # End of https://www.toptal.com/developers/gitignore/api/django + +# Ed's bits +data +database_dumps +seshat/staticfiles +pulumi/logs +pulumi/Pulumi.seshat.yaml +scripts +.DS_Store +*.ipynb + +docs/source/api/seshat/ +docs/source/api/custom_filters/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..19620a997 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM postgis/postgis + +# Update the package lists for upgrades for packages that need upgrading, as well as new packages that have just come to the repositories. +RUN apt-get update -y + +# Install the packages +RUN apt-get install -y gdal-bin libgdal-dev libgeos++-dev libgeos-c1v5 libgeos-dev libgeos-doc + +# Install pip +RUN apt-get install -y python3-pip + +# Upgrade pip +RUN python3 -m pip install --upgrade pip + +# Copy requirements.txt file into the Docker image +COPY requirements.txt . + +# Install Python dependencies +RUN pip install -r requirements.txt + +# Install django-geojson +RUN pip install "django-geojson[field]" \ No newline at end of file diff --git a/README.md b/README.md index 23e37586b..eb44b33e4 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,30 @@ This repo contains the necessary Django Python code to host the [Seshat](http://seshat-db.com/) website and interact with the backend PostgreSQL database. +## Developers + +Follow the instructions available in [docs/source/getting-started/setup/index.rst](docs/source/getting-started/setup/index.rst). + +In order to generate the documentation, in the correct environment run the following command: + +```bash +pip install -r docs/requirements.txt +cd docs +make html +``` + +## GitHub process + +1. Create a new branch from `dev` +2. Test changes locally +3. Test changes on Azure VM set up with Pulumi if needed (see [Azure Setup](docs/setup.md)). + - ATI VMs are set up currently under the `Sustainable Scholarly Communities around Data and Software` subscription +4. Merge branch into `dev` on this fork +5. Repeat the above until satisfied, then PR `dev` to upstream `dev` branch + +## Tests and checks + +On this fork, currently GH actions is set up to run django tests for the following apps when pushing or PR-ing to the `dev` branch: +- Core + +See [docs/source/contribute/testing.rst](docs/source/contribute/testing.rst) on how to run locally. diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 000000000..d0c3cbf10 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 000000000..747ffb7b3 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 000000000..dd3f4499d --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,14 @@ +docstr-coverage +myst-parser +nbsphinx +pandoc +sphinx +sphinx_togglebutton +sphinx-autoapi +sphinx-autoapi +sphinx-book-theme +sphinx-copybutton +sphinx-tabs +sphinx-togglebutton +sphinxcontrib-napoleon +sphinx-rtd-theme \ No newline at end of file diff --git a/docs/setup.md b/docs/setup.md deleted file mode 100644 index a532f50d1..000000000 --- a/docs/setup.md +++ /dev/null @@ -1,82 +0,0 @@ -# Setup instructions - -This page instructs software engineers how to get started working with the Django codebase and PostgreSQL database. - -## Local setup - -1. Ensure you have a working installation of Python 3 - -2. Set up a virtual environment for the project using e.g. venv or conda - - Note: The application has been tested with Python **3.8.13** - - Example: - ``` - conda create --name seshat38 python=3.8.13 - conda activate seshat38 - ``` - -3. Create a fork of the GitHub repo with all branches: https://github.com/MajidBenam/seshat - -4. Clone your fork to your local machine - -5. Ensure you have a working installation of PostgreSQL **version 12** - -
Example instructions for macOS - - - `brew install postgres@12` - - `brew services start postgresql@12` - - Update `~/.zshrc` (or equivalent for your terminal) with: - ``` - export PATH="/opt/homebrew/opt/postgresql@12/bin:$PATH" - export LDFLAGS="-L/opt/homebrew/opt/postgresql@12/lib" - export CPPFLAGS="-I/opt/homebrew/opt/postgresql@12/include" - ``` - - Open a new terminal -
- - Open PostgreSQL with: - ``` - psql postgres - ``` - - In psql, create a default superuser called "postgres", which is needed to restore the Seshat database from backup: - ``` - CREATE USER postgres SUPERUSER; - ``` - -6. After PostgreSQL is installed, install the Python packages in your environment (some packages have psql as a dependency). From the top level of the `seshat` repo: - ``` - pip install -r requirements.txt - ``` - -7. Restore Seshat database from dump: - - Note: you'll need a dump file of the Seshat database, which can be provided by one of the current developers - ``` - createdb -U postgres - - pg_restore -U postgres -d /path/to/file.dump - ``` - - Connect to the new test database to make sure things are in order - ``` - psql -U postgres -d - ``` - -8. Create a config with your database info for Django - - Within the repo, create a file called `seshat/settings/.env` with the db connection vars - - For example: - ``` - NAME= - USER=postgres - HOST=localhost - PORT=5432 - ``` - - The presence of this file will ensure Django connects to your local database - -9. Run Django - ``` - python manage.py runserver - ``` - -10. The webapp should be visible in a browser at http://127.0.0.1:8000/ - - - -## Production setup (AWS) - -_TODO_ \ No newline at end of file diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst new file mode 100644 index 000000000..4c9ee338b --- /dev/null +++ b/docs/source/api/index.rst @@ -0,0 +1,10 @@ +API Reference +============= + +This page contains auto-generated API reference documentation. + +.. toctree:: + :titlesonly: + + /api/seshat/index + /api/custom_filters/index diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 000000000..2361951bb --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,120 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# + +import os +import sys +sys.path.insert(0, os.path.abspath('../..')) +os.environ['DJANGO_SETTINGS_MODULE'] = 'seshat.settings.base' + +import sphinx_rtd_theme + +import seshat + + +# -- Project information ----------------------------------------------------- + +project = 'Seshat' +copyright = '2024, Majid Benam' +author = 'Majid Benam' + +release = seshat.__version__ + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.doctest", + # "sphinx_rtd_theme", + "myst_parser", + "autoapi.extension", + "sphinx_copybutton", + "nbsphinx", + "sphinx.ext.napoleon", + "sphinx.ext.todo", + "sphinx_togglebutton", + "sphinx_tabs.tabs", +] + +templates_path = ["_templates"] +exclude_patterns = [] + +source_suffix = { + ".rst": "restructuredtext", + ".md": "markdown", +} + +# -- autoapi configuration ----- + +autoapi_dirs = [ + "../../seshat/", + "../../pulumi/", +] +autoapi_type = "python" +autoapi_root = "api" + +autoapi_options = [ + "members", + "undoc-members", + "show-inheritance", + "show-module-summary", + # "imported-members", +] + +autoapi_ignore = [ + '_*', + '*backup*', + '*migrations*', + '*mycodes*', + '*myviews*', +] + +autoapi_keep_files = True +autodoc_typehints = "description" +autoapi_add_toctree_entry = False +autoapi_member_order = "groupwise" + +# def skip_attributes(app, what, name, obj, skip, options): +# if what == "attribute": +# skip = True +# return skip + +# def setup(sphinx): +# sphinx.connect("autoapi-skip-member", skip_attributes) + +# -- Napoleon settings ---- + +napoleon_numpy_docstring = True +napoleon_include_private_with_doc = True +napoleon_include_special_with_doc = True + +# -- todo -- +todo_include_todos = False + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "sphinx_book_theme" + +# html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] +html_static_path = ["_static"] + +html_theme_options = { + "use_download_button": True, +} + +# -- copybutton settings ---- + +copybutton_prompt_text = r">>> |\.\.\. |\$ |In \[\d*\]: | {2,5}\.\.\.: | {5,8}: " +copybutton_prompt_is_regexp = True diff --git a/docs/source/contribute/code-of-conduct.rst b/docs/source/contribute/code-of-conduct.rst new file mode 100644 index 000000000..f623574b9 --- /dev/null +++ b/docs/source/contribute/code-of-conduct.rst @@ -0,0 +1,4 @@ +Code of Conduct and Inclusivity +================================ + +... \ No newline at end of file diff --git a/docs/source/contribute/developers-guide.rst b/docs/source/contribute/developers-guide.rst new file mode 100644 index 000000000..4540ef579 --- /dev/null +++ b/docs/source/contribute/developers-guide.rst @@ -0,0 +1,4 @@ +Developer's Guide +================= + +... \ No newline at end of file diff --git a/docs/source/contribute/index.rst b/docs/source/contribute/index.rst new file mode 100644 index 000000000..34bbf1753 --- /dev/null +++ b/docs/source/contribute/index.rst @@ -0,0 +1,12 @@ +Contribute +========== + +.. todo:: Add a bit of welcoming introduction to contributing to the package. + + +.. toctree:: + :maxdepth: 1 + + code-of-conduct + developers-guide + testing diff --git a/docs/source/contribute/testing.rst b/docs/source/contribute/testing.rst new file mode 100644 index 000000000..ff502daf7 --- /dev/null +++ b/docs/source/contribute/testing.rst @@ -0,0 +1,42 @@ +Testing +======= + +Prerequisites +------------- + +Activate your project python environment. Then install all the required test packages: + +.. code-block:: bash + + $ pip install -r requirements/tests.txt + +Running tests locally +--------------------- + +First, make sure the Seshat repo root directory is in your PYTHONPATH: + +.. code-block:: bash + + $ export PYTHONPATH="${PYTHONPATH}:/path/to/seshat" + +Then use the Django test interface to run tests for apps: + +.. code-block:: bash + + $ python manage.py test --keepdb + +Replace ```` with the app's full name (e.g. ``seshat.apps.core``) (or leave this setting entirely off to run all tests). + +The ``--keepdb`` flag ensures you can rerun tests quickly if the setup hasn't changed. + +CI +--- + +GitHub actions is set up to run on this repo. It uses a custom Docker image that gets built on every push or PR to ``dev`` if the Dockerfile has changed. + +See ``.github/workflows`` and the ``Dockerfile``. The tests (``.github/workflows/tests.yml``) and any subsequently introduced workflows should always run on push/PR to ``dev``. + +To set up pushing the docker image using the GH action workflow, I first did the following: + +- Generated a new GitHub token with the ``read:packages`` and ``write:packages`` scopes. Under my ``Settings > Developer settings > Personal access tokens`` (classic token). +- Stored the GitHub token as a secret in the Seshat GitHub repository, ``Settings > Secrets``, named ``GH_TOKEN``. diff --git a/docs/source/figures/pg_hba.conf.png b/docs/source/figures/pg_hba.conf.png new file mode 100644 index 000000000..6daa3e974 Binary files /dev/null and b/docs/source/figures/pg_hba.conf.png differ diff --git a/docs/source/getting-started/index.rst b/docs/source/getting-started/index.rst new file mode 100644 index 000000000..9f3f3fbc8 --- /dev/null +++ b/docs/source/getting-started/index.rst @@ -0,0 +1,9 @@ +Getting started with Seshat +=========================== + +TODO + +.. toctree:: + :maxdepth: 1 + + setup/index diff --git a/docs/source/getting-started/setup/cloud/pulumi.rst b/docs/source/getting-started/setup/cloud/pulumi.rst new file mode 100644 index 000000000..c9c4f6e38 --- /dev/null +++ b/docs/source/getting-started/setup/cloud/pulumi.rst @@ -0,0 +1,314 @@ +Azure cloud setup with Pulumi +============================= + +.. note:: + + This guide on how to run a full setup of the Seshat django app on Azure with Pulumi based on `this guide `_. + + It assumes that you have access to a Seshat database dump, including all the spatial data. You can access it through the project's `Google Drive `_ if you have access to it. + + It assumes that you are located in the UK and have access to the Azure subscription that the Seshat project is under. + + It assumes that your data tables are populated with the shape data. If it is not, you can populate them with the instructions in `spatialdb.rst <../spatialdb.rst>`_. + +.. warning:: + + The setup is only partially automated with Pulumi currently. As you'll see below, subsequent steps are required to that involve SSH-ing into the created VM. + + +Prerequisites +------------- + +The following instructions assume you have the following software installed: + +- Python 3 (in order to use ``venv``) +- Pulumi +- Azure CLI + +.. admonition:: Installation instructions for prerequisites + :class: dropdown + + **Python 3** + + Ensure you have a working installation of Python 3. The application has been tested with Python **3.8.13**. + + If you don't have Python installed, you can download it from `Python's official website `_. + + **Pulumi** + + Ensure you have a working installation of Pulumi. + + .. hint:: + + If you need to install Homebrew, you can find instructions on how to do so on `Homebrew's website `_. + + If you don't have Pulumi installed, follow the `documentation `_ e.g. on a Mac: + + .. code-block:: bash + + brew install pulumi/tap/pulumi + + **Azure CLI** + + Ensure you have a working installation of the Azure CLI. + + If you don't have the Azure CLI installed, you can download it from `Microsoft's official website `_. + +Step 1: Log in to Azure +----------------------- + +Ensure that you are correctly logged in and that the subscription you will use comes up in the list of subscriptions printed out, then set to that subscription: + +.. code-block:: bash + + $ az login + $ az account set --subscription "" + + +Step 2: Create a virtual environment for Pulumi +----------------------------------------------------------------- + +You can use either Conda or Python's built-in ``venv`` module to create a virtual environment (you could also re-use the environment you set up for Seshat development and install the requirements there). + +.. tabs:: + + .. tab:: Conda example + + Create the environment: + + .. code-block:: bash + + $ conda create --name seshat_pulumi + + Activate the environment: + + .. code-block:: bash + + $ conda activate seshat_pulumi + + .. tab:: venv example + + Create the environment: + + .. code-block:: bash + + $ python3 -m venv seshat_pulumi + + Activate the environment: + + .. code-block:: bash + + $ source seshat_pulumi/bin/activate + +Install the requirements: + +.. code-block:: bash + + $ pip install -r pulumi/requirements.txt + +Step 3: Set up a Pulumi stack +------------------------------ + +We assume here that you'll use our provided Pulumi setup (located in the ``/pulumi`` directory in this repository). + +.. admonition:: Setting up a Pulumi stack from scratch + :class: dropdown + + If you're setting up a Pulumi stack from scratch, you can follow the below steps: + + 1. Set up a Pulumi stack for Azure Python: + + .. code-block:: bash + + $ pulumi new azure-python + + 2. Initialize a new Pulumi stack: + + .. code-block:: bash + + $ pulumi stack init + + 3. Select the stack: + + .. code-block:: bash + + $ pulumi stack select + +.. important:: + + In the provided set up in the ``/pulumi`` directory, we have already set up the Pulumi stack for you. + + In the included set up, we: + + - Chose a sensible project name: `seshat-dev` + - Chose the stack name `seshat` + - Chose ``UKSouth`` location + - Made custom edits to the config files for the Seshat app + +To set up this Pulumi stack, run the following commands: + +.. code-block:: bash + + $ pulumi stack init seshat + $ pulumi stack select seshat + +Step 4: Configure Pulumi +------------------------ + +You will need to provide the following configuration values: + +- ``sshPublicKey``: The public key that will be used to SSH into the VM. You can find your public key by running: + + .. code-block:sh + + $ cat ~/.ssh/id_rsa.pub + +The following command will set the `sshPublicKey` configuration value: + +.. code-block:: bash + + $ pulumi config set --secret sshPublicKey "$(cat ~/.ssh/id_rsa.pub)" + +.. + TODO: `privateKey` and `dumpFile` paths are needed for SCP command, which currently isn't working via Pulumi, see manual steps below + + $ pulumi config set privateKey "~/.ssh/id_rsa" + $ pulumi config set dumpFile "/path/to/dumpfile.dump" + + +Step 5: Deploy the app +---------------------- + +To deploy the app, run the following command: + +.. code-block:: bash + + $ pulumi up + + +Manual steps +------------ + +The Pulumi setup is only partially automated. The following steps are required to complete the setup: + +- SSH into the created VM +- Set up the database +- Run the Django app + +Manual step 1: SSH into the created VM +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +First, we want to get the public IP address of the VM: + +.. code-block:: bash + + $ pulumi stack output + +This will output the public IP address of the VM. Make a note of this IP address as you will need it to SSH into the VM. + +In order to SSH into the VM, run the following command: + +.. code-block:: bash + + $ ssh -i ~/.ssh/id_rsa webadmin@ + + +Manual step 2: Set up the database +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Once we've logged inot the VM, we need to set up the database. In this step, we create the database, add PostGIS to it, set a password for the superuser, update postgres to use md5, and restore the database from the dump. + +To create the database, we need to open ``psql``: + +.. code-block:: bash + + $ sudo -u postgres psql + +Then, create the database: + +.. code-block:: sql + + CREATE DATABASE ; + +Exit out of ``psql`` using ``\q``. + +Next, we need to add PostGIS to the database by opening ``psql`` again using the correct user: + +.. code-block:: bash + + $ sudo -u postgres psql -d + +Then, add PostGIS to the database: + +.. code-block:: sql + + CREATE EXTENSION postgis; + +Exit out of ``psql`` using ``\q``. + +Manual step 3: Secure the database +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Choose a password for Postgres. At Turing we have an Azure Key Vault set up under the project subscription where this can be saved (the one we have set up can be reused). + +In order to add the password for the superuser, open ``psql``: + +.. code-block:: bash + + $ sudo -u postgres psql + +Then, add the password for the superuser: + +.. code-block:: sql + + ALTER USER postgres WITH PASSWORD ''; + +Update postgres to use md5: + +.. code-block:: bash + + $ sudo nano /etc/postgresql/16/main/pg_hba.conf + +.. image:: ../../../figures/pg_hba.conf.png + +In order for the changes to take effect, reload postgres: + +.. code-block:: bash + + $ sudo systemctl reload postgresql + +Exit out of ``psql`` using ``\q``. + +Manual step 4: Restore the database from the dump +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. hint:: + + This step assumes that you have access to the Seshat database dump. + + You can access it through the project's `Google Drive `_. + +In order to restore the database from the dump, run the following command: + +.. code-block:: bash + + $ sudo psql -U postgres < ~/seshat.dump + +Manual step 5: Run the Django app +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In order to run the Django app, we need to configure and run it. + +First, open ``seshat/settings/local.py`` and add the created IP address to ``ALLOWED_HOSTS``. + +Then, configure and run the Django app: + +.. code-block:: bash + + $ sudo ufw allow 8000 + $ cd seshat + $ source venv/bin/activate + $ export DJANGO_SETTINGS_MODULE=seshat.settings.local + $ gunicorn seshat.wsgi:application --config gunicorn.conf.py + +Now, you should be able to go to the publicly' exposed IP on port 8000: ``http://:8000/``. diff --git a/docs/source/getting-started/setup/index.rst b/docs/source/getting-started/setup/index.rst new file mode 100644 index 000000000..790d3fe2c --- /dev/null +++ b/docs/source/getting-started/setup/index.rst @@ -0,0 +1,26 @@ +Setting up Seshat +================= + +This page instructs software engineers how to get started working with the Django codebase and PostgreSQL database for the "core" Seshat webapp. It assumes the engineer has access to a dumpfile of the Seshat "core" database. + + +Setting up in the cloud +----------------------- + +We use Pulumi to manage our cloud infrastructure. To set up the infrastructure, you will need to install Pulumi and configure your Azure credentials. + +Instructions for installing Pulumi can be found :doc:`here `. + + +Setting up in a local environment +--------------------------------- + +We also have instructions for how to set up Seshat in a local environment. You can find these instructions :doc:`here `. + + +.. toctree:: + :maxdepth: 1 + + cloud/pulumi + local/index + spatialdb \ No newline at end of file diff --git a/docs/source/getting-started/setup/local/index.rst b/docs/source/getting-started/setup/local/index.rst new file mode 100644 index 000000000..ac184be5b --- /dev/null +++ b/docs/source/getting-started/setup/local/index.rst @@ -0,0 +1,14 @@ +Setting up Seshat in a local environment +======================================== + +macOS and Ubuntu are supported for local development. The following instructions are for macOS and Ubuntu. + +Windows instructions are forthcoming. For Windows, you can use the Windows Subsystem for Linux (WSL) to run Ubuntu. + +.. toctree:: + :maxdepth: 1 + + macos + ubuntu + windows + macos-ubuntu diff --git a/docs/source/getting-started/setup/local/macos-ubuntu.rst b/docs/source/getting-started/setup/local/macos-ubuntu.rst new file mode 100644 index 000000000..cdd1a19c9 --- /dev/null +++ b/docs/source/getting-started/setup/local/macos-ubuntu.rst @@ -0,0 +1,57 @@ +Setting up a local Ubuntu environment on a Mac +============================================== + +In this guide, we will walk you through setting up a local Ubuntu environment on a Mac. This guide is intended for software engineers who are working on the Seshat project and need to set up a local development environment. + +We will use multipass to create a virtual machine running Ubuntu on your Mac. Multipass is a lightweight VM manager for Linux, Windows, and macOS. It's designed for developers who need a fresh Ubuntu environment with a single command. + +Prerequisites +------------- + +You will need to have Homebrew installed on your Mac to install multipass. If you need to install Homebrew, you can find instructions on how to do so on `Homebrew's website `_. + +Steps +----- + +1. Install multipass with brew: + + .. code-block:: bash + + $ brew install multipass + + - Note: the images used by Multipass don’t have a pre-installed graphical desktop + +2. Create a VM (Ubuntu 22.04) + + .. code-block:: bash + + $ multipass launch 22.04 + + - This should create a VM called `primary` by default + +3. Make sure the VM has enough resources: + + .. code-block:: bash + + $ multipass stop primary + $ multipass set local.primary.cpus=4 + $ multipass set local.primary.disk=60G + $ multipass set local.primary.memory=8G + $ multipass start primary + +4. Mount the dir containing the database dump to the VM: + + .. code-block:: bash + + $ multipass mount /path/to/database_dumps/ primary:database_dumps + +5. Then log in to the VM with `multipass shell` and install pre-requisites: + + .. code-block:: bash + + $ sudo apt update + $ sudo add-apt-repository ppa:deadsnakes/ppa + $ sudo apt install python3.8 -y + $ sudo apt install python3.8-venv -y + $ sudo apt-get install python3.8-dev -y + $ sudo apt-get install g++ -y diff --git a/docs/source/getting-started/setup/local/macos.rst b/docs/source/getting-started/setup/local/macos.rst new file mode 100644 index 000000000..76ad05577 --- /dev/null +++ b/docs/source/getting-started/setup/local/macos.rst @@ -0,0 +1,252 @@ +Setting up Seshat in a local macOS environment +============================================== + +Prerequisites +------------- + +Seshat requires the following software to be installed on your machine: + +- Python 3 +- PostgreSQL 16 with PostGIS +- GDAL +- GEOS + +.. admonition:: Installation instructions for prerequisites + :class: dropdown + + **Python 3** + + Ensure you have a working installation of Python 3. The application has been tested with Python **3.8.13**. + + If you don't have Python installed, you can download it from the `official website `_. + + **PostgreSQL and PostGIS** + + Ensure you have a working installation of PostgreSQL with PostGIS. The application has been tested with PostgreSQL **16**. + + If you don't have the software installed, you can use the instructions on `Postgres' website `_ to install it on macOS. + + **GDAL and GEOS** + + Ensure you have a working installation of GDAL and GEOS. + + .. hint:: + + If you need to install Homebrew, you can find instructions on how to do so on `Homebrew's website `_. + + If you don't have them, you can install them using Homebrew. + + Then use Homebrew to install ``gdal`` and ``geos``: + + .. code-block:: bash + + $ brew install gdal geos + + +Step 1: Set up a virtual environment for the project +---------------------------------------------------- + +You can use either Conda or Python's built-in ``venv`` module to create a virtual environment for the project. + +.. tabs:: + + .. tab:: Conda example + + Create the environment: + + .. code-block:: bash + + $ conda create --name seshat python=3.8.13 + + Activate the environment: + + .. code-block:: bash + + $ conda activate seshat + + .. tab:: venv example + + Create the environment: + + .. code-block:: bash + + $ python3.8 -m venv seshat + + Activate the environment: + + .. code-block:: bash + + $ source seshat/bin/activate + + +Step 2: Create a fork of the correct GitHub repo +------------------------------------------------ + +.. note:: + + Note: In the next step, you'll use the URL of the fork you choose to clone the repo. + +Choose which fork you want to work with. + +- If you want to work with the main development branch of Seshat, you should make note of Majid Benam's fork: https://github.com/MajidBenam/seshat +- If you want to work with the spatial development branch of Seshat, you should make note of Ed Chalstrey's fork: https://github.com/edwardchalstrey1/seshat + + +Step 3: Clone the repo +---------------------- + +Using your Terminal, clone the repository: + +.. code-block:: bash + + $ git clone https://github.com/edwardchalstrey1/seshat + + +Step 4: Create an empty database and add the PostGIS extension +-------------------------------------------------------------- + +.. hint:: + + Note that you'll have to use ``;`` to end each SQL command. They will not work without this character. + +In order to create a database, open ``psql`` in the terminal: + +.. code-block:: bash + + $ psql postgres + +In the database, run the following SQL command to create a new database. Note that you should replace ```` with the name you want to give the database: + +.. code-block:: sql + + CREATE DATABASE ; + +Exit out of the ``psql`` program: + +.. code-block:: sql + + \q + +Then open the database using the name you just created in place of ````: + +.. code-block:: bash + + $ psql postgres -d + +Now, you can add the PostGIS extension to your database: + +.. code-block:: sql + + CREATE EXTENSION postgis; + + +Step 5: Configure GDAL and GEOS +------------------------------- + +.. hint:: + + Note: If you installed GDAL and GEOS using Homebrew, you can find the paths to the installations by running ``brew info gdal`` and ``brew info geos``. + + The paths should look something like ``/opt/homebrew/Cellar/gdal/3.9.0_1`` and ``/opt/homebrew/Cellar/geos/3.9.1``. + +Open :doc:`seshat/settings/base.py ` and check (or update) the paths in the following variables, which should be to the paths to your local ``gdal`` and ``geos`` installations: + +- ``GDAL_LIBRARY_PATH`` +- ``GEOS_LIBRARY_PATH`` + +Note: there are hardcoded paths in ``base.py`` for the Mac and Ubuntu instructions above included. + + +Step 6: Install the Python packages +----------------------------------- + +Install the Python packages in your environment (some packages have these as dependencies). + +From the top level of the ``seshat`` directory, run the following commands to install the packages from the ``requirements.txt`` file and the ``django-geojson`` package: + +.. code-block:: bash + + $ pip install -r requirements.txt + $ pip install "django-geojson [field]" + + +Step 7: Seshat database setup +----------------------------- + +Restore Seshat database from dump file: + +.. code-block:: bash + + $ pg_restore -U postgres -d /path/to/file.dump + + +Step 8: Secure the database +--------------------------- + +Add a password to the database for security. + +Add a password for the superuser by logging in to the database with your superuser: + +.. code-block:: bash + + $ psql -U postgres + +Send the following SQL command to set the password for the superuser. Make sure to replace ```` with your desired password (and make sure to remember it): + +.. code-block:: sql + + ALTER USER postgres WITH PASSWORD ''; + +Locate ``pg_hba.conf`` if you don't know where it is + +.. code-block:: bash + + $ psql -U postgres -c 'SHOW hba_file;' + +Update postgres to use md5 with ``nano /path/to/pg_hba.conf`` + +.. image:: ../../../figures/pg_hba.conf.png + + +Step 9: Set up environment variables for connecting to the database +------------------------------------------------------------------- + +Create a configuration file with your database info for Django. The presence of this file will ensure Django connects to your local database. + +Within the repo, create a file called ``seshat/settings/.env`` with the database connection variables. + +The file should look like this: + +.. code-block:: + + NAME= + USER=postgres + HOST=localhost + PORT=5432 + PASSWORD= + + +Step 10: Migrate the database +----------------------------- + +Ensure that all Django database migrations have run: + +.. code-block:: bash + + $ python manage.py migrate + + +Step 11: Load the shape data +---------------------------- + +If the shape data tables are not yet populated in your copy of the Seshat core database and you have access to source data, populate one or more of them with the instructions `here <../spatialdb.rst>`_. + + +Step 12: Run Django +------------------- + +.. code-block:: bash + + $ python manage.py runserver + +The webapp should be visible in a browser at http://127.0.0.1:8000/ diff --git a/docs/source/getting-started/setup/local/ubuntu.rst b/docs/source/getting-started/setup/local/ubuntu.rst new file mode 100644 index 000000000..3025dc530 --- /dev/null +++ b/docs/source/getting-started/setup/local/ubuntu.rst @@ -0,0 +1,321 @@ +Setting up Seshat in a local Ubuntu environment +=============================================== + +.. hint:: + Local setup steps have been tested on an M1 Mac and on an Ubuntu VM running on the Mac. Instructions for setting up an Ubuntu VM on a Mac can be found `here <../getting-started/setup/local/macos-ubuntu.rst>`_. + + +Prerequisites +------------- + +Seshat requires the following software to be installed on your machine: + +- Python 3 +- PostgreSQL 16 with PostGIS +- GDAL +- GEOS + +.. admonition:: Installation instructions for prerequisites + :class: dropdown + + **Python 3** + + Ensure you have a working installation of Python 3. The application has been tested with Python **3.8.13**. + + If you don't have Python installed, you can download it from the `official website `_. + + **PostgreSQL and PostGIS** + + Ensure you have a working installation of PostgreSQL with PostGIS. The application has been tested with PostgreSQL **16**. + + If you don't have PostgreSQL installed, you can follow the instructions below to install it on Ubuntu: + + .. code-block:: bash + + $ sudo apt install gnupg2 wget vim -y + $ sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' + $ curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/postgresql.gpg + $ sudo apt update + $ sudo apt install postgresql-16 postgresql-contrib-16 postgresql-16-postgis-3 -y + $ sudo systemctl start postgresql + $ sudo systemctl enable postgresql + + **GDAL and GEOS** + + Ensure you have a working installation of GDAL and GEOS. + + If you don't have GDAL and GEOS installed, you can follow the instructions below to install them on Ubuntu: + + .. warning:: + + You may first want to check the latest available version of ``libgeos`` using + + .. code-block:: bash + + $ sudo apt search libgeos + + .. code-block:: bash + + $ sudo apt-get install gdal-bin -y + $ sudo apt-get install libgdal-dev -y + $ sudo apt install libgeos++-dev libgeos3.10.2 -y + $ sudo apt install libgeos-c1v5 libgeos-dev libgeos-doc -y + + +Step 1: Set up a virtual environment for the project +---------------------------------------------------- + +You can use either Conda or Python's built-in ``venv`` module to create a virtual environment for the project. + +.. tabs:: + + .. tab:: Conda example + + Create the environment: + + .. code-block:: bash + + $ conda create --name seshat python=3.8.13 + + Activate the environment: + + .. code-block:: bash + + $ conda activate seshat + + .. tab:: venv example + + Create the environment: + + .. code-block:: bash + + $ python3.8 -m venv seshat + + Activate the environment: + + .. code-block:: bash + + $ source seshat/bin/activate + + +Step 2: Create a fork of the correct GitHub repo +------------------------------------------------ + +.. note:: + + Note: In the next step, you'll use the URL of the fork you choose to clone the repo. + +Choose which fork you want to work with. + +- If you want to work with the main development branch of Seshat, you should make note of Majid Benam's fork: https://github.com/MajidBenam/seshat +- If you want to work with the spatial development branch of Seshat, you should make note of Ed Chalstrey's fork: https://github.com/edwardchalstrey1/seshat + + +Step 3: Clone the repo +---------------------- + +Using your Terminal, clone the repository: + +.. code-block:: bash + + $ git clone https://github.com/edwardchalstrey1/seshat + + +Step 4: Create an empty database and add the PostGIS extension +-------------------------------------------------------------- + +.. hint:: + + Note that you'll have to use ``;`` to end each SQL command. They will not work without this character. + + +In order to create a database, open ``psql`` in the terminal: + +.. code-block:: bash + + $ sudo -u postgres psql + +In the database, run the following SQL command to create a new database. Note that you should replace ```` with the name you want to give the database: + +.. code-block:: sql + + CREATE DATABASE ; + +Exit out of the ``psql`` program: + +.. code-block:: sql + + \q + +Then open the database using the name you just created in place of ````: + +.. code-block:: bash + + $ sudo -u postgres psql -d + +Now, you can add the PostGIS extension to your database: + +.. code-block:: sql + + CREATE EXTENSION postgis; + + +Step 5: Configure GDAL and GEOS +------------------------------- + +Open :doc:`seshat/settings/base.py ` and check (or update) the paths in the following variables, which should be to the paths to your local ``gdal`` and ``geos`` installations: + +- ``GDAL_LIBRARY_PATH`` +- ``GEOS_LIBRARY_PATH`` + +Note: there are hardcoded paths in ``base.py`` for the Mac and Ubuntu instructions above included. + + +Step 6: Install the Python packages +----------------------------------- + +Install the Python packages in your environment (some packages have these as dependencies). + +From the top level of the ``seshat`` directory, run the following commands to install the packages from the ``requirements.txt`` file and the ``django-geojson`` package: + +.. code-block:: bash + + $ pip install -r requirements.txt + $ pip install "django-geojson [field]" + + +Step 7: Seshat database setup +----------------------------- + +Restore Seshat database from dump file: + +.. code-block:: bash + + $ sudo nano /etc/postgresql/16/main/pg_hba.conf + +On the line ``local all postgres peer`` change "peer" to "trust" + +You should now be able to reload postgres and populate the database with the following commands: + +.. code-block:: bash + + $ sudo systemctl reload postgresql + $ sudo psql -U postgres < /path/to/file.dump + + +Step 8: Secure the database +--------------------------- + +Add a password to the database for security. + +Add a password for the superuser by logging in to the database with your superuser: + +.. code-block:: bash + + $ sudo -u postgres psql + +Send the following SQL command to set the password for the superuser. Make sure to replace ```` with your desired password (and make sure to remember it): + +.. code-block:: sql + + ALTER USER postgres WITH PASSWORD ''; + +Locate ``pg_hba.conf`` if you don't know where it is: + +.. code-block:: bash + + $ sudo psql -U postgres -c 'SHOW hba_file;' + +Update postgres to use md5 with ``nano /path/to/pg_hba.conf`` + +.. image:: ../../../figures/pg_hba.conf.png + +Restart postgres: + +.. code-block:: bash + + $ sudo systemctl reload postgresql + + +Step 9: Set up environment variables for connecting to the database +------------------------------------------------------------------- + +Create a configuration file with your database info for Django. The presence of this file will ensure Django connects to your local database. + +Within the repo, create a file called ``seshat/settings/.env`` with the database connection variables. + +The file should look like this: + +.. code-block:: + + NAME= + USER=postgres + HOST=localhost + PORT=5432 + PASSWORD= + + +Step 10: Migrate the database +----------------------------- + +Ensure that all Django database migrations have run: + +.. code-block:: bash + + $ python manage.py migrate + + +Step 11: Load the shape data +---------------------------- + +If the shape data tables are not yet populated in your copy of the Seshat core database and you have access to source data, populate one or more of them with the instructions `here <../spatialdb.rst>`_. + + +Step 12: Run Django +------------------- + +.. code-block:: bash + + $ python manage.py runserver + +If you have set up Seshat on a Multipass VM, you can access the Django server from your host machine by following these commands: + +First, check IP inside VM: + +.. code-block:: bash + + $ ip addr show + +This will return a value like ``192.168.64.3``. Note this IP address as you will need to insert it into the following commands. + +In the VM, you need to now ensure that the firewall is not blocking incoming connections on port 8000: + +.. code-block:: bash + + $ sudo ufw allow 8000 + +In a macOS Terminal, run the following command to forward the port, but replace ```` with the IP address you noted earlier: + +.. code-block:: bash + + $ multipass exec primary -- sudo iptables -t nat -A PREROUTING -p tcp --dport 8000 -j DNAT --to-destination :8000 + +Now, restart the VM: + +.. code-block:: bash + + $ multipass restart primary + +Log back into the VM: + +.. code-block:: bash + + $ multipass shell primary + +Finally, run the Django server but remember to first activate the virtual environment (see Step 1): + +.. code-block:: bash + + $ python manage.py runserver 0.0.0.0:8000 + +You should now be able to access the Django server from your host machine by going to ``http://192.168.64.3:8000/`` in a browser (where ``192.168.64.3`` may need to be replaced with the IP address you noted earlier). diff --git a/docs/source/getting-started/setup/local/windows.rst b/docs/source/getting-started/setup/local/windows.rst new file mode 100644 index 000000000..7303da8d7 --- /dev/null +++ b/docs/source/getting-started/setup/local/windows.rst @@ -0,0 +1,4 @@ +Setting up Seshat in a local Windows environment +================================================ + +TODO \ No newline at end of file diff --git a/docs/source/getting-started/setup/spatialdb.rst b/docs/source/getting-started/setup/spatialdb.rst new file mode 100644 index 000000000..82431a371 --- /dev/null +++ b/docs/source/getting-started/setup/spatialdb.rst @@ -0,0 +1,47 @@ +Setting up new shape datasets +============================= + +Ensure that the database and Django are already set up (see :doc:`local instructions ` for more detail) and all migrations have been run for the "core" Django app (``python manage.py migrate core``). + +To create a new shape dataset for use in the Seshat map explorer, you can do the following: + +1. Create a new model for the new dataset in ``seshat/apps/core/models.py`` +2. Generate migration from model, and run it for your database to create the table + + .. code-block:: bash + + $ python manage.py makemigrations core + $ python manage.py migrate core + +3. Create a new "command" at `seshat/apps/core/management/commands` which can be used to populate the db table from the dataset files + - See the examples below + - Add a new header on this page to document here how this works + +4. Create a new view and update the the map template with the necessary logic to use this dataset + - views at ``seshat/apps/core/views.py`` + - template e.g. ``seshat/apps/core/templates/core/world_map.html`` + +Cliopatria shape dataset +------------------------- + +.. + TODO: Add a link here to the published Clipatria dataset + +1. Download and unzip the Cliopatria dataset. +2. Populate ``core_videoshapefile`` table using the following command: + + .. code-block:: bash + + $ python manage.py populate_videodata /path/to/data + + Note: if you wish to further simplify the Cliopatria shape resolution used by the world map after loading it into the database, open ``seshat/apps/core/management/commands/populate_videodata.py`` and modify the SQL query under the comment: "Adjust the tolerance param of ST_Simplify as needed" + +GADM +---- + +1. `Download `_ the whole world GeoPackage file from the `GADM website `_. +2. Populate the ``core_gadmshapefile``, ``core_gadmcountries`` and ``core_gadmprovinces`` tables using the following command: + + .. code-block:: bash + + $ python manage.py populate_gadm /path/to/gpkg_file diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 000000000..56271a451 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,20 @@ +Seshat: Global History Databank +=============================== + +Seshat: Global History Databank brings together the most current and comprehensive body of knowledge about human history in one place. + +.. toctree:: + :maxdepth: 5 + + getting-started/index + contribute/index + api/index + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 000000000..94c5b6121 --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,28 @@ +import multiprocessing + +# The socket to bind +bind = '0.0.0.0:8000' + +# The number of worker processes for handling requests +workers = multiprocessing.cpu_count() * 2 + 1 + +# The number of worker threads for handling requests +threads = 2 + +# Max number of requests per worker before restarting the worker +max_requests = 1200 + +# The type of workers to use +worker_class = 'sync' # or 'gevent' for async workers + +# The maximum number of simultaneous clients +worker_connections = 1000 + +# How long to keep an idle worker running +keepalive = 2 + +# Logging +errorlog = '-' +loglevel = 'info' +accesslog = '-' +access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' diff --git a/manage.py b/manage.py index 35a6274fd..932a6e865 100755 --- a/manage.py +++ b/manage.py @@ -2,11 +2,12 @@ """Django's command-line utility for administrative tasks.""" import os import sys - +from pathlib import Path def main(): """Run administrative tasks.""" - if os.path.exists(".env"): + local_env_path = str(Path.cwd()) + "/seshat/settings/.env" + if os.path.exists(local_env_path): os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'seshat.settings.local') else: diff --git a/notebooks/README.md b/notebooks/README.md new file mode 100644 index 000000000..7c3275dee --- /dev/null +++ b/notebooks/README.md @@ -0,0 +1,21 @@ +# Visualise Cliopatria shape dataset + +Cliopatria is the shape dataset used by the Seshat Global History Databank website. It can also be explored in a local Jupyter notebook running on your local machine by following these instructions. + +1. Ensure you have a working installation of Python 3 and Conda. If not, [download Anaconda](https://docs.anaconda.com/free/anaconda/install/index.html), which should give you both + - Note: you can use a different tool for creating a Python virtual environment than conda (e.g. venv) if you prefer + +2. Set up the required virtual environment, install packages into it and create a jupyter kernel. + - Conda example: + ``` + conda create --name cliopatria python=3.11 + conda activate cliopatria + pip install -r requirements.txt + python -m ipykernel install --user --name=cliopatria --display-name="Python (cliopatria)" + ``` + - Note: This will install Geopandas 0.13.2, but if you [install from source](https://geopandas.org/en/stable/getting_started/install.html#installing-from-source) it's much faster with version 1.0.0 (unreleased on pip as of 18th June 2024) + +3. Open the `cliopatria.ipynb` notebook with Jupyter (or another application that can run notebooks such as VSCode). + - `jupyter lab` (or `jupyter notebook`) + - Note: make sure the notebook Python kernel is using the virtual environment you created (click top right) +4. Follow the instructions in the notebook. \ No newline at end of file diff --git a/notebooks/cliopatria.ipynb b/notebooks/cliopatria.ipynb new file mode 100644 index 000000000..c48b61df0 --- /dev/null +++ b/notebooks/cliopatria.ipynb @@ -0,0 +1,144 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Cliopatria viewer\n", + "\n", + "1. To get started, download a copy of the Cliopatria dataset from here: `[INSERT LINK]`\n", + "2. Move the downloaded dataset to an appropriate location on your machine and pass in the paths in the code cell below and run\n", + "3. Run the subsequent cells of the notebook\n", + "4. Play around with both the GeoDataFrame (gdf) and the rendered map\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "cliopatria_geojson_path = \"../data/cliopatria_composite_unique_nonsimplified.geojson_06052024/cliopatria_composite_unique_nonsimplified.geojson\"\n", + "cliopatria_json_path = \"../data/cliopatria_composite_unique_nonsimplified.geojson_06052024/cliopatria_composite_unique_nonsimplified_name_years.json\"" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "from map_functions import cliopatria_gdf, display_map" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "# Load the Cliopatria data to a GeoDataFrame including end years for each shape\n", + "gdf = cliopatria_gdf(cliopatria_geojson_path, cliopatria_json_path)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Play with the data on the map\n", + "\n", + "**Notes**\n", + "- The slider is a bit buggy, the best way to change year is to enter a year in the box and hit enter. Use minus numbers for BCE.\n", + "- The map is also displayed thrice for some reason!\n", + "- Initial attempts to implement a play button similar to the website code failed, but that may not be needed here.\n", + "- Click the shapes to reveal the polity display names, using the same logic used in the website code - see `map_functions.py`" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "a95aced3593446ceb228a171178f978b", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "IntText(value=0, description='Year:')" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "80c96982f4a34628b3026e9f853a6af9", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "IntSlider(value=0, description='Year:', max=2024, min=-3400)" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "44078fdd8e91499bad99d7fd38b76a65", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Output()" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/echalstrey/.pyenv/versions/3.11.4/lib/python3.11/site-packages/geopandas/geodataframe.py:1538: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " super().__setitem__(key, value)\n" + ] + } + ], + "source": [ + "display_year = 0\n", + "display_map(gdf, display_year)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (cliopatria1)", + "language": "python", + "name": "cliopatria1" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.4" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/notebooks/map_functions.py b/notebooks/map_functions.py new file mode 100644 index 000000000..2148739c0 --- /dev/null +++ b/notebooks/map_functions.py @@ -0,0 +1,171 @@ +import geopandas as gpd +import json +import folium +import folium +import ipywidgets as widgets +from IPython.display import display, clear_output + + +def convert_name(gdf, i): + """ + Convert the polity name of a shape in the Cliopatria dataset to what we want to display on the Seshat world map. + Where gdf is the geodataframe, i is the index of the row/shape of interest. + Returns the name to display on the map. + Returns None if we don't want to display the shape (see comments below for details). + """ + polity_name = gdf.loc[i, 'Name'].replace('(', '').replace(')', '') # Remove spaces and brackets from name + # If a shape has components (is a composite) we'll load the components instead + # ... unless the components have their own components, then load the top level shape + # ... or the shape is in a personal union, then load the personal union shape instead + try: + if gdf.loc[i, 'Components']: # If the shape has components + if ';' not in gdf.loc[i, 'SeshatID']: # If the shape is not a personal union + if len(gdf.loc[i, 'Components']) > 0 and '(' not in gdf.loc[i, 'Components']: # If the components don't have components + polity_name = None + except KeyError: # If the shape has no components, don't modify the name + pass + return polity_name + + +def cliopatria_gdf(cliopatria_geojson_path, cliopatria_json_path): + """ + Load the Cliopatria shape dataset with GeoPandas and add the EndYear column to the geodataframe. + """ + # Load the geojson and json files + gdf = gpd.read_file(cliopatria_geojson_path) + with open(cliopatria_json_path, 'r') as f: + name_years = json.load(f) + + # Create new columns in the geodataframe + gdf['EndYear'] = None + gdf['DisplayName'] = None + + # Loop through the geodataframe + for i in range(len(gdf)): + + # Get the raw name of the current row and the name to display + polity_name_raw = gdf.loc[i, 'Name'] + polity_name = convert_name(gdf, i) + + if polity_name: # convert_name returns None if we don't want to display the shape + if gdf.loc[i, 'Type'] != 'POLITY': # Add the type to the name if it's not a polity + polity_name = gdf.loc[i, 'Type'] + ': ' + polity_name + + # Get the start year of the current row + start_year = gdf.loc[i, 'Year'] + + # Get a sorted list of the years for that name from the geodataframe + this_polity_years = sorted(gdf[gdf['Name'] == polity_name_raw]['Year'].unique()) + + # Get the end year for a shape + # Most of the time, the shape end year is the year of the next shape + # Some polities have a gap in their active years + # For a shape year at the start of a gap, set the end year to be the shape year, so it doesn't cover the inactive period + start_end_years = name_years[polity_name_raw] + end_years = [x[1] for x in start_end_years] + + polity_start_year = start_end_years[0][0] + polity_end_year = end_years[-1] + + # Raise an error if the shape year is not the start year of the polity + if this_polity_years[0] != polity_start_year: + raise ValueError(f'First shape year for {polity_name} is not the start year of the polity') + + # Find the closest higher value from end_years to the shape year + next_end_year = min(end_years, key=lambda x: x if x >= start_year else float('inf')) + + if start_year in end_years: # If the shape year is in the list of polity end years, the start year is the end year + end_year = start_year + else: + this_year_index = this_polity_years.index(start_year) + try: # Try to use the next shape year minus one as the end year if possible, unless it's higher than the next_end_year + next_shape_year_minus_one = this_polity_years[this_year_index + 1] - 1 + end_year = next_shape_year_minus_one if next_shape_year_minus_one < next_end_year else next_end_year + except IndexError: # Otherwise assume the end year of the shape is the end year of the polity + end_year = polity_end_year + + # Set the EndYear column to the end year + gdf.loc[i, 'EndYear'] = end_year + + # Set the DisplayName column to the name to display + gdf.loc[i, 'DisplayName'] = polity_name + + return gdf + + +def create_map(selected_year, gdf, map_output): + global m + m = folium.Map(location=[0, 0], zoom_start=2, tiles='https://a.basemaps.cartocdn.com/rastertiles/voyager_nolabels/{z}/{x}/{y}.png', attr='CartoDB') + + # Filter the gdf for shapes that overlap with the selected_year + filtered_gdf = gdf[(gdf['Year'] <= selected_year) & (gdf['EndYear'] >= selected_year)] + + # Remove '0x' and add '#' to the start of the color strings + filtered_gdf['Color'] = '#' + filtered_gdf['Color'].str.replace('0x', '') + + # Transform the CRS of the GeoDataFrame to WGS84 (EPSG:4326) + filtered_gdf = filtered_gdf.to_crs(epsg=4326) + + # Define a function for the style_function parameter + def style_function(feature, color): + return { + 'fillColor': color, + 'color': color, + 'weight': 2, + 'fillOpacity': 0.5 + } + + # Add the polygons to the map + for _, row in filtered_gdf.iterrows(): + # Convert the geometry to GeoJSON + geojson = folium.GeoJson( + row.geometry, + style_function=lambda feature, color=row['Color']: style_function(feature, color) + ) + + # Add a popup to the GeoJSON + folium.Popup(row['DisplayName']).add_to(geojson) + + # Add the GeoJSON to the map + geojson.add_to(m) + + # Display the map + with map_output: + clear_output(wait=True) + display(m) + + +def display_map(gdf, display_year): + + # Create a text box for input + year_input = widgets.IntText( + value=display_year, + description='Year:', + ) + + # Define a function to be called when the value of the text box changes + def on_value_change(change): + create_map(change['new'], gdf, map_output) + + # Create a slider for input + year_slider = widgets.IntSlider( + value=display_year, + min=gdf['Year'].min(), + max=gdf['EndYear'].max(), + description='Year:', + ) + + # Link the text box and the slider + widgets.jslink((year_input, 'value'), (year_slider, 'value')) + + # Create an output widget + map_output = widgets.Output() + + # Attach the function to the text box + year_input.observe(on_value_change, names='value') + + # Display the widgets + display(year_input, year_slider, map_output) + + # Call create_map initially to display the map + create_map(display_year, gdf, map_output) \ No newline at end of file diff --git a/notebooks/requirements.txt b/notebooks/requirements.txt new file mode 100644 index 000000000..03f14b0c2 --- /dev/null +++ b/notebooks/requirements.txt @@ -0,0 +1,5 @@ +jupyter==1.0.0 +ipykernel==6.29.3 +geopandas==0.13.2 +contextily==1.6.0 +folium==0.16.0 \ No newline at end of file diff --git a/pulumi/.gitignore b/pulumi/.gitignore new file mode 100644 index 000000000..f1321d096 --- /dev/null +++ b/pulumi/.gitignore @@ -0,0 +1,3 @@ +*.pyc +venv/ +logs \ No newline at end of file diff --git a/pulumi/Pulumi.yaml b/pulumi/Pulumi.yaml new file mode 100644 index 000000000..ff370606b --- /dev/null +++ b/pulumi/Pulumi.yaml @@ -0,0 +1,10 @@ +name: seshat-dev +runtime: + name: python + options: + virtualenv: venv +description: Spin up the Seshat Django app on a dev server +config: + pulumi:tags: + value: + pulumi:template: azure-python diff --git a/pulumi/__main__.py b/pulumi/__main__.py new file mode 100644 index 000000000..b096e31b4 --- /dev/null +++ b/pulumi/__main__.py @@ -0,0 +1,163 @@ +import base64 +import pulumi +import subprocess +from os.path import expanduser +from pulumi_azure import core, compute, network + +# Create an Azure Resource Group +resource_group = core.ResourceGroup('seshat-pulumi', location='uksouth') + +# Create a network with a single subnet +virtual_network = network.VirtualNetwork('virtualNetwork', + resource_group_name=resource_group.name, + address_spaces=['10.0.0.0/16'], + subnets=[{ + 'name': 'default', + 'address_prefix': '10.0.1.0/24', + }] +) + +# Create a public IP +public_ip = network.PublicIp('publicIp', + resource_group_name=resource_group.name, + location=resource_group.location, + allocation_method='Dynamic', +) + +# Create a network security group +network_security_group = network.NetworkSecurityGroup('networkSecurityGroup', + resource_group_name=resource_group.name, + security_rules=[ + {'name': 'ssh', 'priority': 1001, 'direction': 'Inbound', + 'access': 'Allow', 'protocol': 'Tcp', 'source_port_range': '*', + 'destination_port_range': '22', 'source_address_prefix': '*', + 'destination_address_prefix': '*'}, + {'name': 'http', 'priority': 1002, 'direction': 'Inbound', + 'access': 'Allow', 'protocol': 'Tcp', 'source_port_range': '*', + 'destination_port_range': '80', 'source_address_prefix': '*', + 'destination_address_prefix': '*'}, + {'name': 'https', 'priority': 1003, 'direction': 'Inbound', + 'access': 'Allow', 'protocol': 'Tcp', 'source_port_range': '*', + 'destination_port_range': '443', 'source_address_prefix': '*', + 'destination_address_prefix': '*'}, + {'name': 'django', 'priority': 1004, 'direction': 'Inbound', + 'access': 'Allow', 'protocol': 'Tcp', 'source_port_range': '*', + 'destination_port_range': '8000', 'source_address_prefix': '*', + 'destination_address_prefix': '*'}, + ] +) + +# Associate the network security group with the subnet +association = network.SubnetNetworkSecurityGroupAssociation('association', + subnet_id=virtual_network.subnets[0].id, # Use the subnet from the virtual network + network_security_group_id=network_security_group.id, +) + +# Create a network interface and associate it with the subnet +network_interface = network.NetworkInterface('networkInterface', + resource_group_name=resource_group.name, + ip_configurations=[{ + 'name': 'webserver', + 'subnet_id': virtual_network.subnets[0].id, # Use the subnet from the virtual network + 'private_ip_address_allocation': 'Dynamic', + 'public_ip_address_id': public_ip.id, + }] +) + +# Get the IP address as a string +# TODO: move this into the VM script and run in python +# ip_address = public_ip.ip_address.apply(lambda ip: ip if ip is not None else '') +# os.environ['ALLOWED_HOSTS'] = ip_address + +# Create a data script for the VM +# TODO: Set the ALLOWED_HOSTS environment variable (used by Django) +custom_data_script = '''#!/bin/bash + +# Install Python 3.8 +sudo apt update +sudo add-apt-repository ppa:deadsnakes/ppa +sudo apt install -y python3.8 +sudo apt install -y python3.8-venv +sudo apt-get install -y python3.8-dev +sudo apt-get install -y g++ + +# Install PostgreSQL 16 +sudo apt install -y gnupg2 wget vim +sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' +curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/postgresql.gpg +sudo apt update +sudo apt install -y postgresql-16 postgresql-contrib-16 postgresql-16-postgis-3 +sudo systemctl start postgresql +sudo systemctl enable postgresql + +# Install GDAL and GEOS +sudo apt-get install -y gdal-bin +sudo apt-get install -y libgdal-dev +sudo apt install -y libgeos++-dev libgeos3.10.2 +sudo apt install -y libgeos-c1v5 libgeos-dev libgeos-doc + +# Clone Seshat +git clone https://github.com/edwardchalstrey1/seshat /home/webadmin/seshat +cd /home/webadmin/seshat +python3.8 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +pip install "django-geojson [field]" + + +## TODO: Get the pulumi script to run the full setup +# # Create .env file with database configuration +# echo "NAME= +# USER=postgres +# HOST=localhost +# PORT=5432 +# PASSWORD=" > /home/webadmin/seshat/seshat/settings/.env + +# # Run django +# export DJANGO_SETTINGS_MODULE=seshat.settings.local +# gunicorn seshat.wsgi:application --config gunicorn.conf.py & +''' + +# Create a VM +vm = compute.LinuxVirtualMachine('vm', + resource_group_name=resource_group.name, + network_interface_ids=[network_interface.id], + size='Standard_D2s_v3', + source_image_reference={ + 'publisher': 'Canonical', + 'offer': '0001-com-ubuntu-server-jammy', + 'sku': '22_04-lts', + 'version': 'latest', + }, + os_disk={ + 'caching': 'ReadWrite', + 'storage_account_type': 'Premium_LRS', + }, + computer_name='webserver', + admin_username='webadmin', + disable_password_authentication=True, + admin_ssh_keys=[{ + 'username': 'webadmin', + 'public_key': pulumi.Config().require_secret('sshPublicKey'), + }], + custom_data=base64.b64encode(custom_data_script.encode('ascii')).decode('ascii'), +) + +# Export the public IP address of the VM +pulumi.export('publicIp', public_ip.ip_address) + +## TODO: Get the pulumi script to run the full setup +# # Create a config object +# config = pulumi.Config() + +# # Get the dump file path from the config +# dump_file = config.require('dumpFile') + +# # Get the private key path from the config +# private_key_path = expanduser(config.require('privateKey')) + +# # Copy database dump file to the VM +# cmd = pulumi.Output.all(private_key_path, dump_file, public_ip.ip_address).apply( +# lambda args: f"scp -i {args[0]} {args[1]} webadmin@{args[2]}:~/seshat.dump" +# ) +# cmd_result = cmd.apply(lambda cmd: subprocess.run(cmd, shell=True, check=True)) diff --git a/pulumi/requirements.txt b/pulumi/requirements.txt new file mode 100644 index 000000000..97bda9ba4 --- /dev/null +++ b/pulumi/requirements.txt @@ -0,0 +1,6 @@ +pulumi>=3.0.0,<4.0.0 +pulumi-azure-native>=2.0.0,<3.0.0 +pulumi-azure>=4.0.0,<5.0.0 +pulumi-azuread>=5.0.0,<6.0.0 +pulumi-random>=4.0.0,<5.0.0 +pulumi-command==0.9.2 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 87b623776..4d56c2607 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,11 @@ asgiref==3.5.0 -backports.zoneinfo==0.2.1 +backports.zoneinfo==0.2.1;python_version<"3.9" bibtexparser==1.3.0 certifi==2021.10.8 cffi==1.15.1 charset-normalizer==2.0.12 cryptography==38.0.1 +distinctipy==1.2.3 defusedxml==0.7.1 dj-database-url==0.5.0 Django==4.0.3 @@ -12,13 +13,18 @@ django-allauth==0.51.0 django-cors-headers==3.11.0 django-crispy-forms==1.14.0 django-debug-toolbar==3.2.4 +django-environ==0.11.2 django-filter==21.1 +django_geojson==4.0.0 django-heroku==0.3.1 django-mathfilters==1.0.0 djangorestframework==3.13.1 +django-recaptcha==4.0.0 feedparser==6.0.10 +geopandas==0.13.2 gunicorn==20.1.0 idna==3.3 +matplotlib==3.7.4 MarkupSafe==2.1.1 oauthlib==3.2.1 psycopg2==2.9.3 diff --git a/seshat/__init__.py b/seshat/__init__.py index e69de29bb..a68927d6c 100644 --- a/seshat/__init__.py +++ b/seshat/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" \ No newline at end of file diff --git a/seshat/apps/accounts/custom_validators.py b/seshat/apps/accounts/custom_validators.py index a370ed352..d33e7f937 100644 --- a/seshat/apps/accounts/custom_validators.py +++ b/seshat/apps/accounts/custom_validators.py @@ -5,7 +5,18 @@ def validate_email_with_dots(value): """ - Custom validator to reject email addresses with more than four dots in their domain part. + Custom validator to reject email addresses with more than four dots in + their domain part. + + Args: + value (str): The email address to validate. + + Returns: + None + + Raises: + ValidationError: If the email address contains more than four dots in + the domain part. """ # Split the email address into local and domain parts local_part, domain_part = value.split('@') @@ -15,4 +26,3 @@ def validate_email_with_dots(value): raise ValidationError("Email address contains too many dots in the domain part.") # You can add more checks if needed - diff --git a/seshat/apps/accounts/forms.py b/seshat/apps/accounts/forms.py index a6800226e..5edc1e1c6 100644 --- a/seshat/apps/accounts/forms.py +++ b/seshat/apps/accounts/forms.py @@ -8,13 +8,20 @@ from django.contrib.admin.widgets import FilteredSelectMultiple from django.template.defaulttags import register -from .models import Seshat_Task, Seshat_Expert, Profile, User +from .models import Seshat_Task, Seshat_Expert, Profile +from django.contrib.auth.models import User from django.contrib.auth.forms import UserCreationForm class Seshat_TaskForm(forms.ModelForm): + """ + Form for adding or updating a task. + """ class Meta: + """ + :noindex: + """ model = Seshat_Task fields = ["giver", "taker", "task_description", "task_url"] labels = { @@ -48,9 +55,16 @@ class Meta: # } class ProfileForm(forms.ModelForm): + """ + Form for adding or updating a profile. + """ first_name = forms.CharField(max_length=32) last_name = forms.CharField(max_length=32) + class Meta: + """ + :noindex: + """ model = Profile fields = ["first_name", "last_name", "role", "location", "bio", ] labels = { @@ -62,20 +76,32 @@ class Meta: } widgets = { - 'bio': forms.Textarea(attrs={'class': 'form-control mb-3', }), -# 'role': forms.TextInput(attrs={'class': 'form-control mb-3', }), -# 'location': forms.TextInput(attrs={'class': 'form-control mb-3', }), -# 'last_name': forms.TextInput(attrs={'class': 'form-control mb-3', }), -# 'first_name': forms.TextInput(attrs={'class': 'form-control mb-3', }), - + 'bio': forms.Textarea(attrs={'class': 'form-control mb-3', }), +# 'role': forms.TextInput(attrs={'class': 'form-control mb-3', }), +# 'location': forms.TextInput(attrs={'class': 'form-control mb-3', }), +# 'last_name': forms.TextInput(attrs={'class': 'form-control mb-3', }), +# 'first_name': forms.TextInput(attrs={'class': 'form-control mb-3', }), } class CustomSignUpForm(UserCreationForm): - # Your other form fields + """ + Form for signing up a user. + """ def clean_email(self): + """ + A method to clean the email field and check if it contains too many + dots in the username part. + + Returns: + str: The email address if it is valid. + + Raises: + ValidationError: If the email address contains too many dots in the + username part. + """ email = self.cleaned_data.get('email') if email: username, domain = email.split('@') diff --git a/seshat/apps/accounts/models.py b/seshat/apps/accounts/models.py index 9f86fe581..5c48dd467 100644 --- a/seshat/apps/accounts/models.py +++ b/seshat/apps/accounts/models.py @@ -16,6 +16,9 @@ # ) class Profile(models.Model): + """ + Model representing a user profile. + """ SESHATADMIN = 1 RA = 2 SESHATEXPERT = 3 @@ -28,13 +31,21 @@ class Profile(models.Model): ) user = models.OneToOneField(User, on_delete=models.CASCADE) email_confirmed = models.BooleanField(default=False, null=True, blank=True) - #avatar = models.ImageField(default='default.jpg', upload_to='profile_images') + # avatar = models.ImageField(default='default.jpg', upload_to='profile_images') bio = models.TextField( null=True, blank=True) location = models.CharField(max_length=30, blank=True) role = models.PositiveSmallIntegerField( choices=ROLE_CHOICES, null=True, blank=True) def get_absolute_url(self): + """ + Returns the url to access a particular instance of the model. + + :noindex: + + Returns: + str: A string of the url to access a particular instance of the model. + """ return reverse('user-profile') def __str__(self): # __unicode__ for Python 2 @@ -43,6 +54,9 @@ def __str__(self): # __unicode__ for Python 2 @receiver(post_save, sender=User) def create_or_update_user_profile(sender, instance, created, **kwargs): + """ + Signal handler for creating or updating a user profile. + """ if created: Profile.objects.create(user=instance) instance.profile.save() @@ -51,6 +65,9 @@ def create_or_update_user_profile(sender, instance, created, **kwargs): class Seshat_Expert(models.Model): + """ + Model representing a Seshat Expert. + """ SESHATADMIN = 'Seshat Admin' RA = 'RA' SESHATEXPERT = 'Seshat Expert' @@ -70,6 +87,9 @@ def __str__(self): # __unicode__ for Python 2 return self.user.username + " (" + self.role + ")" class Seshat_Task(models.Model): + """ + Model representing a Seshat Task. + """ giver = models.ForeignKey(Seshat_Expert, on_delete=models.CASCADE) taker = models.ManyToManyField(Seshat_Expert, related_name="%(app_label)s_%(class)s_related", related_query_name="%(app_label)s_%(class)ss", blank=True,) task_description = models.TextField( null=True, blank=True) @@ -77,10 +97,24 @@ class Seshat_Task(models.Model): def get_absolute_url(self): + """ + Returns the url to access a particular instance of the model. + + :noindex: + + Returns: + str: A string of the url to access a particular instance of the model. + """ return reverse('seshat_task-detail', args=[str(self.id)]) - + @property def display_takers(self): + """ + Returns a string of all takers of the task. + + Returns: + str: A string of all takers of the task, joined with a HTML tag ("
"). + """ all_takers = [] for taker in self.taker.all(): all_takers.append(taker.__str__()) @@ -88,6 +122,12 @@ def display_takers(self): @property def clickable_url(self): + """ + Returns a clickable URL. + + Returns: + str: A string of a clickable URL. + """ return f'{self.task_url}' def __str__(self): # __unicode__ for Python 2 diff --git a/seshat/apps/accounts/tests.py b/seshat/apps/accounts/tests.py index 7ce503c2d..e69de29bb 100644 --- a/seshat/apps/accounts/tests.py +++ b/seshat/apps/accounts/tests.py @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/seshat/apps/accounts/views.py b/seshat/apps/accounts/views.py index a4deb0224..965beb468 100644 --- a/seshat/apps/accounts/views.py +++ b/seshat/apps/accounts/views.py @@ -21,13 +21,50 @@ def accounts(request): + """ + View function for the accounts page. + + Note: + TODO: This seems like an unused function and it should be removed. + + Args: + request (HttpRequest): The request object. + + Returns: + HttpResponse: The response object. + """ return HttpResponse('

Hello Accounts.

') def accounts_new(request): + """ + View function for the accounts page. + + Note: + TODO: This seems like an unused function and it should be removed. + + Args: + request (HttpRequest): The request object. + + Returns: + HttpResponse: The response object. + """ return HttpResponse('

Hello Aiiiiiiiccounts.

') def has_add_scp_prv_permission(user): + """ + Function to check if a user has the 'core.add_seshatprivatecommentpart' permission. + + Note: + TODO: Investigate whether this function doubles with the functionality + of the 'permission_required' decorator. + + Args: + user (User): The user object. + + Returns: + bool: True if the user has the permission, False otherwise. + """ return user.has_perm('core.add_seshatprivatecommentpart') @@ -54,6 +91,9 @@ def has_add_scp_prv_permission(user): class ProfileUpdate(PermissionRequiredMixin, UpdateView): + """ + Generic class-based view for updating a user's profile. + """ model = Profile context_object_name = 'user' form_class = ProfileForm @@ -62,6 +102,17 @@ class ProfileUpdate(PermissionRequiredMixin, UpdateView): permission_required = 'core.add_seshatprivatecommentpart' def get_context_data(self, **kwargs): + """ + Get the context data of the view. + + :noindex: + + Args: + **kwargs: Arbitrary keyword arguments. + + Returns: + dict: The context data of the view. + """ context = super(ProfileUpdate, self).get_context_data(**kwargs) user = self.request.user context['profile_form'] = ProfileForm( @@ -69,8 +120,17 @@ def get_context_data(self, **kwargs): initial={'first_name': user.first_name, 'last_name': user.last_name}, ) return context - + def form_valid(self, form): + """ + Method for saving the form data. + + Args: + form (Form): The form object. + + Returns: + HttpResponseRedirect: The response object. + """ profile = form.save() user = profile.user user.last_name = form.cleaned_data['last_name'] @@ -87,6 +147,19 @@ def form_valid(self, form): @login_required @permission_required('core.add_seshatprivatecommentpart', raise_exception=True) def profile(request): + """ + View function for displaying a user's profile. + + Note: + This view requires that the user be logged in. + This view requires that the user have the 'core.add_seshatprivatecommentpart' permission. + + Args: + request (HttpRequest): The request object. + + Returns: + HttpResponse: The response object. + """ # all_vars = [] # a_huge_context_data_dic = {} #my_data = m.objects.filter(polity = polity_id) @@ -150,6 +223,9 @@ def profile(request): return render(request, 'registration/profile.html', context=context) class Seshat_taskCreate(PermissionRequiredMixin, CreateView): + """ + Generic class-based view for creating a task. + """ model = Seshat_Task form_class = Seshat_TaskForm template_name = "registration/seshat_task/seshat_task_form.html" @@ -169,6 +245,9 @@ class Seshat_taskCreate(PermissionRequiredMixin, CreateView): # paginate_by = 10 class Seshat_taskDetailView(generic.DetailView): + """ + Generic class-based detail view for a task. + """ model = Seshat_Task template_name = "registration/seshat_task/seshat_task_detail.html" @@ -187,9 +266,21 @@ class Seshat_taskDetailView(generic.DetailView): # return response -from .forms import CustomSignUpForm # Import your custom form +from .forms import CustomSignUpForm def signup(request): + """ + View function for signing up a new user. + + Note: + This view function handles both GET and POST requests. + + Args: + request (HttpRequest): The request object. + + Returns: + HttpResponse: The response object. + """ if request.method == 'POST': form = CustomSignUpForm(request.POST) if form.is_valid(): diff --git a/seshat/apps/core/context_processors.py b/seshat/apps/core/context_processors.py index 38d16d5da..37f56b276 100644 --- a/seshat/apps/core/context_processors.py +++ b/seshat/apps/core/context_processors.py @@ -1,7 +1,23 @@ from .models import SeshatPrivateCommentPart, Polity from ..accounts.models import Seshat_Expert + def notifications(request): + """ + Handle the notifications logic for authenticated users and fetch necessary + data. + + Args: + request (HttpRequest): The HTTP request object. + + Returns: + dict: A dictionary containing: + - 'notifications_count' (int): The number of private comments for + the authenticated user. + - 'all_polities' (QuerySet): A queryset of all polities. + - 'search_term' (str): The search term submitted in the request, + if any. + """ # Fetch the data you need #print("Halooooooooooooooooo") if request.user.is_authenticated: @@ -26,4 +42,3 @@ def notifications(request): 'all_polities': all_polities, 'search_term': search_term, } - diff --git a/seshat/apps/core/custom_filters.py b/seshat/apps/core/custom_filters.py index 2b63e64a5..adf25b7f3 100644 --- a/seshat/apps/core/custom_filters.py +++ b/seshat/apps/core/custom_filters.py @@ -4,9 +4,28 @@ @register.filter def get_attributes(obj): + """ + A custom filter to get all attributes of an object in a template. + + Args: + obj (object): The object to get attributes from. + + Returns: + dict: A dictionary of the object's attributes. + """ return vars(obj) @register.filter def zip_lists(a, b): + """ + A custom filter to zip two lists together in a template. + + Args: + a (list): The first list to zip. + b (list): The second list to zip. + + Returns: + zip: A zip object of the two lists. + """ return zip(a, b) diff --git a/seshat/apps/core/forms.py b/seshat/apps/core/forms.py index 8e8f8ee8c..d0a46ae11 100644 --- a/seshat/apps/core/forms.py +++ b/seshat/apps/core/forms.py @@ -19,7 +19,13 @@ #from .models import Religion class ReligionForm(forms.ModelForm): + """ + Form for adding or updating a new religion in the database. + """ class Meta: + """ + :noindex: + """ model = Religion fields = ['religion_name',] @@ -29,7 +35,13 @@ class Meta: class ReferenceForm(forms.ModelForm): + """ + Form for adding or updating a new reference in the database. + """ class Meta: + """ + :noindex: + """ model = Reference fields = ('title', 'year', 'creator', 'zotero_link', 'long_name') labels = { @@ -54,8 +66,13 @@ class Meta: class CitationForm(forms.ModelForm): - + """ + Form for adding or updating a new citation in the database. + """ class Meta: + """ + :noindex: + """ model = Citation fields = ('ref', 'page_from', 'page_to', ) labels = { @@ -71,6 +88,15 @@ class Meta: attrs={'class': 'form-control mb-3 fw-bold', }) } def clean(self): + """ + Check if the citation is a duplicate. + + Returns: + dict: The cleaned data. + + Raises: + ValidationError: If the citation is a duplicate. + """ cleaned_data = super(CitationForm, self).clean() cleaned_page_from = cleaned_data.get("page_from") cleaned_page_to = cleaned_data.get("page_to") @@ -87,7 +113,13 @@ def clean(self): class PolityForm(forms.ModelForm): + """ + Form for adding or updating a new polity in the database. + """ class Meta: + """ + :noindex: + """ model = Polity fields = ('name', 'new_name', 'long_name', 'start_year', 'end_year','home_seshat_region', 'polity_tag' , 'shapefile_name', 'private_comment','general_description') labels = { @@ -117,13 +149,19 @@ class Meta: 'home_seshat_region': forms.Select(attrs={'class': 'form-control js-example-basic-single form-select mb-3',}), 'polity_tag': forms.Select(attrs={'class': 'form-control form-select mb-3',}), 'shapefile_name': forms.TextInput(attrs={'class': 'form-control mb-3', }), - 'private_comment': forms.Textarea(attrs={'class': 'form-control', 'style': 'height: 100px', 'placeholder':'Add a private comment that will only be visible to Seshat experts and RAs.'}), + 'private_comment': forms.Textarea(attrs={'class': 'form-control', 'style': 'height: 100px', 'placeholder':'Add a private comment that will only be visible to Seshat experts and RAs.\nUse this box to request edits to the polity map data.'}), 'general_description': forms.Textarea(attrs={'class': 'form-control mb-3', 'style': 'height: 265px', 'placeholder':'Add a general description (optional)'}), } class PolityUpdateForm(forms.ModelForm): + """ + Form for adding or updating an existing polity in the database. + """ class Meta: + """ + :noindex: + """ model = Polity fields = ('name', 'new_name', 'long_name', 'start_year', 'end_year','home_seshat_region', 'polity_tag', 'shapefile_name', 'private_comment','general_description') labels = { @@ -153,14 +191,20 @@ class Meta: 'home_seshat_region': forms.Select(attrs={'class': 'form-control js-example-basic-single form-select mb-3',}), 'polity_tag': forms.Select(attrs={'class': 'form-control form-select mb-3',}), 'shapefile_name': forms.TextInput(attrs={'class': 'form-control mb-3', }), - 'private_comment': forms.Textarea(attrs={'class': 'form-control', 'style': 'height: 100px', 'placeholder':'Add a private comment that will only be visible to seshat experts and RAs.'}), + 'private_comment': forms.Textarea(attrs={'class': 'form-control', 'style': 'height: 100px', 'placeholder':'Add a private comment that will only be visible to seshat experts and RAs.\nUse this box to request edits to the polity map data.'}), 'general_description': forms.Textarea(attrs={'class': 'form-control mb-3', 'style': 'height: 265px', 'placeholder':'Add a general description (optional)'}), } class NgaForm(forms.ModelForm): + """ + Form for adding or updating a new NGA in the database. + """ class Meta: + """ + :noindex: + """ model = Nga fields = ('name', 'world_region', 'subregion', 'fao_country') labels = { @@ -181,7 +225,13 @@ class Meta: class CapitalForm(forms.ModelForm): + """ + Form for adding or updating a new capital in the database. + """ class Meta: + """ + :noindex: + """ model = Capital fields = ('name', 'latitude', 'longitude', 'current_country', 'alternative_names','is_verified', 'url_on_the_map', 'note') labels = { @@ -214,7 +264,13 @@ class Meta: class SeshatCommentForm(forms.ModelForm): + """ + Form for adding or updating a new comment in the database. + """ class Meta: + """ + :noindex: + """ model = SeshatComment fields = ('text',) labels = { @@ -225,7 +281,13 @@ class Meta: } class SeshatCommentPartForm(forms.ModelForm): + """ + Form for adding or updating a new comment part in the database. + """ class Meta: + """ + :noindex: + """ model = SeshatCommentPart fields = ('comment', 'comment_part_text', 'comment_citations', 'comment_order', 'comment_curator') labels = { @@ -247,7 +309,13 @@ class Meta: class SeshatPrivateCommentPartForm(forms.ModelForm): + """ + Form for adding or updating a new private comment part in the database. + """ class Meta: + """ + :noindex: + """ model = SeshatPrivateCommentPart fields = ('private_comment', 'private_comment_part_text', 'private_comment_owner', 'private_comment_reader') labels = { @@ -267,7 +335,13 @@ class Meta: class SeshatPrivateCommentForm(forms.ModelForm): + """ + Form for adding or updating a new private comment in the database. + """ class Meta: + """ + :noindex: + """ model = SeshatPrivateComment fields = ('text',) labels = { @@ -278,6 +352,9 @@ class Meta: } class ReferenceWithPageForm(forms.Form): + """ + Form for adding or updating a new reference with page numbers in the database. + """ ref = forms.ModelChoiceField( queryset=Reference.objects.all(), widget=forms.Select(attrs={'class': 'form-control form-select mb-1 js-example-basic-single', 'text':'ref',}), @@ -308,7 +385,20 @@ def __init__(self, *args, **kwargs): self.helper.add_input(Submit('submit', 'Submit')) class BaseReferenceFormSet(BaseFormSet): + """ + Base formset for adding or updating multiple references to a comment. + """ def add_fields(self, form, index): + """ + Add fields to the form. + + Args: + form (Form): The form to add fields to. + index (int): The index of the form. + + Returns: + None + """ super().add_fields(form, index) form.fields['ref'].widget.attrs['class'] = 'form-control form-select mb-1 p-1 js-example-basic-single' form.fields['page_from'].widget.attrs['class'] = 'form-control mb-1 p-1' @@ -389,6 +479,9 @@ def clean_email(self): return email class Meta: + """ + :noindex: + """ model = User fields = ('username', 'first_name', 'last_name', 'email', 'password1', 'password2', 'captcha') @@ -443,6 +536,9 @@ class VariablehierarchyFormNew(forms.Form): label=" Verified?", required=False, widget=forms.CheckboxInput(attrs={'type': 'checkbox', 'class': 'form-control form-check-input align-middle'})) class Meta: + """ + :noindex: + """ unique_together = ("variable_name", "section_name", "subsection_name") # VarHierFormSet = formset_factory(VariablehierarchyForm, extra=10) diff --git a/seshat/apps/crisisdb/mycodes/__init__.py b/seshat/apps/core/management/__init__.py similarity index 100% rename from seshat/apps/crisisdb/mycodes/__init__.py rename to seshat/apps/core/management/__init__.py diff --git a/seshat/apps/core/management/commands/__init__.py b/seshat/apps/core/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/seshat/apps/core/management/commands/populate_gadm.py b/seshat/apps/core/management/commands/populate_gadm.py new file mode 100644 index 000000000..d96eb5e4b --- /dev/null +++ b/seshat/apps/core/management/commands/populate_gadm.py @@ -0,0 +1,130 @@ +from django.db import connection +from django.contrib.gis.gdal import DataSource +from django.core.management.base import BaseCommand +from seshat.apps.core.models import GADMShapefile, GADMCountries, GADMProvinces + +class Command(BaseCommand): + help = 'Populates the GADMShapefile table with features from a GeoPackage' + + def add_arguments(self, parser): + parser.add_argument('gpkg_file', type=str, help='Path to the GeoPackage file') + + def handle(self, *args, **options): + + # Clear the GADMShapefile table + self.stdout.write(self.style.SUCCESS('Clearing GADMShapefile table...')) + GADMShapefile.objects.all().delete() + self.stdout.write(self.style.SUCCESS('GADMShapefile table cleared')) + # Clear the core_gadmcountries table + self.stdout.write(self.style.SUCCESS('Clearing core_gadmcountries table...')) + GADMCountries.objects.all().delete() + self.stdout.write(self.style.SUCCESS('core_gadmcountries table cleared')) + # Clear the core_gadmprovinces table + self.stdout.write(self.style.SUCCESS('Clearing core_gadmprovinces table...')) + GADMProvinces.objects.all().delete() + + gpkg_file = options['gpkg_file'] + + data_source = DataSource(gpkg_file) + layer = data_source[0] # Access the first layer in the GeoPackage + + for feature in layer: + geom = feature.geom # Retrieve the geometry of the feature + # Creating a GEOSGeometry object from the feature's geometry + geom_gis = geom.geos + + # Create an entry in the GADMShapefile model for each feature in the layer + self.stdout.write(self.style.SUCCESS(f"Inserting features into the GADMShapefile table for {feature.get('COUNTRY')}...")) + GADMShapefile.objects.create( + geom=geom_gis, + UID=feature.get('UID'), + GID_0=feature.get('GID_0'), + NAME_0=feature.get('NAME_0'), + VARNAME_0=feature.get('VARNAME_0'), + GID_1=feature.get('GID_1'), + NAME_1=feature.get('NAME_1'), + VARNAME_1=feature.get('VARNAME_1'), + NL_NAME_1=feature.get('NL_NAME_1'), + ISO_1=feature.get('ISO_1'), + HASC_1=feature.get('HASC_1'), + CC_1=feature.get('CC_1'), + TYPE_1=feature.get('TYPE_1'), + ENGTYPE_1=feature.get('ENGTYPE_1'), + VALIDFR_1=feature.get('VALIDFR_1'), + GID_2=feature.get('GID_2'), + NAME_2=feature.get('NAME_2'), + VARNAME_2=feature.get('VARNAME_2'), + NL_NAME_2=feature.get('NL_NAME_2'), + HASC_2=feature.get('HASC_2'), + CC_2=feature.get('CC_2'), + TYPE_2=feature.get('TYPE_2'), + ENGTYPE_2=feature.get('ENGTYPE_2'), + VALIDFR_2=feature.get('VALIDFR_2'), + GID_3=feature.get('GID_3'), + NAME_3=feature.get('NAME_3'), + VARNAME_3=feature.get('VARNAME_3'), + NL_NAME_3=feature.get('NL_NAME_3'), + HASC_3=feature.get('HASC_3'), + CC_3=feature.get('CC_3'), + TYPE_3=feature.get('TYPE_3'), + ENGTYPE_3=feature.get('ENGTYPE_3'), + VALIDFR_3=feature.get('VALIDFR_3'), + GID_4=feature.get('GID_4'), + NAME_4=feature.get('NAME_4'), + VARNAME_4=feature.get('VARNAME_4'), + CC_4=feature.get('CC_4'), + TYPE_4=feature.get('TYPE_4'), + ENGTYPE_4=feature.get('ENGTYPE_4'), + VALIDFR_4=feature.get('VALIDFR_4'), + GID_5=feature.get('GID_5'), + NAME_5=feature.get('NAME_5'), + CC_5=feature.get('CC_5'), + TYPE_5=feature.get('TYPE_5'), + ENGTYPE_5=feature.get('ENGTYPE_5'), + GOVERNEDBY=feature.get('GOVERNEDBY'), + SOVEREIGN=feature.get('SOVEREIGN'), + DISPUTEDBY=feature.get('DISPUTEDBY'), + REGION=feature.get('REGION'), + VARREGION=feature.get('VARREGION'), + COUNTRY=feature.get('COUNTRY'), + CONTINENT=feature.get('CONTINENT'), + SUBCONT=feature.get('SUBCONT') + ) + + self.stdout.write(self.style.SUCCESS(f"Inserted feature into the GADMShapefile table.")) + + self.stdout.write(self.style.SUCCESS(f"Successfully populated the GADMShapefile table.")) + + # Populate the core_gadmcountries and core_gadmprovinces table + # The 0.01 value is the simplification tolerance. + # Using a lower value will increase the resolution of the shapes used, but result in slower loading in the django app. + # Some smaller countries/provinces cannot be simplified with 0.01, so try 0.001. + self.stdout.write(self.style.SUCCESS(f"Populating the core_gadmcountries table...")) + with connection.cursor() as cursor: + cursor.execute(""" + INSERT INTO core_gadmcountries (geom, "COUNTRY") + SELECT + COALESCE(ST_Simplify(ST_Union(geom), 0.01), ST_Simplify(ST_Union(geom), 0.001)) AS geom, + "COUNTRY" + FROM + core_gadmshapefile + GROUP BY + "COUNTRY"; + """) + self.stdout.write(self.style.SUCCESS(f"Successfully populated the core_gadmcountries table.")) + + self.stdout.write(self.style.SUCCESS(f"Populating the core_gadmprovinces table...")) + with connection.cursor() as cursor: + cursor.execute(""" + INSERT INTO core_gadmprovinces (geom, "COUNTRY", "NAME_1", "ENGTYPE_1") + SELECT + COALESCE(ST_Simplify(ST_Union(geom), 0.01), ST_Simplify(ST_Union(geom), 0.001)) AS geom, + "COUNTRY", + "NAME_1", + "ENGTYPE_1" + FROM + core_gadmshapefile + GROUP BY + "COUNTRY", "NAME_1", "ENGTYPE_1"; + """) + self.stdout.write(self.style.SUCCESS(f"Successfully populated the core_gadmprovinces table.")) \ No newline at end of file diff --git a/seshat/apps/core/management/commands/populate_videodata.py b/seshat/apps/core/management/commands/populate_videodata.py new file mode 100644 index 000000000..495333312 --- /dev/null +++ b/seshat/apps/core/management/commands/populate_videodata.py @@ -0,0 +1,192 @@ +import os +import json +import fnmatch +from distinctipy import get_colors, get_hex +from django.contrib.gis.geos import GEOSGeometry, MultiPolygon +from django.core.management.base import BaseCommand +from django.db import connection +from seshat.apps.core.models import VideoShapefile + +class Command(BaseCommand): + help = 'Populates the database with Shapefiles' + + def add_arguments(self, parser): + parser.add_argument('dir', type=str, help='Directory containing geojson files') + + def handle(self, *args, **options): + dir = options['dir'] + + # Clear the VideoShapefile table + self.stdout.write(self.style.SUCCESS('Clearing VideoShapefile table...')) + VideoShapefile.objects.all().delete() + self.stdout.write(self.style.SUCCESS('VideoShapefile table cleared')) + + # Get the start and end years for each shape + # Load a file with 'name_years.json' in the filename kept in the same dir as the geojson files. + # Loads a dict of polity names and their start and end years. + # The values are lists of the form [[first_start_year, first_end_year], [second_start_year, second_end_year], ...] + + # List all files in the directory + files = os.listdir(dir) + + # Find the first file that includes 'name_years.json' in the filename + name_years_file = next((f for f in files if fnmatch.fnmatch(f, '*name_years.json*')), None) + + if name_years_file: + name_years_path = os.path.join(dir, name_years_file) + with open(name_years_path, 'r') as f: + name_years = json.load(f) + else: + self.stdout.write(self.style.ERROR("No file found with 'name_years.json' in the filename")) + + # Dict of all the shape years for a given polity + polity_years = {} + # Set of all polities, for generating colour mapping + all_polities = set() + # Dict of all the polities found and the shapes they include + polity_shapes = {} + # Iterate over files in the directory + for filename in os.listdir(dir): + if filename.endswith('.geojson'): + file_path = os.path.join(dir, filename) + + # Read and parse the GeoJSON file + with open(file_path, 'r') as geojson_file: + geojson_data = json.load(geojson_file) + + # Extract data and create VideoShapefile instances + for feature in geojson_data['features']: + properties = feature['properties'] + polity_name = properties['Name'].replace('(', '').replace(')', '') # Remove spaces and brackets from name + polity_colour_key = polity_name + try: + # If a shape has components we'll load the components instead + # ... unless the components have their own components, then load the top level shape + # ... or the shape is a personal union, then load the personal union shape + if properties['Components']: + if ';' not in properties['SeshatID']: + if len(properties['Components']) > 0 and '(' not in properties['Components']: + polity_name = None + except KeyError: + pass + try: + if properties['Member_of']: + # If a shape is a component, get the parent polity to use as the polity_colour_key + if len(properties['Member_of']) > 0: + polity_colour_key = properties['Member_of'].replace('(', '').replace(')', '') + except KeyError: + pass + if polity_name: + if properties['Type'] != 'POLITY': + polity_name = properties['Type'] + ': ' + polity_name + if polity_name not in polity_years: + polity_years[polity_name] = [] + polity_years[polity_name].append(properties['Year']) + if polity_colour_key not in polity_shapes: + polity_shapes[polity_colour_key] = [] + polity_shapes[polity_colour_key].append(feature) + + all_polities.add(polity_colour_key) + + self.stdout.write(self.style.SUCCESS(f'Found shape for {polity_name} ({properties["Year"]})')) + + # Sort the polities and generate a colour mapping + unique_polities = sorted(all_polities) + self.stdout.write(self.style.SUCCESS(f'Generating colour mapping for {len(unique_polities)} polities')) + pol_col_map = polity_colour_mapping(unique_polities) + self.stdout.write(self.style.SUCCESS(f'Colour mapping generated')) + + # Iterate through polity_shapes and create VideoShapefile instances + for polity_colour_key, features in polity_shapes.items(): + for feature in features: + properties = feature['properties'] + polity_name = properties["Name"].replace('(', '').replace(')', '') + if properties['Type'] != 'POLITY': + polity_name = properties['Type'] + ': ' + polity_name + self.stdout.write(self.style.SUCCESS(f'Importing shape for {polity_name} ({properties["Year"]})')) + + # Get a sorted list of the shape years this polity + this_polity_years = sorted(polity_years[polity_name]) + + # Get the end year for a shape + # Most of the time, the shape end year is the year of the next shape + # Some polities have a gap in their active years + # For a shape year at the start of a gap, set the end year to be the shape year, so it doesn't cover the inactive period + start_end_years = name_years[properties['Name']] + end_years = [x[1] for x in start_end_years] + + polity_start_year = start_end_years[0][0] + polity_end_year = end_years[-1] + + # Raise an error if the shape year is not the start year of the polity + if this_polity_years[0] != polity_start_year: + raise ValueError(f'First shape year for {polity_name} is not the start year of the polity') + + # Find the closest higher value from end_years to the shape year + next_end_year = min(end_years, key=lambda x: x if x >= properties['Year'] else float('inf')) + + if properties['Year'] in end_years: # If the shape year is in the list of polity end years, the start year is the end year + end_year = properties['Year'] + else: + this_year_index = this_polity_years.index(properties['Year']) + try: # Try to use the next shape year minus one as the end year if possible, unless it's higher than the next_end_year + next_shape_year_minus_one = this_polity_years[this_year_index + 1] - 1 + end_year = next_shape_year_minus_one if next_shape_year_minus_one < next_end_year else next_end_year + except IndexError: # Otherwise assume the end year of the shape is the end year of the polity + end_year = polity_end_year + + # Save geom and convert Polygon to MultiPolygon if necessary + geom = GEOSGeometry(json.dumps(feature['geometry'])) + if geom.geom_type == 'Polygon': + geom = MultiPolygon(geom) + + self.stdout.write(self.style.SUCCESS(f'Creating VideoShapefile instance for {polity_name} ({properties["Year"]} - {end_year})')) + + VideoShapefile.objects.create( + geom=geom, + name=polity_name, + polity=polity_colour_key, + wikipedia_name=properties['Wikipedia'], + seshat_id=properties['SeshatID'], + area=properties['Area'], + start_year=properties['Year'], + end_year=end_year, + polity_start_year=polity_start_year, + polity_end_year=polity_end_year, + colour=pol_col_map[polity_colour_key] + ) + + self.stdout.write(self.style.SUCCESS(f'Successfully imported shape for {polity_name} ({properties["Year"]})')) + + self.stdout.write(self.style.SUCCESS(f'Successfully imported all shapes for {polity_name}')) + + self.stdout.write(self.style.SUCCESS(f'Successfully imported all data from {filename}')) + + ########################################################### + ### Adjust the tolerance param of ST_Simplify as needed ### + ########################################################### + + self.stdout.write(self.style.SUCCESS('Adding simplified geometries for faster loading...')) + + ## Use this code if you want to simplify the geometries + # with connection.cursor() as cursor: + # cursor.execute(""" + # UPDATE core_videoshapefile + # SET simplified_geom = ST_Simplify(geom, 0.07); + # """) + + ## Use this code if you don't need to simplify the geometries + with connection.cursor() as cursor: + cursor.execute(""" + UPDATE core_videoshapefile + SET simplified_geom = geom; + """) + self.stdout.write(self.style.SUCCESS('Simplified geometries added')) + + +def polity_colour_mapping(polities): + """Use DistinctiPy package to assign a colour to each polity""" + colours = [] + for col in get_colors(len(polities)): + colours.append(get_hex(col)) + return dict(zip(polities, colours)) diff --git a/seshat/apps/core/migrations/0064_gadmcountries_gadmprovinces_gadmshapefile_and_more.py b/seshat/apps/core/migrations/0064_gadmcountries_gadmprovinces_gadmshapefile_and_more.py new file mode 100644 index 000000000..4ebcdce09 --- /dev/null +++ b/seshat/apps/core/migrations/0064_gadmcountries_gadmprovinces_gadmshapefile_and_more.py @@ -0,0 +1,109 @@ +# Generated by Django 4.0.3 on 2024-05-13 10:41 + +import django.contrib.gis.db.models.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0063_seshatprivatecommentpart_private_comment_reader'), + ] + + operations = [ + migrations.CreateModel( + name='GADMCountries', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('geom', django.contrib.gis.db.models.fields.MultiPolygonField(srid=4326)), + ('COUNTRY', models.CharField(max_length=100, null=True)), + ], + ), + migrations.CreateModel( + name='GADMProvinces', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('geom', django.contrib.gis.db.models.fields.MultiPolygonField(srid=4326)), + ('COUNTRY', models.CharField(max_length=100, null=True)), + ('NAME_1', models.CharField(max_length=100, null=True)), + ('ENGTYPE_1', models.CharField(max_length=100, null=True)), + ], + ), + migrations.CreateModel( + name='GADMShapefile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('geom', django.contrib.gis.db.models.fields.MultiPolygonField(srid=4326)), + ('UID', models.BigIntegerField()), + ('GID_0', models.CharField(max_length=100, null=True)), + ('NAME_0', models.CharField(max_length=100, null=True)), + ('VARNAME_0', models.CharField(max_length=100, null=True)), + ('GID_1', models.CharField(max_length=100, null=True)), + ('NAME_1', models.CharField(max_length=100, null=True)), + ('VARNAME_1', models.CharField(max_length=100, null=True)), + ('NL_NAME_1', models.CharField(max_length=100, null=True)), + ('ISO_1', models.CharField(max_length=100, null=True)), + ('HASC_1', models.CharField(max_length=100, null=True)), + ('CC_1', models.CharField(max_length=100, null=True)), + ('TYPE_1', models.CharField(max_length=100, null=True)), + ('ENGTYPE_1', models.CharField(max_length=100, null=True)), + ('VALIDFR_1', models.CharField(max_length=100, null=True)), + ('GID_2', models.CharField(max_length=100, null=True)), + ('NAME_2', models.CharField(max_length=100, null=True)), + ('VARNAME_2', models.CharField(max_length=100, null=True)), + ('NL_NAME_2', models.CharField(max_length=100, null=True)), + ('HASC_2', models.CharField(max_length=100, null=True)), + ('CC_2', models.CharField(max_length=100, null=True)), + ('TYPE_2', models.CharField(max_length=100, null=True)), + ('ENGTYPE_2', models.CharField(max_length=100, null=True)), + ('VALIDFR_2', models.CharField(max_length=100, null=True)), + ('GID_3', models.CharField(max_length=100, null=True)), + ('NAME_3', models.CharField(max_length=100, null=True)), + ('VARNAME_3', models.CharField(max_length=100, null=True)), + ('NL_NAME_3', models.CharField(max_length=100, null=True)), + ('HASC_3', models.CharField(max_length=100, null=True)), + ('CC_3', models.CharField(max_length=100, null=True)), + ('TYPE_3', models.CharField(max_length=100, null=True)), + ('ENGTYPE_3', models.CharField(max_length=100, null=True)), + ('VALIDFR_3', models.CharField(max_length=100, null=True)), + ('GID_4', models.CharField(max_length=100, null=True)), + ('NAME_4', models.CharField(max_length=100, null=True)), + ('VARNAME_4', models.CharField(max_length=100, null=True)), + ('CC_4', models.CharField(max_length=100, null=True)), + ('TYPE_4', models.CharField(max_length=100, null=True)), + ('ENGTYPE_4', models.CharField(max_length=100, null=True)), + ('VALIDFR_4', models.CharField(max_length=100, null=True)), + ('GID_5', models.CharField(max_length=100, null=True)), + ('NAME_5', models.CharField(max_length=100, null=True)), + ('CC_5', models.CharField(max_length=100, null=True)), + ('TYPE_5', models.CharField(max_length=100, null=True)), + ('ENGTYPE_5', models.CharField(max_length=100, null=True)), + ('GOVERNEDBY', models.CharField(max_length=100, null=True)), + ('SOVEREIGN', models.CharField(max_length=100, null=True)), + ('DISPUTEDBY', models.CharField(max_length=100, null=True)), + ('REGION', models.CharField(max_length=100, null=True)), + ('VARREGION', models.CharField(max_length=100, null=True)), + ('COUNTRY', models.CharField(max_length=100, null=True)), + ('CONTINENT', models.CharField(max_length=100, null=True)), + ('SUBCONT', models.CharField(max_length=100, null=True)), + ], + ), + migrations.CreateModel( + name='VideoShapefile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('geom', django.contrib.gis.db.models.fields.MultiPolygonField(srid=4326)), + ('simplified_geom', django.contrib.gis.db.models.fields.MultiPolygonField(null=True, srid=4326)), + ('name', models.CharField(max_length=100)), + ('polity', models.CharField(max_length=100)), + ('wikipedia_name', models.CharField(max_length=100, null=True)), + ('seshat_id', models.CharField(max_length=100)), + ('area', models.FloatField()), + ('start_year', models.IntegerField()), + ('end_year', models.IntegerField()), + ('polity_start_year', models.IntegerField()), + ('polity_end_year', models.IntegerField()), + ('colour', models.CharField(max_length=7)), + ], + ), + ] diff --git a/seshat/apps/core/migrations/0065_alter_videoshapefile_id.py b/seshat/apps/core/migrations/0065_alter_videoshapefile_id.py new file mode 100644 index 000000000..eb3c28094 --- /dev/null +++ b/seshat/apps/core/migrations/0065_alter_videoshapefile_id.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.3 on 2024-05-22 14:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0064_gadmcountries_gadmprovinces_gadmshapefile_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='videoshapefile', + name='id', + field=models.AutoField(primary_key=True, serialize=False), + ), + ] diff --git a/seshat/apps/core/models.py b/seshat/apps/core/models.py index 6c0b11deb..5aa8cf3d9 100644 --- a/seshat/apps/core/models.py +++ b/seshat/apps/core/models.py @@ -1,4 +1,4 @@ -from django.db import models +from django.contrib.gis.db import models from django.db.models.fields.related import ManyToManyField from django.contrib.auth.models import User from django.utils.safestring import mark_safe @@ -26,6 +26,15 @@ from django.core.validators import URLValidator def give_me_a_color_for_expert(value): + """ + Returns a color for a given expert. + + Args: + value (int): The id of the expert. + + Returns: + str: A color for the expert. + """ light_colors = [ '#e6b8af', '#f4cccc', @@ -143,20 +152,56 @@ def give_me_a_color_for_expert(value): ) def return_citations_for_comments(self): + """ + This function is used to return the citations of the model instance + (returning the value used in the display_citations method of the model + instance). + + Note: + The model instance must have the following attribute: + - citations + + The model instance must have the following methods: + - zoteroer + + Args: + self (model instance): The model instance. + + Returns: + str: The citations of the model instance, separated by comma. + """ if self.comment_citations.all(): return ', '.join([' ' + citation.citation_short_title + '' for citation in self.comment_citations.all()]) def return_number_of_citations_for_comments(self): + """ + Returns the number of citations for a comment. + + Returns: + int: The number of citations for a comment. + """ if self.comment_citations.all(): return len(self.comment_citations.all()) return 0 def return_citations_plus_for_comments(self): + """ + Returns a string of all the citations for a comment. + + Returns: + str: A string of all the citations for a comment. + """ get_scp_tr = ScpThroughCtn.objects.filter(seshatcommentpart=self.id) if get_scp_tr: return ', '.join([' ' + x.citation.citation_short_title + '' for x in get_scp_tr]) def return_number_of_citations_plus_for_comments(self): + """ + Returns the number of citations for a comment. + + Returns: + int: The number of citations for a comment. + """ get_scp_tr = ScpThroughCtn.objects.filter(seshatcommentpart=self.id) if get_scp_tr: return len(get_scp_tr) @@ -166,6 +211,9 @@ def return_number_of_citations_plus_for_comments(self): class SeshatPrivateComment(models.Model): + """ + Model representing a private comment. + """ text = models.TextField(blank=True, null=True,) def __str__(self) -> str: @@ -187,9 +235,20 @@ def __str__(self) -> str: return f'{to_be_shown}' def get_absolute_url(self): + """ + Returns the url to access a particular instance of the model. + + :noindex: + + Returns: + str: A string of the url to access a particular instance of the model. + """ return reverse('seshatprivatecomments') class SeshatPrivateCommentPart(models.Model): + """ + Model representing a part of a private comment. + """ private_comment = models.ForeignKey(SeshatPrivateComment, on_delete=models.SET_NULL, related_name="inner_private_comments_related", related_query_name="inner_private_comments_related", null=True, blank=True) private_comment_part_text = models.TextField(blank=True, null=True,) @@ -202,13 +261,23 @@ class SeshatPrivateCommentPart(models.Model): last_modified_date = models.DateTimeField(auto_now=True, blank=True, null=True) def get_absolute_url(self): + """ + Returns the url to access a particular instance of the model. + + :noindex: + + Returns: + str: A string of the url to access a particular instance of the model. + """ return reverse('seshatprivatecomment-update', args=[str(self.private_comment.id)]) class Meta: + """ + :noindex: + """ ordering = ["created_date", "last_modified_date"] def __str__(self) -> str: - """string for epresenting the model obj in Admin Site""" if self.private_comment_part_text: return self.private_comment_part_text else: @@ -216,20 +285,32 @@ def __str__(self) -> str: class Macro_region(models.Model): + """ + Model representing a macro region. + """ name = models.CharField(max_length=100) class Meta: + """ + :noindex: + """ ordering = ['name',] def __str__(self): return self.name class Seshat_region(models.Model): + """ + Model representing a Seshat region. + """ name = models.CharField(max_length=100) mac_region = models.ForeignKey(Macro_region, on_delete=models.SET_NULL, null=True, blank=True, related_name="mac_region") subregions_list = models.CharField(max_length=500, blank=True, null=True) class Meta: + """ + :noindex: + """ ordering = ['mac_region__name', 'name'] def __str__(self): @@ -239,6 +320,9 @@ def __str__(self): class Nga(models.Model): + """ + Model representing a NGA. + """ name = models.CharField(max_length=100) subregion = models.CharField(max_length=100, blank=True, null=True) longitude = models.DecimalField(max_digits= 16, decimal_places = 12, blank=True, null=True) @@ -249,18 +333,31 @@ class Nga(models.Model): world_region = models.CharField(max_length=100, choices=WORLD_REGION_CHOICES, default="Europe", null=True, blank=True) class Meta: + """ + :noindex: + """ ordering = ['name'] def get_absolute_url(self): + """ + Returns the url to access a particular instance of the model. + + :noindex: + + Returns: + str: A string of the url to access a particular instance of the model. + """ return reverse('ngas') def __str__(self) -> str: - """string for epresenting the model obj in Admin Site""" return self.name class Polity(models.Model): + """ + Model representing a polity. + """ name = models.CharField(max_length=100) start_year = models.IntegerField(blank=True, null=True) end_year = models.IntegerField(blank=True, null=True) @@ -279,10 +376,26 @@ class Polity(models.Model): modified_date = models.DateTimeField(auto_now=True, blank=True, null=True) class Meta: + """ + :noindex: + """ verbose_name = 'polity' verbose_name_plural = 'polities' + unique_together = ("name",) + ordering = ['long_name'] def clean(self): + """ + Verifies a number of conditions on the start and end years of the polity. + + Raises: + ValidationError: If the start year is greater than the end year. + ValidationError: If the end year is greater than the current year. + ValidationError: If the start year is greater than the current year. + + Returns: + None + """ current_year = date.today().year if self.start_year is not None and self.end_year is not None and self.start_year > self.end_year: raise ValidationError("Start year cannot be greater than end year.") @@ -292,18 +405,16 @@ def clean(self): raise ValidationError("Start year cannot be greater than the current year") def __str__(self) -> str: - """string for epresenting the model obj in Admin Site""" if self.long_name and self.new_name: return f"{self.long_name} ({self.new_name})" else: return self.name - - class Meta: - unique_together = ("name",) - ordering = ['long_name'] class Capital(models.Model): + """ + Model representing a capital. + """ name = models.CharField(max_length=100) alternative_names = models.CharField(max_length=300, blank=True, null=True) current_country = models.CharField(max_length=100, blank=True, null=True) @@ -319,19 +430,33 @@ class Capital(models.Model): blank=True, null=True,) def get_absolute_url(self): + """ + Returns the url to access a particular instance of the model. + + :noindex: + + Returns: + str: A string of the url to access a particular instance of the model. + """ return reverse('capitals') def __str__(self) -> str: - """string for epresenting the model obj in Admin Site""" if self.name and self.alternative_names: return str(self.name) + " [" + str(self.alternative_names) + "]" return self.name class Meta: - #ordering = ['-year'] - ordering = ['is_verified'] + """ + :noindex: + """ + + #ordering = ['-year'] + ordering = ['is_verified'] class Ngapolityrel(models.Model): + """ + Model representing a relationship between a NGA and a polity. + """ name = models.CharField(max_length=200, blank=True, null=True) polity_party = models.ForeignKey(Polity, on_delete=models.SET_NULL, null=True, related_name="polity_sides") nga_party = models.ForeignKey(Nga, on_delete=models.SET_NULL, null=True, related_name="nga_sides") @@ -340,7 +465,6 @@ class Ngapolityrel(models.Model): is_home_nga = models.BooleanField(default=False, blank=True, null=True) def __str__(self) -> str: - """string for epresenting the model obj in Admin Site""" if self.name: return self.name elif self.polity_party and self.nga_party: @@ -349,43 +473,56 @@ def __str__(self) -> str: return str(self.id) class Country(models.Model): + """ + Model representing a country. + """ name = models.CharField(max_length=200) polity = models.ForeignKey( Polity, on_delete=models.SET_NULL, null=True, related_name="countries") class Meta: + """ + :noindex: + """ verbose_name = 'country' verbose_name_plural = 'countries' + unique_together = ("name",) def __str__(self) -> str: - """string for epresenting the model obj in Admin Site""" return self.name - - class Meta: - unique_together = ("name",) class Section(models.Model): + """ + Model representing a section. + """ name = models.CharField(max_length=200) def __str__(self) -> str: - """string for epresenting the model obj in Admin Site""" return self.name class Meta: + """ + :noindex: + """ unique_together = ("name",) class Subsection(models.Model): + """ + Model representing a subsection. + """ name = models.CharField(max_length=200) section = models.ForeignKey( Section, on_delete=models.SET_NULL, null=True, related_name="subsections") def __str__(self) -> str: - """string for epresenting the model obj in Admin Site""" return self.name - + class Meta: + """ + :noindex: + """ unique_together = ("name", "section") @@ -422,6 +559,9 @@ class Meta: class Variablehierarchy(models.Model): + """ + Model representing a variable hierarchy. + """ name = models.CharField( max_length=200) section = models.ForeignKey( @@ -432,15 +572,19 @@ class Variablehierarchy(models.Model): explanation = models.TextField(blank=True, null=True,) def __str__(self) -> str: - """string for epresenting the model obj in Admin Site""" return self.name class Meta: + """ + :noindex: + """ unique_together = ("name", "section", "subsection") class Reference(models.Model): - """Model Representing a Reference""" + """ + Model Representing a reference. + """ title = models.CharField(max_length=500,) year = models.IntegerField(blank=True, null=True, ) creator = models.CharField(max_length=500, ) @@ -451,7 +595,6 @@ class Reference(models.Model): modified_date = models.DateTimeField(auto_now=True, blank=True, null=True) def __str__(self) -> str: - """string for epresenting the model obj in Admin Site""" original_title = self.title if len(original_title) > 60: shorter_title = original_title[0:60] + original_title[60:].split(" ")[0] + " ..." @@ -462,10 +605,16 @@ def __str__(self) -> str: else: return "(%s_XXXX): %s" % (self.creator, shorter_title,) - @property def reference_short_title(self): - """Second String for representing the Model Object""" + """ + Returns a short title for the reference. If the title is longer than + 60 characters, it is truncated. If the title is not provided, a default + title is returned. + + Returns: + str: A short title for the reference. + """ original_long_name = self.long_name if original_long_name and len(original_long_name) > 60: @@ -483,17 +632,30 @@ def reference_short_title(self): return "NO_TITLES_PROVIDED" def get_absolute_url(self): + """ + Returns the url to access a particular instance of the model. + + :noindex: + + Returns: + str: A string of the url to access a particular instance of the model. + """ return reverse('references') class Meta: - #ordering = ['-year'] - unique_together = ("zotero_link",) - ordering = ['-created_date', 'title'] - + """ + :noindex: + """ + # ordering = ['-year'] + unique_together = ("zotero_link",) + ordering = ['-created_date', 'title'] class Citation(models.Model): - """Model representing a specific citation.""" + """ + Model representing a specific citation. + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, help_text="Unique Id for this particular citation") ref = models.ForeignKey( @@ -509,6 +671,12 @@ class Citation(models.Model): # ("can_renew", "Can Renew A Book"),) def zoteroer(self): + """ + Returns the Zotero link for the citation. + + Returns: + str: The Zotero link for the citation. + """ if self.ref.zotero_link and "NOZOTERO_LINK" not in self.ref.zotero_link: my_zotero_link = "https://www.zotero.org/groups/1051264/seshat_databank/items/" + \ str(self.ref.zotero_link) @@ -522,7 +690,6 @@ def zoteroer(self): # return(str(self.page_to)) def __str__(self) -> str: - """String for representing the Model Object""" if self.ref and self.ref.title: original_title = self.ref.title else: @@ -539,12 +706,12 @@ def __str__(self) -> str: else: original_long_name = "REFERENCE_WITH_NO_LONG_NAME" if original_long_name and len(original_long_name) > 50: - shorter_name = original_long_name[0:50] + original_long_name[50:].split(" ")[0] + "..." + shorter_name = original_long_name[0:50] + original_long_name[50:].split(" ")[0] + "..." elif original_long_name: - shorter_name = original_long_name + shorter_name = original_long_name else: shorter_name = "BlaBla" - + if self.ref and self.ref.zotero_link and "NOZOTERO_LINK" in self.ref.zotero_link: return f'(NOZOTERO: {shorter_name})' if self.ref and self.ref.creator: @@ -563,11 +730,19 @@ def __str__(self) -> str: print(self.id) print(self.modified_date) - return "BADBADREFERENCE" - + def full_citation_display(self) -> str: - """String for representing the Model Object""" + """ + Returns a string of the full citation. If the citation has a title, it + is included in the string. If the citation has a creator, it is + included in the string. If the citation has a year, it is included in + the string. If the citation has a page_from, it is included in the + string. If the citation has a page_to, it is included in the string. + + Returns: + str: A string of the full citation. + """ if self.ref and self.ref.title: original_title = self.ref.title else: @@ -582,10 +757,10 @@ def full_citation_display(self) -> str: else: original_long_name = "REFERENCE_WITH_NO_LONG_NAME" if original_long_name: - shorter_name = original_long_name + shorter_name = original_long_name else: shorter_name = "BlaBla" - + if self.ref and self.ref.zotero_link and "NOZOTERO_LINK" in self.ref.zotero_link: return f'(NOZOTERO: {shorter_name})' if self.ref and self.ref.creator: @@ -604,13 +779,15 @@ def full_citation_display(self) -> str: print(self.id) print(self.modified_date) - return "BADBADREFERENCE" - + class Meta: - #ordering = ['-year'] - ordering = ['-modified_date'] - constraints = [ + """ + :noindex: + """ + #ordering = ['-year'] + ordering = ['-modified_date'] + constraints = [ models.UniqueConstraint( name="No_PAGE_TO_AND_FROM", fields=("ref",), @@ -626,18 +803,25 @@ class Meta: fields=("ref", "page_to"), condition=Q(page_from__isnull=True) ), - ] - #unique_together = ["ref", "page_from", "page_to"] - + ] + #unique_together = ["ref", "page_from", "page_to"] + @property def citation_short_title(self): - """Second String for representing the Model Object""" + """ + Returns a short title for the citation. If the title is longer than + 40 characters, it is truncated. If the title is not provided, a default + title is returned. + + Returns: + str: A short title for the citation. + """ original_long_name = self.ref.long_name if original_long_name and len(original_long_name) > 40: - shorter_name = original_long_name[0:40] + original_long_name[40:].split(" ")[0] + "..." + shorter_name = original_long_name[0:40] + original_long_name[40:].split(" ")[0] + "..." elif original_long_name: - shorter_name = original_long_name + shorter_name = original_long_name else: shorter_name = "BlaBla" @@ -656,25 +840,54 @@ def citation_short_title(self): return '[{0} {1}]'.format(self.ref.creator, self.ref.year) def get_absolute_url(self): + """ + Returns the url to access a particular instance of the model. + + :noindex: + + Returns: + str: A string of the url to access a particular instance of the model. + """ return reverse('citations') def save(self, *args, **kwargs): + """ + Saves the citation to the database. + + Args: + *args: Additional arguments. + **kwargs: Additional keyword arguments. + + Raises: + IntegrityError: If the citation cannot be saved to the database. + + Returns: + None + """ try: super(Citation, self).save(*args, **kwargs) except IntegrityError as e: print(e) class SeshatComment(models.Model): + """ + Model representing a comment. + """ text = models.TextField(blank=True, null=True,) def zoteroer(self): + """ + Returns the Zotero link for the comment. + + Returns: + str: The Zotero link for the comment. + """ if self.ref.zotero_link and "NOZOTERO_LINK" not in self.ref.zotero_link: my_zotero_link = "https://www.zotero.org/groups/1051264/seshat_databank/items/" + \ str(self.ref.zotero_link) else: my_zotero_link = "#" return my_zotero_link - def __str__(self) -> str: all_comment_parts = self.inner_comments_related.all().order_by('comment_order') @@ -712,10 +925,21 @@ def __str__(self) -> str: return f'{to_be_shown}' def get_absolute_url(self): + """ + Returns the url to access a particular instance of the model. + + :noindex: + + Returns: + str: A string of the url to access a particular instance of the model. + """ return reverse('seshatcomments') class SeshatCommentPart(models.Model): + """ + Model representing a part of a comment. + """ comment = models.ForeignKey(SeshatComment, on_delete=models.SET_NULL, related_name="inner_comments_related", related_query_name="inner_comments_related", null=True, blank=True) comment_part_text = models.TextField(blank=True, null=True,) @@ -730,17 +954,46 @@ class SeshatCommentPart(models.Model): citation_index = models.IntegerField(blank=True, null=True) modified_date = models.DateTimeField(auto_now=True, blank=True, null=True) - @property def display_citations(self): + """ + Display the citations of the model instance. + + :noindex: + + Note: + The method is a property, and an alias for the + return_citations_for_comments function. + + Returns: + str: The citations of the model instance, separated by comma. + """ return return_citations_for_comments(self) - + @property def citations_count(self): + """ + Returns the number of citations for a comment. + + Returns: + int: The number of citations for a comment. + """ return return_number_of_citations_for_comments(self) @property def display_citations_plus(self): + """ + Returns a string of all the citations for a comment. + + :noindex: + + Note: + The method is a property, and an alias for the + return_citations_for_comments function. + + Returns: + str: A string of all the citations for a comment. + """ if return_citations_plus_for_comments(self) and return_citations_for_comments(self): return return_citations_plus_for_comments(self) + return_citations_for_comments(self) elif return_citations_plus_for_comments(self): @@ -750,17 +1003,33 @@ def display_citations_plus(self): @property def citations_count_plus(self): + """ + Returns the number of citations for a comment. + + Returns: + int: The number of citations for a comment. + """ return return_number_of_citations_plus_for_comments(self) def get_absolute_url(self): + """ + Returns the url to access a particular instance of the model. + + :noindex: + + Returns: + str: A string of the url to access a particular instance of the model. + """ return reverse('seshatcomment-update', args=[str(self.comment.id)]) class Meta: + """ + :noindex: + """ ordering = ['comment_order', "modified_date"] #ordering = ["modified_date"] def __str__(self) -> str: - """string for epresenting the model obj in Admin Site""" if self.comment_part_text and self.display_citations_plus: return self.comment_part_text + ' ' + self.display_citations_plus elif self.comment_part_text and self.display_citations: @@ -772,6 +1041,10 @@ def __str__(self) -> str: class ScpThroughCtn(models.Model): + """ + Model representing a through model for the many-to-many relationship between + a comment part and a citation. + """ seshatcommentpart = models.ForeignKey(SeshatCommentPart, on_delete=models.CASCADE, related_name="%(app_label)s_%(class)s_related", related_query_name="%(app_label)s_%(class)s", null=True, blank=True) citation = models.ForeignKey(Citation, on_delete=models.CASCADE, related_name="%(app_label)s_%(class)s_related", @@ -780,6 +1053,9 @@ class ScpThroughCtn(models.Model): class SeshatCommon(models.Model): + """ + Model representing a common Seshat model. + """ polity = models.ForeignKey(Polity, on_delete=models.SET_NULL, related_name="%(app_label)s_%(class)s_related", related_query_name="%(app_label)s_%(class)s", null=True, blank=True) name = models.CharField( @@ -809,6 +1085,9 @@ class SeshatCommon(models.Model): private_comment = models.ForeignKey(SeshatPrivateComment, on_delete=models.DO_NOTHING, related_name="%(app_label)s_%(class)s_related", related_query_name="%(app_label)s_%(class)s", null=True, blank=True) class Meta: + """ + :noindex: + """ abstract = True ordering = ['polity'] @@ -819,19 +1098,129 @@ class Meta: # job_category = models.CharField(choices=job_category_annual_wages_choices) # job_description = models.CharField( # choices=job_description_annual_wages_choices) - - + class Religion(models.Model): + """ + Model representing a religion. + """ name = models.CharField(max_length=100, default="Religion") religion_name = models.CharField(max_length=100, null=True, blank=True) religion_family = models.CharField(max_length=100, blank=True, null=True) religion_genus = models.CharField(max_length=100, blank=True, null=True) class Meta: + """ + :noindex: + """ ordering = ['name'] def __str__(self) -> str: - """string for epresenting the model obj in Admin Site""" if self.religion_name: return self.religion_name - return self.name \ No newline at end of file + return self.name + +# Shapefile models + +class VideoShapefile(models.Model): + """ + Model representing a video shapefile. + """ + id = models.AutoField(primary_key=True) + geom = models.MultiPolygonField() + simplified_geom = models.MultiPolygonField(null=True) + name=models.CharField(max_length=100) + polity=models.CharField(max_length=100) + wikipedia_name=models.CharField(max_length=100, null=True) + seshat_id=models.CharField(max_length=100) + area=models.FloatField() + start_year=models.IntegerField() + end_year=models.IntegerField() + polity_start_year=models.IntegerField() + polity_end_year=models.IntegerField() + colour=models.CharField(max_length=7) + + def __str__(self): + return "Name: %s" % self.name + +class GADMShapefile(models.Model): + """ + + """ + geom = models.MultiPolygonField() + UID=models.BigIntegerField() + GID_0=models.CharField(max_length=100, null=True) + NAME_0=models.CharField(max_length=100, null=True) + VARNAME_0=models.CharField(max_length=100, null=True) + GID_1=models.CharField(max_length=100, null=True) + NAME_1=models.CharField(max_length=100, null=True) + VARNAME_1=models.CharField(max_length=100, null=True) + NL_NAME_1=models.CharField(max_length=100, null=True) + ISO_1=models.CharField(max_length=100, null=True) + HASC_1=models.CharField(max_length=100, null=True) + CC_1=models.CharField(max_length=100, null=True) + TYPE_1=models.CharField(max_length=100, null=True) + ENGTYPE_1=models.CharField(max_length=100, null=True) + VALIDFR_1=models.CharField(max_length=100, null=True) + GID_2=models.CharField(max_length=100, null=True) + NAME_2=models.CharField(max_length=100, null=True) + VARNAME_2=models.CharField(max_length=100, null=True) + NL_NAME_2=models.CharField(max_length=100, null=True) + HASC_2=models.CharField(max_length=100, null=True) + CC_2=models.CharField(max_length=100, null=True) + TYPE_2=models.CharField(max_length=100, null=True) + ENGTYPE_2=models.CharField(max_length=100, null=True) + VALIDFR_2=models.CharField(max_length=100, null=True) + GID_3=models.CharField(max_length=100, null=True) + NAME_3=models.CharField(max_length=100, null=True) + VARNAME_3=models.CharField(max_length=100, null=True) + NL_NAME_3=models.CharField(max_length=100, null=True) + HASC_3=models.CharField(max_length=100, null=True) + CC_3=models.CharField(max_length=100, null=True) + TYPE_3=models.CharField(max_length=100, null=True) + ENGTYPE_3=models.CharField(max_length=100, null=True) + VALIDFR_3=models.CharField(max_length=100, null=True) + GID_4=models.CharField(max_length=100, null=True) + NAME_4=models.CharField(max_length=100, null=True) + VARNAME_4=models.CharField(max_length=100, null=True) + CC_4=models.CharField(max_length=100, null=True) + TYPE_4=models.CharField(max_length=100, null=True) + ENGTYPE_4=models.CharField(max_length=100, null=True) + VALIDFR_4=models.CharField(max_length=100, null=True) + GID_5=models.CharField(max_length=100, null=True) + NAME_5=models.CharField(max_length=100, null=True) + CC_5=models.CharField(max_length=100, null=True) + TYPE_5=models.CharField(max_length=100, null=True) + ENGTYPE_5=models.CharField(max_length=100, null=True) + GOVERNEDBY=models.CharField(max_length=100, null=True) + SOVEREIGN=models.CharField(max_length=100, null=True) + DISPUTEDBY=models.CharField(max_length=100, null=True) + REGION=models.CharField(max_length=100, null=True) + VARREGION=models.CharField(max_length=100, null=True) + COUNTRY=models.CharField(max_length=100, null=True) + CONTINENT=models.CharField(max_length=100, null=True) + SUBCONT=models.CharField(max_length=100, null=True) + + def __str__(self): + return "Name: %s" % self.name + +class GADMCountries(models.Model): + """ + Model representing a country (GADM). + """ + geom = models.MultiPolygonField() + COUNTRY=models.CharField(max_length=100, null=True) + + def __str__(self): + return "Name: %s" % self.name + +class GADMProvinces(models.Model): + """ + Model representing a province (GADM). + """ + geom = models.MultiPolygonField() + COUNTRY=models.CharField(max_length=100, null=True) + NAME_1=models.CharField(max_length=100, null=True) + ENGTYPE_1=models.CharField(max_length=100, null=True) + + def __str__(self): + return "Name: %s" % self.name diff --git a/seshat/apps/core/signals.py b/seshat/apps/core/signals.py index 01afa2746..96a217484 100644 --- a/seshat/apps/core/signals.py +++ b/seshat/apps/core/signals.py @@ -6,6 +6,18 @@ @receiver(post_save, sender=SeshatCommentPart) def update_subcomment_ordering(sender, instance, **kwargs): + """ + A signal to update the ordering of subcomments when a new subcomment is + created or an existing subcomment is updated. + + Args: + sender (SeshatCommentPart): The sender of the signal. + instance (SeshatCommentPart): The instance of the subcomment. + **kwargs: Arbitrary keyword arguments. + + Returns: + None + """ if not instance.pk: last_subcomment = instance.comment.inner_comments_related.last() instance.comment_order = last_subcomment.comment_order + 1 if last_subcomment else 0 diff --git a/seshat/apps/core/static/core/js/map_functions.js b/seshat/apps/core/static/core/js/map_functions.js new file mode 100644 index 000000000..f6a9b00b1 --- /dev/null +++ b/seshat/apps/core/static/core/js/map_functions.js @@ -0,0 +1,407 @@ +function updateSliderOutput() { + if (slider.value < 0) { + output.innerHTML = Math.abs(slider.value) + ' BCE'; + } else { + output.innerHTML = slider.value + ' CE'; + } +} + +function adjustSliderUp() { + slider.value = Number(slider.value) + 1; + enterYearInput.value = slider.value; // Sync enterYear input with dateSlide value + updateSliderOutput(); // Update the displayed year + plotPolities(); // This function is defined differently in the world_map and polity_map templates +} + +function adjustSliderDown() { + slider.value = Number(slider.value) - 1; + enterYearInput.value = slider.value; // Sync enterYear input with dateSlide value + updateSliderOutput(); // Update the displayed year + plotPolities(); // This function is defined differently in the world_map and polity_map templates +} + +function updateSliderValue(value) { + var sliderValue = document.getElementById('sliderValue'); + switch (value) { + case '1': + sliderValue.textContent = '1 y/s'; // See the values in the startPlay function below + break; + case '2': + sliderValue.textContent = '5 y/s'; + break; + case '3': + sliderValue.textContent = '20 y/s'; + break; + case '4': + sliderValue.textContent = '50 y/s'; + break; + case '5': + sliderValue.textContent = '100 y/s'; + break; + } + plotPolities(); +} + +function setSliderTicks (tickYears) { + var datalist = document.getElementById('yearTickmarks'); + var tickmarkValuesDiv = document.getElementById('yearTickmarkValues'); + + // If the data list already has options, remove them + while (datalist.firstChild) { + datalist.removeChild(datalist.firstChild); + }; + // If the tickmark values div already has spans, remove them + while (tickmarkValuesDiv.firstChild) { + tickmarkValuesDiv.removeChild(tickmarkValuesDiv.firstChild); + }; + + // Loop to add tickmarks + i = 0; + for (const tickValue of tickYears) { + var option = document.createElement('option'); + option.value = tickValue; + datalist.appendChild(option); + + // Create and add corresponding span for tickmark labels + var span = document.createElement('span'); + span.textContent = tickValue; + span.style.position = 'absolute'; + span.style.textAlign = 'center'; + + // Use transform to center the span over the tickmark, with special handling for the first and last span + var leftPercentage = (i / (tickYears.length - 1) * 100); + span.style.left = `${leftPercentage}%`; + if (i === 0) { + span.style.transform = 'translateX(0%)'; // No translation for the first span + span.style.textAlign = 'left'; // Align text to the left for the first span + } else if (i === (tickYears.length - 1)) { + span.style.transform = 'translateX(-100%)'; // Adjust the last span to prevent overflow + } else { + span.style.transform = 'translateX(-50%)'; // Center all other spans + } + tickmarkValuesDiv.appendChild(span); + i++; + } +}; + +function startPlay() { + stopPlay(); // Clear existing interval before starting a new one + + var animationSpeed = parseFloat(playRateInput.value); + if (animationSpeed == 1) { + var yearsPerSecond = 1; + } else if (animationSpeed == 2) { + var yearsPerSecond = 5; + } else if (animationSpeed == 3) { + var yearsPerSecond = 20; + } else if (animationSpeed == 4) { + var yearsPerSecond = 50; + } else if (animationSpeed == 5) { + var yearsPerSecond = 100; + } + + var milliseconds = 1 / (yearsPerSecond / 1000); + + playInterval = setInterval(function () { + // Increment the slider value by 1 + slider.value = Number(slider.value) + 1; + enterYearInput.value = slider.value; // Sync enterYear input with dateSlide value + updateSliderOutput(); // Update the displayed year + plotPolities(); // This function is defined differently in the world_map and polity_map templates + + // Stop playing when the slider reaches its maximum value + if (slider.value >= parseInt(slider.max)) { + stopPlay(); + } + }, milliseconds); // Interval based on user input +} + +function stopPlay() { + clearInterval(playInterval); +} + +function storeYear() { + var year = document.getElementById('enterYear').value; + history.pushState(null, '', '/core/world_map/?year=' + year); + if (!allPolitiesLoaded) { + document.getElementById('loadingIndicator').style.display = 'block'; + } +} + +function switchBaseMap() { + var selectedMap = document.querySelector('input[name="baseMap"]:checked').value; + var base = document.getElementById("baseMapGADM").value + + if (base == 'province') { + var baseShapeData = provinceShapeData; + } else if (base == 'country') { + var baseShapeData = countryShapeData; + } + + // Only show "Current borders" select for GADM + var baseMapGADMFieldset = document.getElementById("baseMapGADMFieldset"); + if (selectedMap == 'gadm') { + baseMapGADMFieldset.style.display = "block" + } else { + baseMapGADMFieldset.style.display = "none" + } + + // Remove all province layers + provinceLayers.forEach(function (layer) { + map.removeLayer(layer); + }); + + // Clear the provinceLayers array + provinceLayers = []; + + map.removeLayer(currentLayer); + + if (selectedMap === 'osm') { + currentLayer = baseLayers.osm.addTo(map); + } else { + currentLayer = baseLayers.carto.addTo(map); + } + + if (selectedMap === 'gadm') { + // Add countries or provinces to the base map + baseShapeData.forEach(function (shape) { + // Ensure the geometry is not empty + if (shape.geometry && shape.geometry.type) { + gadmFillColour = 'white'; // Default fill colour + if (shape.country.toLowerCase().includes('sea')) { + gadmFillColour = 'lightblue'; + } + // Loop through each polygon and add it to the map + for (var i = 0; i < shape.geometry.coordinates.length; i++) { + var coordinates = shape.geometry.coordinates[i][0]; + // Swap latitude and longitude for each coordinate + coordinates = coordinates.map(function (coord) { + return [coord[1], coord[0]]; + }); + var polygon = L.polygon(coordinates).addTo(map); + if (!shape.country.toLowerCase().includes('sea')) { + if (base == 'province') { + var popupContent = ` + + + + + + + + + + + + + +
${shape.province}
Type${shape.provinceType}
CountryModern ${shape.country}
+ `; + } else if (base == 'country') { + var popupContent = ` + + + +
Modern ${shape.country} +
+ `; + } + polygon.bindPopup(popupContent); + }; + // Set the style using the style method + polygon.setStyle({ + fillColor: gadmFillColour, // Set the fill color based on the "colour" field + color: 'black', // Set the border color + weight: 1, // Set the border weight + fillOpacity: 0.5 // Set the fill opacity + }); + polygon.bringToBack(); // Move the province layers to back so they are always behind polity shapes + provinceLayers.push(polygon); // Add the layer to the array + } + } + }); + } +} + +function updateLegend() { + var variable = document.getElementById('chooseVariable').value; + var legendDiv = document.getElementById('variableLegend'); + var selectedYear1 = document.getElementById('dateSlide').value; // Giving it the same name as a var used in the templated JS caused an error + var selectedYearInt1 = parseInt(selectedYear1); + + // Clear the current legend + legendDiv.innerHTML = ''; + + if (variable == 'polity') { + var addedPolities = []; + var addedPolityNames = []; + shapesData.forEach(function (shape) { + // If the polity shape is part of a personal union or meta-polity active in the selected year, don't add it to the legend + var ignore = false; + if (shape.union_name) { + if ((parseInt(shape.union_start_year) <= selectedYearInt1 && parseInt(shape.union_end_year) >= selectedYearInt1)) { + ignore = true; + }; + }; + if (!ignore) { + shape_name_col_dict = {}; + shape_name_col_dict['polity'] = shape.polity; + shape_name_col_dict['colour'] = shape.colour; + if (shape.weight > 0 && !addedPolityNames.includes(shape_name_col_dict['polity'])) { + // If the shape spans the selected year + if ((parseInt(shape.start_year) <= selectedYearInt1 && parseInt(shape.end_year) >= selectedYearInt1)) { + // Add the polity to the list of added polities + addedPolities.push(shape_name_col_dict); + addedPolityNames.push(shape_name_col_dict['polity']); + }; + }; + }; + }); + + // Sort the polities by name + addedPolities.sort(function (a, b) { + return a.polity.localeCompare(b.polity); + }); + + // Add a legend for highlighted polities + if (addedPolities.length > 0) { + var legendTitle = document.createElement('h3'); + legendTitle.textContent = 'Selected Polities'; + legendDiv.appendChild(legendTitle); + for (var i = 0; i < addedPolities.length; i++) { + var legendItem = document.createElement('p'); + var colorBox = document.createElement('span'); + colorBox.style.display = 'inline-block'; + colorBox.style.width = '20px'; + colorBox.style.height = '20px'; + colorBox.style.backgroundColor = addedPolities[i].colour; + colorBox.style.border = '1px solid black'; + colorBox.style.marginRight = '10px'; + legendItem.appendChild(colorBox); + legendItem.appendChild(document.createTextNode(addedPolities[i].polity)); + legendDiv.appendChild(legendItem); + } + }; + + } else if (variable in categorical_variables) { + + var legendTitle = document.createElement('h3'); + legendTitle.textContent = document.getElementById('chooseCategoricalVariableSelection').value; + legendDiv.appendChild(legendTitle); + + for (var key in oneLanguageColourMapping) { + if (key === 'No Seshat page') { // Skip the "No Seshat page" key as it's the same colour as "Uncoded" (see world_map.html) + continue; + } + var legendItem = document.createElement('p'); + + var colorBox = document.createElement('span'); + colorBox.style.display = 'inline-block'; + colorBox.style.width = '20px'; + colorBox.style.height = '20px'; + colorBox.style.backgroundColor = oneLanguageColourMapping[key]; + colorBox.style.marginRight = '10px'; + legendItem.appendChild(colorBox); + + if (key === 'Unknown') { + colorBox.style.border = '1px solid black'; + } + if (key === 'Unknown') { + legendItem.appendChild(document.createTextNode('Coded unknown')); + } else { + legendItem.appendChild(document.createTextNode(`${key}`)); + } + + legendDiv.appendChild(legendItem); + }; + + } else { // Absent-present variables + var legendTitle = document.createElement('h3'); + legendTitle.textContent = variable; + legendDiv.appendChild(legendTitle); + + for (var key in variableColourMapping) { + if (key === 'no seshat page') { // Skip the "No Seshat page" key as it's the same colour as "Uncoded" (see world_map.html) + continue; + } + var legendItem = document.createElement('p'); + + var colorBox = document.createElement('span'); + colorBox.style.display = 'inline-block'; + colorBox.style.width = '20px'; + colorBox.style.height = '20px'; + colorBox.style.backgroundColor = variableColourMapping[key]; + colorBox.style.marginRight = '10px'; + legendItem.appendChild(colorBox); + + if (key === 'unknown') { + colorBox.style.border = '1px solid black'; + } + + legendItem.appendChild(document.createTextNode(longAbsentPresentVarName(key))); + + legendDiv.appendChild(legendItem); + } + } + + if (document.querySelector('input[name="baseMap"]:checked').value == 'gadm') { + var legendItem = document.createElement('p'); + + var colorBox = document.createElement('span'); + colorBox.style.display = 'inline-block'; + colorBox.style.width = '20px'; + colorBox.style.height = '20px'; + colorBox.style.backgroundColor = 'white'; + colorBox.style.border = '1px solid black'; + colorBox.style.marginRight = '10px'; + + legendItem.appendChild(colorBox); + legendItem.appendChild(document.createTextNode('Base map')); + + legendDiv.appendChild(legendItem); + } +} + +function updateCategoricalVariableSelection(variable){ + var dropdown = document.getElementById('chooseCategoricalVariableSelection'); + dropdown.innerHTML = ''; + if (localStorage.getItem(variable)) { + document.getElementById('chooseCategoricalVariableSelection').value = localStorage.getItem(variable); + } + categorical_variables[variable].forEach(function (choice) { + var option = document.createElement('option'); + option.value = choice; + option.text = choice; + + // Set some default selections if no selection has been made + if (localStorage.getItem(variable)) { + if (localStorage.getItem(variable) === choice) { + option.selected = true; + } + } else { + if (choice === 'Greek' || choice === 'Indo-European') { + option.selected = true; + } + } + + dropdown.appendChild(option); + }); + var varSelectElement = document.getElementById('chooseVariable'); + var varText = varSelectElement.options[varSelectElement.selectedIndex].text; + document.querySelector('label[for="chooseCategoricalVariableSelection"]').textContent = varText + ': '; +} + +function longAbsentPresentVarName(var_name){ + if (var_name === 'A~P') { + var_name = 'Absent then Present'; + } else if (var_name === 'P~A') { + var_name = 'Present then Absent'; + } else if (var_name === 'unknown') { + var_name = 'Coded Unknown'; + } else if (var_name === 'no seshat page') { + var_name = 'No Seshat Page'; + } else { + var_name = `${var_name[0].toUpperCase()}${var_name.slice(1)}`; + } + return var_name; +} \ No newline at end of file diff --git a/seshat/apps/core/static/core/styles.css b/seshat/apps/core/static/core/styles.css index 89ad74c10..cf6966124 100644 --- a/seshat/apps/core/static/core/styles.css +++ b/seshat/apps/core/static/core/styles.css @@ -198,4 +198,23 @@ hr.bg-fading { .text-darkorange { color: #FF8C00; +} + +.spinner { + border: 16px solid #f3f3f3; + border-radius: 50%; + border-top: 16px solid #782823; + width: 15px; + height: 15px; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } } \ No newline at end of file diff --git a/seshat/apps/core/templates/core/nlp_datapoints.html b/seshat/apps/core/templates/core/nlp_datapoints.html index 048b5086a..ae8c56f24 100644 --- a/seshat/apps/core/templates/core/nlp_datapoints.html +++ b/seshat/apps/core/templates/core/nlp_datapoints.html @@ -2595,7 +2595,9 @@
* What you see below is the first draft of my analysis of all S 221 -
Macrostate shapefiles
+ +
Macrostate shapefiles
+ 265 265 0 (0) diff --git a/seshat/apps/core/templates/core/partials/_navbar.html b/seshat/apps/core/templates/core/partials/_navbar.html index 5be6c5732..201ed704c 100644 --- a/seshat/apps/core/templates/core/partials/_navbar.html +++ b/seshat/apps/core/templates/core/partials/_navbar.html @@ -120,6 +120,9 @@ +