From 6e2267a4b61ad0fe220511789f8a38998c90081e Mon Sep 17 00:00:00 2001 From: Matthew Grove Date: Sun, 1 Jan 2023 16:09:18 +0000 Subject: [PATCH 01/44] Update Caddyfile for newer Caddy version Format single_field common_log is deprecated --- Caddyfile | 2 +- deploy/templates/Caddyfile.j2 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Caddyfile b/Caddyfile index 70cd7c9..2e77277 100644 --- a/Caddyfile +++ b/Caddyfile @@ -10,6 +10,6 @@ log { output stderr - format single_field common_log + format console } } \ No newline at end of file diff --git a/deploy/templates/Caddyfile.j2 b/deploy/templates/Caddyfile.j2 index 65cdfc4..f407511 100644 --- a/deploy/templates/Caddyfile.j2 +++ b/deploy/templates/Caddyfile.j2 @@ -10,6 +10,6 @@ http://* { log { output stderr - format single_field common_log + format console } } \ No newline at end of file From de111bea55a5a1d9d3ad27521534972864e6c2d5 Mon Sep 17 00:00:00 2001 From: Matthew Grove Date: Sun, 1 Jan 2023 16:10:49 +0000 Subject: [PATCH 02/44] Update Git repo references for devlopment testing --- deploy/playbook.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/deploy/playbook.yml b/deploy/playbook.yml index 1e19a32..be797c1 100644 --- a/deploy/playbook.yml +++ b/deploy/playbook.yml @@ -11,7 +11,7 @@ register: vagrant_dir vars: - project_name: mapper + project_name: relationship-mapper project_dir: /srv/{{ project_name }} project_src_dir: "{{ project_dir }}/src" @@ -41,9 +41,10 @@ - name: Clone / update from source repos ansible.builtin.git: - repo: 'https://github.com/Southampton-RSG/breccia-mapper.git' + # repo: 'https://github.com/Southampton-RSG/breccia-mapper.git' + repo: 'https://github.com/mgrove36/breccia-relationship-mapper.git' dest: '{{ project_src_dir }}' - version: docker + version: dev # master accept_hostkey: yes - name: Copy template files From 4b37c202f4d350d1bfaf7e5ec5b5963da7691613 Mon Sep 17 00:00:00 2001 From: Matthew Grove Date: Sun, 1 Jan 2023 16:11:25 +0000 Subject: [PATCH 03/44] Add example settings file for deployment --- deploy/settings.example.ini | 112 ++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 deploy/settings.example.ini diff --git a/deploy/settings.example.ini b/deploy/settings.example.ini new file mode 100644 index 0000000..81f1434 --- /dev/null +++ b/deploy/settings.example.ini @@ -0,0 +1,112 @@ +[settings] + +; REQUIRED=Secret key +; Used to generate CSRF tokens - must never be made public +# SECRET_KEY=SECRET_KEY_IS_REQUIRED + +; Parent project name +; Displayed in templates where the name of the parent project should be used +; Default: Parent Project Name +# PARENT_PROJECT_NAME=Parent Project Name + +; Project long name +; Displayed in templates where the full name of the project should be used +; Default: Project Long Name +# PROJECT_LONG_NAME=Project Long Name + +; Project short name +; Displayed in templates where a short identifier for the project should be used +; Default: shortname +# PROJECT_SHORT_NAME=shortname + +; Debug +; Should the server run in debug mode? Provides information to users which is unsafe in production +; Default: False +# DEBUG=False + +; Allowed hosts +; Accepted values for server header in request - protects against CSRF and CSS attacks +; Default: * if DEBUG else localhost +# ALLOWED_HOSTS=* if DEBUG else localhost + +; Database URL +; URL to database - uses format described at https://github.com/jacobian/dj-database-url +; Default: sqlite://db.sqlite3 +# DATABASE_URL=sqlite://db.sqlite3 + +; Database backup storage location +; Directory where database backups should be stored +; Default: .dbbackup +# DBBACKUP_STORAGE_LOCATION=.dbbackup + +; Default language +; Default language - used for translation - has not been enabled +; Default: en-gb +# LANGUAGE_CODE=en-gb + +; Timezone +; Default timezone +; Default: UTC +# TIME_ZONE=UTC + +; Logging level +; Level of messages written to log file +; Default: INFO +# LOG_LEVEL=INFO + +; Logging filename +; Path to logfile +; Default: debug.log +# LOG_FILENAME=debug.log + +; Logging duration +; Number of days of logs to keep - logfile is rotated out at the end of each day +; Default: 14 +# LOG_DAYS=14 + +; STMP host +; Hostname of SMTP server +; Default: None +# EMAIL_HOST=None + +; Default from email address +; Email address from which messages are sent +; Default: None +# DEFAULT_FROM_EMAIL=None + +; DEBUG ONLY=Email file path +; Directory where emails will be stored if not using an SMTP server +; Default: mail.log +# EMAIL_FILE_PATH=mail.log + +; SMTP username +; Username to authenticate with SMTP server +; Default: None +# EMAIL_HOST_USER=None + +; SMTP password +; Password to authenticate with SMTP server +; Default: None +# EMAIL_HOST_PASSWORD=None + +; SMTP port +; Port to access on SMTP server +; Default: 25 +# EMAIL_PORT=25 + +; SMTP use TLS +; Use TLS to communicate with SMTP server? Usually on port 587 +; Cannot be enabled at the same time as EMAIL_USE_SSL +; Default: True if EMAIL_PORT == 587 else False +# EMAIL_USE_TLS=True if EMAIL_PORT == 587 else False + +; SMTP use SSL +; Use SSL to communicate with SMTP server? Usually on port 465 +; Cannot be enabled at the same time as EMAIL_USE_TLS +; Default: True if EMAIL_PORT == 465 else False +# EMAIL_USE_SSL=True if EMAIL_PORT == 465 else False + +; Google Maps API key +; Google Maps API key to display maps of people's locations +; Default: None +# GOOGLE_MAPS_API_KEY=None \ No newline at end of file From 770b4f1114957c9755f38fc551d76d183ab60901 Mon Sep 17 00:00:00 2001 From: Matthew Grove Date: Sun, 1 Jan 2023 16:12:45 +0000 Subject: [PATCH 04/44] Add way to change deployment configuration options --- deploy/playbook.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/deploy/playbook.yml b/deploy/playbook.yml index be797c1..3b984e5 100644 --- a/deploy/playbook.yml +++ b/deploy/playbook.yml @@ -56,6 +56,12 @@ - Caddyfile - docker-compose.yml + - name: Copy settings file + ansible.builtin.copy: + src: 'settings.ini' + dest: '{{ project_src_dir }}/breccia_mapper/settings.ini' + mode: 0600 + - name: Create database file ansible.builtin.file: path: "{{ project_dir }}/db.sqlite3" From 7e2491be76ef5fc3e377d21a5a05c3b0e8202f1a Mon Sep 17 00:00:00 2001 From: Matthew Grove Date: Thu, 5 Jan 2023 17:49:27 +0000 Subject: [PATCH 05/44] [FEAT] Update dependencies Upgrade versions of most packages and make other required changes to ensure compatibility. Update database models and migrations to match new requirements set by Django --- Dockerfile | 2 +- ...ity_id_alter_activitymedium_id_and_more.py | 33 +++++ breccia_mapper/settings.py | 14 ++- breccia_mapper/templates/base.html | 52 +++++--- breccia_mapper/templates/index.html | 8 +- people/forms.py | 8 +- .../0002_add_relationship_models.py | 4 +- .../0022_refactor_person_questions.py | 2 +- .../0035_add_organisation_questions.py | 2 +- .../0039_add_organisation_relationship.py | 4 +- ...nship_organisationrelationship_and_more.py | 119 ++++++++++++++++++ people/models/organisation.py | 5 + people/models/person.py | 5 + people/models/question.py | 2 +- people/models/relationship.py | 14 ++- people/templates/people/map.html | 2 +- people/templates/people/network.html | 2 +- .../templates/people/organisation/detail.html | 2 +- .../templates/people/organisation/update.html | 2 +- .../templates/people/person/detail_full.html | 14 ++- .../people/person/detail_partial.html | 2 +- people/templates/people/person/update.html | 2 +- .../templates/people/relationship/update.html | 2 +- requirements.txt | 75 ++++++----- 24 files changed, 293 insertions(+), 84 deletions(-) mode change 100644 => 100755 Dockerfile create mode 100755 activities/migrations/0007_alter_activity_id_alter_activitymedium_id_and_more.py mode change 100644 => 100755 breccia_mapper/templates/base.html mode change 100644 => 100755 people/forms.py mode change 100644 => 100755 people/migrations/0002_add_relationship_models.py mode change 100644 => 100755 people/migrations/0022_refactor_person_questions.py mode change 100644 => 100755 people/migrations/0035_add_organisation_questions.py mode change 100644 => 100755 people/migrations/0039_add_organisation_relationship.py create mode 100755 people/migrations/0055_remove_organisationrelationship_unique_relationship_organisationrelationship_and_more.py mode change 100644 => 100755 people/models/organisation.py mode change 100644 => 100755 people/models/person.py mode change 100644 => 100755 people/models/question.py mode change 100644 => 100755 people/models/relationship.py mode change 100644 => 100755 people/templates/people/map.html mode change 100644 => 100755 people/templates/people/network.html mode change 100644 => 100755 people/templates/people/organisation/detail.html mode change 100644 => 100755 people/templates/people/organisation/update.html mode change 100644 => 100755 people/templates/people/person/detail_full.html mode change 100644 => 100755 people/templates/people/person/detail_partial.html mode change 100644 => 100755 people/templates/people/person/update.html mode change 100644 => 100755 people/templates/people/relationship/update.html mode change 100644 => 100755 requirements.txt diff --git a/Dockerfile b/Dockerfile old mode 100644 new mode 100755 index 14b3c68..4884e33 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.8-slim +FROM python:3.9-slim RUN groupadd -r mapper && useradd --no-log-init -r -g mapper mapper diff --git a/activities/migrations/0007_alter_activity_id_alter_activitymedium_id_and_more.py b/activities/migrations/0007_alter_activity_id_alter_activitymedium_id_and_more.py new file mode 100755 index 0000000..d4d7663 --- /dev/null +++ b/activities/migrations/0007_alter_activity_id_alter_activitymedium_id_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.1.4 on 2023-01-05 16:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activities', '0006_activity_attendance_optional'), + ] + + operations = [ + migrations.AlterField( + model_name='activity', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='activitymedium', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='activityseries', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='activitytype', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/breccia_mapper/settings.py b/breccia_mapper/settings.py index 6627eac..8bab638 100644 --- a/breccia_mapper/settings.py +++ b/breccia_mapper/settings.py @@ -165,7 +165,6 @@ THIRD_PARTY_APPS = [ 'post_office', 'bootstrap_datepicker_plus', 'hijack', - 'compat', ] FIRST_PARTY_APPS = [ @@ -184,6 +183,7 @@ MIDDLEWARE = [ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'hijack.middleware.HijackUserMiddleware', ] ROOT_URLCONF = 'breccia_mapper.urls' @@ -417,6 +417,18 @@ else: default=(EMAIL_PORT == 465), cast=bool) +# Bootstrap Datepicker Plus Settings +BOOTSTRAP_DATEPICKER_PLUS = { + "variant_options": { + "date": { + "format": "%Y-%m-%d", + }, + } +} + +# Database default automatic primary key +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + # Upstream API keys GOOGLE_MAPS_API_KEY = config('GOOGLE_MAPS_API_KEY', default=None) diff --git a/breccia_mapper/templates/base.html b/breccia_mapper/templates/base.html old mode 100644 new mode 100755 index ef622cb..be095e9 --- a/breccia_mapper/templates/base.html +++ b/breccia_mapper/templates/base.html @@ -16,21 +16,23 @@ {% bootstrap_css %} + href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.1/css/fontawesome.min.css" + integrity="sha512-giQeaPns4lQTBMRpOOHsYnGw1tGVzbAIHUyHRgn7+6FmiEgGGjaG0T2LZJmAPMzRCl+Cug0ItQ2xDZpTmEc+CQ==" + crossorigin="anonymous" + referrerpolicy="no-referrer" /> + href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.1/css/solid.min.css" + integrity="sha512-6mc0R607di/biCutMUtU9K7NtNewiGQzrvWX4bWTeqmljZdJrwYvKJtnhgR+Ryvj+NRJ8+NnnCM/biGqMe/iRA==" + crossorigin="anonymous" + referrerpolicy="no-referrer" /> - {% load staticfiles %} + {% load static %} + href="{% static 'hijack/hijack.min.css' %}" /> {% if 'javascript_in_head'|bootstrap_setting %} {% if 'include_jquery'|bootstrap_setting %} @@ -107,13 +109,13 @@ @@ -130,7 +132,7 @@ {% else %} @@ -149,8 +151,30 @@ {% endif %} - {% load hijack_tags %} - {% hijack_notification %} + {% load hijack %} + + {# Hijack notification if user is hijacked #} + {% if person.user == request.user and request.user.is_hijacked %} +
+
+
+ {% blocktrans trimmed with user=request.user %} + You are currently working on behalf of {{ user }}. + {% endblocktrans %} +
+
+ {% csrf_token %} + + + +
+
+
+ {% endif %} {% if request.user.is_authenticated and not request.user.has_person %} @@ -43,7 +43,7 @@

Do Feature 2

- +
@@ -53,7 +53,7 @@

Do Feature 3

- +
diff --git a/people/forms.py b/people/forms.py old mode 100644 new mode 100755 index 925ea56..18eb007 --- a/people/forms.py +++ b/people/forms.py @@ -5,7 +5,7 @@ import typing from django import forms from django.conf import settings -from bootstrap_datepicker_plus import DatePickerInput +from bootstrap_datepicker_plus.widgets import DatePickerInput from django_select2.forms import ModelSelect2Widget, Select2Widget, Select2MultipleWidget from . import models @@ -185,8 +185,8 @@ class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase): widgets = { 'nationality': Select2MultipleWidget(), 'country_of_residence': Select2Widget(), - 'organisation_started_date': DatePickerInput(format='%Y-%m-%d'), - 'project_started_date': DatePickerInput(format='%Y-%m-%d'), + 'organisation_started_date': DatePickerInput(), + 'project_started_date': DatePickerInput(), 'latitude': forms.HiddenInput, 'longitude': forms.HiddenInput, } @@ -325,7 +325,7 @@ class OrganisationRelationshipAnswerSetForm(forms.ModelForm, class DateForm(forms.Form): date = forms.DateField( required=False, - widget=DatePickerInput(format='%Y-%m-%d'), + widget=DatePickerInput(), help_text='Show relationships as they were on this date' ) diff --git a/people/migrations/0002_add_relationship_models.py b/people/migrations/0002_add_relationship_models.py old mode 100644 new mode 100755 index cc6c888..d448c52 --- a/people/migrations/0002_add_relationship_models.py +++ b/people/migrations/0002_add_relationship_models.py @@ -50,10 +50,10 @@ class Migration(migrations.Migration): ), migrations.AddConstraint( model_name='relationshipquestionchoice', - constraint=models.UniqueConstraint(fields=('question', 'text'), name='unique_question_answer'), + constraint=models.UniqueConstraint(fields=('question', 'text'), name='unique_question_answer_relationshipquestionchoice'), ), migrations.AddConstraint( model_name='relationship', - constraint=models.UniqueConstraint(fields=('source', 'target'), name='unique_relationship'), + constraint=models.UniqueConstraint(fields=('source', 'target'), name='unique_relationship_relationship'), ), ] diff --git a/people/migrations/0022_refactor_person_questions.py b/people/migrations/0022_refactor_person_questions.py old mode 100644 new mode 100755 index 2da386b..d2869d8 --- a/people/migrations/0022_refactor_person_questions.py +++ b/people/migrations/0022_refactor_person_questions.py @@ -50,6 +50,6 @@ class Migration(migrations.Migration): ), migrations.AddConstraint( model_name='personquestionchoice', - constraint=models.UniqueConstraint(fields=('question', 'text'), name='unique_question_answer'), + constraint=models.UniqueConstraint(fields=('question', 'text'), name='unique_question_answer_personquestionchoice'), ), ] diff --git a/people/migrations/0035_add_organisation_questions.py b/people/migrations/0035_add_organisation_questions.py old mode 100644 new mode 100755 index 19fccaf..d3f0702 --- a/people/migrations/0035_add_organisation_questions.py +++ b/people/migrations/0035_add_organisation_questions.py @@ -56,6 +56,6 @@ class Migration(migrations.Migration): ), migrations.AddConstraint( model_name='organisationquestionchoice', - constraint=models.UniqueConstraint(fields=('question', 'text'), name='unique_question_answer'), + constraint=models.UniqueConstraint(fields=('question', 'text'), name='unique_question_answer_organisationquestionchoice'), ), ] diff --git a/people/migrations/0039_add_organisation_relationship.py b/people/migrations/0039_add_organisation_relationship.py old mode 100644 new mode 100755 index 461d897..dfcd46c --- a/people/migrations/0039_add_organisation_relationship.py +++ b/people/migrations/0039_add_organisation_relationship.py @@ -67,10 +67,10 @@ class Migration(migrations.Migration): ), migrations.AddConstraint( model_name='organisationrelationshipquestionchoice', - constraint=models.UniqueConstraint(fields=('question', 'text'), name='unique_question_answer'), + constraint=models.UniqueConstraint(fields=('question', 'text'), name='unique_question_answer_organisationrelationshipquestionchoice'), ), migrations.AddConstraint( model_name='organisationrelationship', - constraint=models.UniqueConstraint(fields=('source', 'target'), name='unique_relationship'), + constraint=models.UniqueConstraint(fields=('source', 'target'), name='unique_relationship_organisationrelationship'), ), ] diff --git a/people/migrations/0055_remove_organisationrelationship_unique_relationship_organisationrelationship_and_more.py b/people/migrations/0055_remove_organisationrelationship_unique_relationship_organisationrelationship_and_more.py new file mode 100755 index 0000000..ccdcce5 --- /dev/null +++ b/people/migrations/0055_remove_organisationrelationship_unique_relationship_organisationrelationship_and_more.py @@ -0,0 +1,119 @@ +# Generated by Django 4.1.4 on 2023-01-05 16:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('people', '0054_add_option_for_auto_negative_response'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='organisationrelationship', + name='unique_relationship_organisationrelationship', + ), + migrations.RemoveConstraint( + model_name='relationship', + name='unique_relationship_relationship', + ), + migrations.AlterField( + model_name='organisation', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='organisationanswerset', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='organisationquestion', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='organisationquestionchoice', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='organisationrelationship', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='organisationrelationshipanswerset', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='organisationrelationshipquestion', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='organisationrelationshipquestionchoice', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='person', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='personanswerset', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='personquestion', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='personquestionchoice', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='relationship', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='relationshipanswerset', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='relationshipquestion', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='relationshipquestionchoice', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='user', + name='first_name', + field=models.CharField(blank=True, max_length=150, verbose_name='first name'), + ), + migrations.AlterField( + model_name='user', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AddConstraint( + model_name='organisationrelationship', + constraint=models.UniqueConstraint(fields=('source', 'target'), name='unique_relationship_modelorganisationrelationship'), + ), + migrations.AddConstraint( + model_name='relationship', + constraint=models.UniqueConstraint(fields=('source', 'target'), name='unique_relationship_modelrelationship'), + ), + ] diff --git a/people/models/organisation.py b/people/models/organisation.py old mode 100644 new mode 100755 index 3156d7f..347999a --- a/people/models/organisation.py +++ b/people/models/organisation.py @@ -35,6 +35,11 @@ class OrganisationQuestionChoice(QuestionChoice): on_delete=models.CASCADE, blank=False, null=False) + class Meta(QuestionChoice.Meta): + constraints = [ + models.UniqueConstraint(fields=['question', 'text'], + name='unique_question_answer_organisationquestionchoice') + ] class Organisation(models.Model): diff --git a/people/models/person.py b/people/models/person.py old mode 100644 new mode 100755 index 618bbfb..4d572af --- a/people/models/person.py +++ b/people/models/person.py @@ -77,6 +77,11 @@ class PersonQuestionChoice(QuestionChoice): on_delete=models.CASCADE, blank=False, null=False) + class Meta(QuestionChoice.Meta): + constraints = [ + models.UniqueConstraint(fields=['question', 'text'], + name='unique_question_answer_personquestionchoice') + ] class Person(models.Model): diff --git a/people/models/question.py b/people/models/question.py old mode 100644 new mode 100755 index 5d455e1..93b8161 --- a/people/models/question.py +++ b/people/models/question.py @@ -92,7 +92,7 @@ class QuestionChoice(models.Model): abstract = True constraints = [ models.UniqueConstraint(fields=['question', 'text'], - name='unique_question_answer') + name='unique_question_answer_modelquestionchoice') ] ordering = [ 'question__order', diff --git a/people/models/relationship.py b/people/models/relationship.py old mode 100644 new mode 100755 index 3fd703d..ecd2c98 --- a/people/models/relationship.py +++ b/people/models/relationship.py @@ -33,6 +33,11 @@ class RelationshipQuestionChoice(QuestionChoice): on_delete=models.CASCADE, blank=False, null=False) + class Meta(QuestionChoice.Meta): + constraints = [ + models.UniqueConstraint(fields=['question', 'text'], + name='unique_question_answer_relationshipquestionchoice') + ] class Relationship(models.Model): @@ -40,7 +45,7 @@ class Relationship(models.Model): class Meta: constraints = [ models.UniqueConstraint(fields=['source', 'target'], - name='unique_relationship'), + name='unique_relationship_modelrelationship'), ] #: Person reporting the relationship @@ -122,6 +127,11 @@ class OrganisationRelationshipQuestionChoice(QuestionChoice): on_delete=models.CASCADE, blank=False, null=False) + class Meta(QuestionChoice.Meta): + constraints = [ + models.UniqueConstraint(fields=['question', 'text'], + name='unique_question_answer_organisationrelationshipquestionchoice') + ] class OrganisationRelationship(models.Model): @@ -129,7 +139,7 @@ class OrganisationRelationship(models.Model): class Meta: constraints = [ models.UniqueConstraint(fields=['source', 'target'], - name='unique_relationship'), + name='unique_relationship_modelorganisationrelationship'), ] #: Person reporting the relationship diff --git a/people/templates/people/map.html b/people/templates/people/map.html old mode 100644 new mode 100755 index 688d32e..8560ee0 --- a/people/templates/people/map.html +++ b/people/templates/people/map.html @@ -3,7 +3,7 @@ {% block extra_head %} {{ map_markers|json_script:'map-markers' }} - {% load staticfiles %} + {% load static %} - {% load staticfiles %} + {% load static %} {% endblock %} \ No newline at end of file diff --git a/people/templates/people/organisation/detail.html b/people/templates/people/organisation/detail.html old mode 100644 new mode 100755 index 1bb6dd0..6b75af2 --- a/people/templates/people/organisation/detail.html +++ b/people/templates/people/organisation/detail.html @@ -3,7 +3,7 @@ {% block extra_head %} {{ map_markers|json_script:'map-markers' }} - {% load staticfiles %} + {% load static %} diff --git a/people/templates/people/person/detail_full.html b/people/templates/people/person/detail_full.html old mode 100644 new mode 100755 index d550fa1..c3b3651 --- a/people/templates/people/person/detail_full.html +++ b/people/templates/people/person/detail_full.html @@ -3,7 +3,7 @@ {% block extra_head %} {{ map_markers|json_script:'map-markers' }} - {% load staticfiles %} + {% load static %} diff --git a/people/templates/people/relationship/update.html b/people/templates/people/relationship/update.html old mode 100644 new mode 100755 index 11022e5..680fecc --- a/people/templates/people/relationship/update.html +++ b/people/templates/people/relationship/update.html @@ -23,6 +23,6 @@ {% endblock %} {% block extra_script %} - {% load staticfiles %} + {% load static %} {% endblock %} diff --git a/requirements.txt b/requirements.txt old mode 100644 new mode 100755 index 4791a26..8497233 --- a/requirements.txt +++ b/requirements.txt @@ -1,46 +1,45 @@ -astroid==2.3.3 -beautifulsoup4==4.8.2 -dj-database-url==0.5.0 -Django==2.2.10 -django-appconf==1.0.3 -django-bootstrap4==1.1.1 -django-bootstrap-datepicker-plus==3.0.5 -django-compat==1.0.15 -django-constance==2.6.0 -django-countries==5.5 -django-dbbackup==3.2.0 -django-filter==2.2.0 -django-hijack==2.2.1 -django-picklefield==2.1.1 -django-post-office==3.4.0 -django-select2==7.2.0 +astroid==2.12.13 +beautifulsoup4==4.11.1 +dj-database-url==1.2.0 +Django==4.1.4 +django-appconf==1.0.5 +django-bootstrap4==22.3 +django-bootstrap-datepicker-plus==5.0.2 +django-constance==2.9.1 +django-countries==7.5 +django-dbbackup==4.0.2 +django-filter==22.1 +django-hijack==3.2.6 +django-picklefield==3.1 +django-post-office==3.6.3 +django-select2==8.0.0 django-settings-export==1.2.1 -djangorestframework==3.11.0 +djangorestframework==3.14.0 dodgy==0.2.1 -isort==4.3.21 +isort==5.11.4 jsonfield==3.1.0 -lazy-object-proxy==1.4.3 -mccabe==0.6.1 +lazy-object-proxy==1.8.0 +mccabe==0.7.0 # mysqlclient==1.4.6 -pep8-naming==0.4.1 -prospector==1.2.0 -pycodestyle==2.4.0 -pydocstyle==5.0.2 -pyflakes==2.1.1 -pylint==2.4.4 +pep8-naming==0.10.0 +prospector==1.8.3 +pycodestyle==2.10.0 +pydocstyle==6.1.1 +pyflakes==2.5.0 +pylint==2.15.9 pylint-celery==0.3 -pylint-django==2.0.12 +pylint-django==2.5.3 pylint-flask==0.6 -pylint-plugin-utils==0.6 -python-decouple==3.3 -pytz==2019.3 +pylint-plugin-utils==0.7 +python-decouple==3.6 +pytz==2022.7 pyuca==1.2 -PyYAML==5.3 -requirements-detector==0.6 -setoptconf==0.2.0 -six==1.14.0 -snowballstemmer==2.0.0 -soupsieve==1.9.5 -sqlparse==0.3.0 +PyYAML==6.0 +requirements-detector==1.0.3 +setoptconf==0.3.0 +six==1.16.0 +snowballstemmer==2.2.0 +soupsieve==2.3.2.post1 +sqlparse==0.4.3 typed-ast -wrapt==1.11.2 +wrapt==1.14.1 From 15bbf2f7d1f3b050906af0632fe18a3cf10d2c06 Mon Sep 17 00:00:00 2001 From: Matthew Grove Date: Thu, 5 Jan 2023 17:56:04 +0000 Subject: [PATCH 06/44] [BUILD] Change docker image & container names --- Caddyfile | 2 +- deploy/templates/Caddyfile.j2 | 2 +- deploy/templates/docker-compose.yml.j2 | 8 +++++--- docker-compose.yml | 8 +++++--- 4 files changed, 12 insertions(+), 8 deletions(-) mode change 100644 => 100755 Caddyfile mode change 100644 => 100755 deploy/templates/Caddyfile.j2 mode change 100644 => 100755 deploy/templates/docker-compose.yml.j2 diff --git a/Caddyfile b/Caddyfile old mode 100644 new mode 100755 index 2e77277..d5cc982 --- a/Caddyfile +++ b/Caddyfile @@ -6,7 +6,7 @@ not path /static/* } - reverse_proxy @proxy_paths http://web:8000 + reverse_proxy @proxy_paths http://server:8000 log { output stderr diff --git a/deploy/templates/Caddyfile.j2 b/deploy/templates/Caddyfile.j2 old mode 100644 new mode 100755 index f407511..a5449da --- a/deploy/templates/Caddyfile.j2 +++ b/deploy/templates/Caddyfile.j2 @@ -6,7 +6,7 @@ http://* { not path /static/* } - reverse_proxy @proxy_paths http://web:8000 + reverse_proxy @proxy_paths http://server:8000 log { output stderr diff --git a/deploy/templates/docker-compose.yml.j2 b/deploy/templates/docker-compose.yml.j2 old mode 100644 new mode 100755 index 08d51ee..122d2a2 --- a/deploy/templates/docker-compose.yml.j2 +++ b/deploy/templates/docker-compose.yml.j2 @@ -1,8 +1,9 @@ version: '3.1' services: - web: - image: breccia-mapper + server: + image: breccia-relationship-mapper + container_name: relationship-mapper-server build: {{ project_src_dir }} ports: - 8000:8000 @@ -16,6 +17,7 @@ services: caddy: image: caddy:2 + container_name: relationship-mapper-caddy restart: unless-stopped ports: - 80:80 @@ -27,7 +29,7 @@ services: - caddy_data:/data - caddy_config:/config depends_on: - - web + - server volumes: caddy_data: diff --git a/docker-compose.yml b/docker-compose.yml index c2331f0..afe24f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,9 @@ version: '3.1' services: - web: - image: breccia-mapper + server: + image: breccia-relationship-mapper + container_name: relationship-mapper-server build: . ports: - 8000:8000 @@ -16,6 +17,7 @@ services: caddy: image: caddy:2 + container_name: relationship-mapper-caddy restart: unless-stopped ports: - 80:80 @@ -27,7 +29,7 @@ services: - caddy_data:/data - caddy_config:/config depends_on: - - web + - server volumes: caddy_data: From 07c15281c0114c3092443d151b6f1035e64062d4 Mon Sep 17 00:00:00 2001 From: Matthew Grove Date: Thu, 5 Jan 2023 17:56:39 +0000 Subject: [PATCH 07/44] [FIX] Residual compatibility update Font Awesome icon updated to new version --- people/templates/people/relationship/detail.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) mode change 100644 => 100755 people/templates/people/relationship/detail.html diff --git a/people/templates/people/relationship/detail.html b/people/templates/people/relationship/detail.html old mode 100644 new mode 100755 index 2d4c0ee..f681941 --- a/people/templates/people/relationship/detail.html +++ b/people/templates/people/relationship/detail.html @@ -47,7 +47,7 @@
{% if relationship.reverse %} - + {% endif %}
From 050a69d5ea6eae352966393a892a2e9fa9bad4aa Mon Sep 17 00:00:00 2001 From: Matthew Grove Date: Thu, 5 Jan 2023 17:57:33 +0000 Subject: [PATCH 08/44] [BUILD] Update image name in Ansible playbook --- deploy/playbook.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/playbook.yml b/deploy/playbook.yml index 3b984e5..fa79dfd 100644 --- a/deploy/playbook.yml +++ b/deploy/playbook.yml @@ -85,7 +85,7 @@ chdir: "{{ project_dir }}" cmd: docker compose build {{ item }} loop: - - web + - server - name: Start containers ansible.builtin.command: From c5dca62b0b5f42902e5d68194d067a490e0752bb Mon Sep 17 00:00:00 2001 From: Matthew Grove Date: Thu, 5 Jan 2023 20:18:59 +0000 Subject: [PATCH 09/44] [FEAT] Automate provisioning of admin user with Ansible --- deploy/playbook.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/deploy/playbook.yml b/deploy/playbook.yml index fa79dfd..5b65fcd 100644 --- a/deploy/playbook.yml +++ b/deploy/playbook.yml @@ -14,6 +14,10 @@ project_name: relationship-mapper project_dir: /srv/{{ project_name }} project_src_dir: "{{ project_dir }}/src" + provision_superuser: false + superuser_username: admin + superuser_password: admin + superuser_email: email@example.com tasks: - name: Vagrant specific tasks @@ -91,3 +95,15 @@ ansible.builtin.command: chdir: "{{ project_dir }}" cmd: docker compose up -d + + - name: Provision admin user + ansible.builtin.command: + chdir: "{{ project_dir }}" + cmd: sudo docker compose exec -it server /bin/bash -c "DJANGO_SUPERUSER_USERNAME='{{ superuser_username }}' DJANGO_SUPERUSER_PASSWORD='{{ superuser_password }}' DJANGO_SUPERUSER_EMAIL='{{ superuser_email }}' /app/manage.py createsuperuser --no-input" + when: provision_superuser + + - name: Display warning about new superuser + debug: + msg: + - "[WARNING] A superuser has been provisioned with the username \"{{ superuser_username }}\" and password that was provided. This user has unlimited access to the relationship mapper." + when: provision_superuser \ No newline at end of file From 03553926756f1101a7d40d7041cc1b8c38738afb Mon Sep 17 00:00:00 2001 From: Matthew Grove Date: Thu, 5 Jan 2023 23:43:31 +0000 Subject: [PATCH 10/44] [FEAT] Move a lot of content/settings to Constance They can now be changed in the Django admin interface --- breccia_mapper/settings.py | 113 +++++++++++++++++++----- breccia_mapper/static/media/400x400.png | Bin 0 -> 1307 bytes breccia_mapper/static/media/800x500.png | Bin 0 -> 2776 bytes breccia_mapper/templates/base.html | 6 +- breccia_mapper/templates/index.html | 60 +++++++++---- breccia_mapper/urls.py | 2 + people/forms.py | 7 +- people/views/organisation.py | 9 +- requirements.txt | 1 + 9 files changed, 149 insertions(+), 49 deletions(-) create mode 100644 breccia_mapper/static/media/400x400.png create mode 100644 breccia_mapper/static/media/800x500.png diff --git a/breccia_mapper/settings.py b/breccia_mapper/settings.py index 8bab638..4980d41 100644 --- a/breccia_mapper/settings.py +++ b/breccia_mapper/settings.py @@ -16,18 +16,6 @@ https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ Many configuration settings are input from `settings.ini`. The most likely required settings are: SECRET_KEY, DEBUG, ALLOWED_HOSTS, DATABASE_URL, PROJECT_*_NAME, EMAIL_* -- PARENT_PROJECT_NAME - default: Parent Project Name - Displayed in templates where the name of the parent project should be used - -- PROJECT_LONG_NAME - default: Project Long Name - Displayed in templates where the full name of the project should be used - -- PROJECT_SHORT_NAME - default: shortname - Displayed in templates where a short identifier for the project should be used - - SECRET_KEY (REQUIRED) Used to generate CSRF tokens - must never be made public @@ -118,16 +106,9 @@ import dj_database_url SETTINGS_EXPORT = [ 'DEBUG', - 'PARENT_PROJECT_NAME', - 'PROJECT_LONG_NAME', - 'PROJECT_SHORT_NAME', 'GOOGLE_MAPS_API_KEY', ] -PARENT_PROJECT_NAME = config('PARENT_PROJECT_NAME', - default='Parent Project Name') -PROJECT_LONG_NAME = config('PROJECT_LONG_NAME', default='Project Long Name') -PROJECT_SHORT_NAME = config('PROJECT_SHORT_NAME', default='shortname') # Build paths inside the project like this: BASE_DIR.joinpath(...) BASE_DIR = pathlib.Path(__file__).parent.parent @@ -297,6 +278,10 @@ STATIC_ROOT = BASE_DIR.joinpath('static') STATICFILES_DIRS = [BASE_DIR.joinpath('breccia_mapper', 'static')] +# Media uploads +MEDIA_ROOT = BASE_DIR.joinpath('breccia_mapper', 'static', 'media') +MEDIA_URL = "/static/media/" + # Logging - NB the logger name is empty to capture all output LOGGING = { @@ -340,6 +325,10 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name # Admin panel variables +CONSTANCE_ADDITIONAL_FIELDS = { + 'image_field': ['django.forms.ImageField', {}] +} + CONSTANCE_CONFIG = { 'NOTICE_TEXT': ( '', @@ -359,17 +348,95 @@ CONSTANCE_CONFIG = { 'RELATIONSHIP_FORM_HELP': ( '', 'Help text to display at the top of relationship forms.'), + 'PARENT_PROJECT_NAME': ( + '', + 'Parent project name'), + 'PROJECT_LONG_NAME': ( + 'Project Long Name', + 'Project long name'), + 'PROJECT_SHORT_NAME': ( + 'Short Name', + 'Project short name'), + 'PROJECT_LEAD': ( + 'John Doe', + 'Project lead'), + 'PROJECT_TAGLINE': ( + 'Here is your project\'s tagline.', + 'Project tagline'), + 'HOMEPAGE_HEADER_IMAGE': ( + '800x500.png', + 'Homepage header image', + 'image_field'), + 'HOMEPAGE_CARD_1_TITLE': ( + 'Step 1', + 'Homepage card #1 title'), + 'HOMEPAGE_CARD_1_DESCRIPTION': ( + 'Tell us about your position within the project', + 'Homepage card #1 description'), + 'HOMEPAGE_CARD_1_ICON': ( + 'building-user', + 'Homepage card #1 icon'), + 'HOMEPAGE_CARD_2_TITLE': ( + 'Step 2', + 'Homepage card #2 title'), + 'HOMEPAGE_CARD_2_DESCRIPTION': ( + 'Describe your relationships with other stakeholders', + 'Homepage card #2 description'), + 'HOMEPAGE_CARD_2_ICON': ( + 'handshake-simple', + 'Homepage card #2 icon'), + 'HOMEPAGE_CARD_3_TITLE': ( + 'Step 3', + 'Homepage card #3 title'), + 'HOMEPAGE_CARD_3_DESCRIPTION': ( + 'Use the network view to build new relationships', + 'Homepage card #3 description'), + 'HOMEPAGE_CARD_3_ICON': ( + 'diagram-project', + 'Homepage card #3 icon'), + 'HOMEPAGE_ABOUT_TITLE': ( + 'About Us', + 'Homepage about section title'), + 'HOMEPAGE_ABOUT_CONTENT': ( + """Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. In massa tempor nec feugiat nisl. Eget dolor morbi non arcu risus quis varius quam quisque. Nisl pretium fusce id velit ut tortor pretium viverra suspendisse. Vitae auctor eu augue ut lectus arcu. Tellus molestie nunc non blandit massa enim nec. At consectetur lorem donec massa sapien. Placerat orci nulla pellentesque dignissim enim sit. Sit amet mauris commodo quis imperdiet. Tellus at urna condimentum mattis pellentesque.
In vitae turpis massa sed. Fermentum posuere urna nec tincidunt praesent semper feugiat nibh sed. Ut consequat semper viverra nam libero justo laoreet. Velit ut tortor pretium viverra suspendisse potenti nullam ac tortor. Nunc id cursus metus aliquam eleifend mi in nulla posuere. Aliquam eleifend mi in nulla posuere sollicitudin aliquam. Est ante in nibh mauris cursus mattis molestie a iaculis. Nunc id cursus metus aliquam. Auctor urna nunc id cursus metus aliquam. Porttitor lacus luctus accumsan tortor posuere ac ut consequat semper. Volutpat consequat mauris nunc congue nisi. Leo vel fringilla est ullamcorper eget. Vitae purus faucibus ornare suspendisse sed nisi lacus sed. Massa id neque aliquam vestibulum morbi blandit. Iaculis nunc sed augue lacus viverra vitae congue. Sodales neque sodales ut etiam.""", + 'Homepage about section content'), + 'HOMEPAGE_ABOUT_IMAGE': ( + '400x400.png', + 'Homepage about section image', + 'image_field'), } # yapf: disable CONSTANCE_CONFIG_FIELDSETS = { - 'Notice Banner': ( + 'Project options': ( + 'PARENT_PROJECT_NAME', + 'PROJECT_LONG_NAME', + 'PROJECT_SHORT_NAME', + 'PROJECT_LEAD', + 'PROJECT_TAGLINE', + ), + 'Homepage configuration': ( + 'HOMEPAGE_HEADER_IMAGE', + 'HOMEPAGE_CARD_1_TITLE', + 'HOMEPAGE_CARD_1_DESCRIPTION', + 'HOMEPAGE_CARD_1_ICON', + 'HOMEPAGE_CARD_2_TITLE', + 'HOMEPAGE_CARD_2_DESCRIPTION', + 'HOMEPAGE_CARD_2_ICON', + 'HOMEPAGE_CARD_3_TITLE', + 'HOMEPAGE_CARD_3_DESCRIPTION', + 'HOMEPAGE_CARD_3_ICON', + 'HOMEPAGE_ABOUT_TITLE', + 'HOMEPAGE_ABOUT_CONTENT', + 'HOMEPAGE_ABOUT_IMAGE', + ), + 'Notice banner': ( 'NOTICE_TEXT', 'NOTICE_CLASS', ), - 'Data Collection': ( + 'Data Ccollection': ( 'CONSENT_TEXT', ), - 'Help Text': ( + 'Help text': ( 'PERSON_LIST_HELP', 'ORGANISATION_LIST_HELP', 'RELATIONSHIP_FORM_HELP', @@ -396,7 +463,7 @@ BOOTSTRAP4 = { EMAIL_HOST = config('EMAIL_HOST', default=None) DEFAULT_FROM_EMAIL = config( 'DEFAULT_FROM_EMAIL', - default=f'{PROJECT_SHORT_NAME}@localhost.localdomain') + default=f'{CONSTANCE_CONFIG["PROJECT_SHORT_NAME"][0]}@localhost.localdomain') SERVER_EMAIL = DEFAULT_FROM_EMAIL if EMAIL_HOST is None: diff --git a/breccia_mapper/static/media/400x400.png b/breccia_mapper/static/media/400x400.png new file mode 100644 index 0000000000000000000000000000000000000000..86686275c6f7af94753c34f0b16834564bec4727 GIT binary patch literal 1307 zcmbVKX;Tsi0HsOo@JORwJU3Ib)x>M5T`_5BQSqcuDH3a2bV=9nmc(=orj^u28ds78 zodPfL$d#l-ZE_y-L|^{0DyT2 z5`hK)w$Z=RX#19ve)I6=me`#Pj}DZ{WDEv_#bWVzJh52Z+SGHY%u0`)p{U!e{^lcm~q@LpzUi)YyI>(Dr8 zK}Y17Og0jkY4c$3>|nmmpYU5x_(k(L0g3W?%|!#W=jw$4lOV)a!T-R<{(qJ@C=`Dt zubP!yh4EXw5eqKy(I1K_j;WDWtt;+mFxc@9M@5O&6$3VTYCO~lOKnV7+k%wvN}ozL zPb@RG>1c7#Mq{(C(66ryvTu{?l6<^-vtu+Xsp*&VH$g>ZEJ24$H za{Xis@@nFt5B}y4Qkv;~PG!HuwW2{7t66 z;0H`2>#E-?W~_hvC3Ezb^8qDg?#>SN{%|#S0aL5G{fa9$PNbhgCvYQ{9f@*P61`WF z>rLp24;p5{kevMl4p(w9%@Ic1HFr8>DodvGIHw_>RkwQ|9|%WJJs)Y#vz!o`H~>1yIJuFD>a&&p_oP3JBbZmj8)tC+<^10F@ry*4Hv=NU+P<*xK~@p=N4u$q90#c zu(qQnYwuaOU{;BP8El5 zB-eS>cWerhS3)d>_9;r3k!oY)im+J9uU&Shj#O(6HsvhoD$uR+Dwv!yzzWOnjO%~$*!A{ThdZAPBd~WZ2a-HaY*A7 z-j)k>7(IX-TMiU@O8SMJ+a*C&e7Uc7*chmSJ-?ms&Q3z2}>JScv;pnRy zP$ReYVxF}}%7RHNb$ssQmrU&MZHimXygBOzWh?=#vU1ZZd!3#Bc*!iKW#1L$tz;$c zq0VAXKeKXc=>h#re|u-`r=WPBf$tE#G+dK*I(-6nQP@$0N0hzGhM%jRpc076(H*b<(_5niL0Vp{C ZX}?2K4-nhhp#C~eNMIzQ`NS`je*t-DXfyx- literal 0 HcmV?d00001 diff --git a/breccia_mapper/static/media/800x500.png b/breccia_mapper/static/media/800x500.png new file mode 100644 index 0000000000000000000000000000000000000000..af408ed07511c9656da41b633a3adef94a7d6efe GIT binary patch literal 2776 zcmc&$i8mYA9?m>7lva7Q4G~J)r$wcvMhsF@Vry${g4&l#5rU=?OUtNQTOA0xTP!*wQqGB8R-rw-vJ?EZ#?!Di+zvcVR@BWf)t8)@byQb!xIP^e*JI(+TMVhG8l~P?CgSq0uqTtr_<}}>q|>ZTUuIZG+JWR z-RFle(GZJU7y#g8_tE?zOXDOE01)%Bgk6P)7jUQhF;ltq)VZ>4ujM)(a4Z8Hbe&ymKwryEHnLC%VafgY%0}a- zLreeH@ei2}gW~%KYdxJF1H*ETslKIkjc+08j&)82@Lwd5nk2>~@NAaeac#3-{W(rcR} zc!AU5Q|b=Ilz3gI=qA?}*t9XTYe`2I{B{>PSOVFl=`Qf(bVD4dN>WNyR&5lR$2dNw zhRkglH^c!Wy{1EDoBlkdDx)?Ad`jD&iNPoyRInC+#E)zuDE^`*EXAAD;UtKAv5ovU z4i?9MTgh1UZ{+_(C%W^}H@`j(UO2S^v8T@FzT}L%7(hwCS`P=J3`~cLGSQ48-2kh| zun3Xm7&=)JS3x+UBD}e|QNFeB9(gs&MagRomNf%J)ha0 z%&^?#8?F3Vgr|Y}cLcA>FE_SC{%K`^`HQfhspkdiP=6GQEVCo3U6^s?Q;}H`=Iw+pn&NQlSAiM=}byW;x8pw#8C>3TEH6O zRLczpBjt#>uF`ao$7*3IpRnb~56qgFCCxsT*$nN}m2U!O|D$vg%9GImOXfUJsU_;6 zr!K4be!^|ZzmEMpY+;d80y#0bs^?AHvY1v3W@4s))ty!~Uv`sabzL(r-(JPswR0)o zno;*g|G}R3&PkQ)>eiWgAoWayu!A(~Ge|k3LRu;>Uj<_Lx0`!;l2(Iat8I6tyK^}z zZphTWat-o~jES!efw5LQ_+->od!+&0^e$_XlSh%25q(*4E8iu$wVZVhjjM$_5~Os7FMHijYUKqEQh@d)6-Gh z`#L;0ibJwa%N`asg%0B6uuK$G+5XB} zOuILjxU#PM*F+j9bca;31zTzmqrnw}h3tJRJgzj)nre9HeXt5+7t*--;!<8rT}Z~H z{j$gf`rcL3*J|#~M*=mu*^b>9tr3g^!OoOzrQP#XJG54Z9eOrHo;+Bz)R>lez+lfg z*iRKF(=B-k$Uh?ko3PQJ<|a*T5By4ZW(0`Vgv0b0$RYN`v$UL1Eq)8S?d+N|ekkZ7 zio6=@Xu?&uXgm5P!e>bd380xNl(OHju7A}*UFXW)`86)ro~w#b#NBTsTR$um-KqO{ ziL{d_(XPDEKL%p9&Fr9HZvduNj+p?>RNy(0+Fvur_)D0vo<5u^QWm%Ln<%B$G_)OSB< zZ6#j8eF4?}$k3zmdM19~f7|N|1y_@hU$Gmr>pL$h?^;m6uajMli^T0_ExSnftsZElzta!n!1@*zqlEML>jaQ(;!SYAzJ?%Hu`#QJBV^E@hQ zTRklIpicw{jg}UDspOvf=<7;iDEO9J2EG?Bv25B^tW;aBzobtS3{Rw~`0(muh`Z3U zk#&<%NrJoAjFq~a%;9UoRmu+AXgio&%myF7#0c~|^~*OnxmS{Gcupl)Pm zq*k^v#wzFQ!3g++{Mpf)2g1)I4n{fv0g;I|dxz~M01%_!A*4FAqVsBNwDf3iX=)9t IHTHb?H - {{ settings.PROJECT_LONG_NAME }} + {{ config.PROJECT_LONG_NAME }} {% bootstrap_css %} @@ -58,7 +58,7 @@