diff --git a/breccia_mapper/settings.py b/breccia_mapper/settings.py
index d2424c7..395c659 100644
--- a/breccia_mapper/settings.py
+++ b/breccia_mapper/settings.py
@@ -94,6 +94,10 @@ The most likely required settings are: SECRET_KEY, DEBUG, ALLOWED_HOSTS, DATABAS
- EMAIL_USE_SSL
default: True if EMAIL_PORT == 465 else False
Use SSL to communicate with SMTP server? Usually on port 465
+
+- GOOGLE_MAPS_API_KEY
+ default: None
+ Google Maps API key to display maps of people's locations
"""
import collections
@@ -113,6 +117,7 @@ SETTINGS_EXPORT = [
'DEBUG',
'PROJECT_LONG_NAME',
'PROJECT_SHORT_NAME',
+ 'GOOGLE_MAPS_API_KEY',
]
PROJECT_LONG_NAME = config('PROJECT_LONG_NAME', default='Project Long Name')
@@ -374,17 +379,28 @@ else:
default=(EMAIL_PORT == 465),
cast=bool)
+
+# Upstream API keys
+
+GOOGLE_MAPS_API_KEY = config('GOOGLE_MAPS_API_KEY', default=None)
+
+
# Import customisation app settings if present
try:
from custom.settings import (
CUSTOMISATION_NAME,
TEMPLATE_NAME_INDEX,
- TEMPLATE_WELCOME_EMAIL_NAME
- )
- logger.info("Loaded customisation app: %s", CUSTOMISATION_NAME)
+ TEMPLATE_WELCOME_EMAIL_NAME,
+ CONSTANCE_CONFIG as constance_config_custom,
+ CONSTANCE_CONFIG_FIELDSETS as constance_config_fieldsets_custom
+ ) # yapf: disable
+
+ CONSTANCE_CONFIG.update(constance_config_custom)
+ CONSTANCE_CONFIG_FIELDSETS.update(constance_config_fieldsets_custom)
INSTALLED_APPS.append('custom')
+ logger.info("Loaded customisation app: %s", CUSTOMISATION_NAME)
except ImportError as exc:
logger.info("No customisation app loaded: %s", exc)
diff --git a/breccia_mapper/templates/base.html b/breccia_mapper/templates/base.html
index 1b93a01..faf19a4 100644
--- a/breccia_mapper/templates/base.html
+++ b/breccia_mapper/templates/base.html
@@ -66,6 +66,10 @@
People
+
+ Organisations
+
+
Activity Series
@@ -74,6 +78,10 @@
Activities
+
+ Map
+
+
Network
diff --git a/people/forms.py b/people/forms.py
index 80c6fad..607214f 100644
--- a/people/forms.py
+++ b/people/forms.py
@@ -1,6 +1,4 @@
-"""
-Forms for creating / updating models belonging to the 'people' app.
-"""
+"""Forms for creating / updating models belonging to the 'people' app."""
import typing
@@ -24,6 +22,13 @@ def get_date_year_range() -> typing.Iterable[int]:
return range(this_year, this_year - num_years_display, -1)
+class OrganisationForm(forms.ModelForm):
+ """Form for creating / updating an instance of :class:`Organisation`."""
+ class Meta:
+ model = models.Organisation
+ fields = ['name', 'latitude', 'longitude']
+
+
class PersonForm(forms.ModelForm):
"""Form for creating / updating an instance of :class:`Person`."""
class Meta:
@@ -48,6 +53,8 @@ class DynamicAnswerSetBase(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
+ initial = kwargs.get('initial', {})
+
for question in self.question_model.objects.all():
field_class = self.field_class
field_widget = self.field_widget
@@ -56,11 +63,14 @@ class DynamicAnswerSetBase(forms.Form):
field_class = forms.ModelMultipleChoiceField
field_widget = Select2MultipleWidget
+ field_name = f'question_{question.pk}'
+
field = field_class(label=question,
queryset=question.answers,
widget=field_widget,
- required=self.field_required)
- self.fields['question_{}'.format(question.pk)] = field
+ required=self.field_required,
+ initial=initial.get(field_name, None))
+ self.fields[field_name] = field
class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
@@ -78,11 +88,15 @@ class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
'job_title',
'disciplines',
'themes',
+ 'latitude',
+ 'longitude',
]
widgets = {
'nationality': Select2Widget(),
'country_of_residence': Select2Widget(),
'themes': Select2MultipleWidget(),
+ 'latitude': forms.HiddenInput,
+ 'longitude': forms.HiddenInput,
}
help_texts = {
'organisation_started_date':
@@ -93,7 +107,10 @@ class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
def save(self, commit=True) -> models.PersonAnswerSet:
# Save Relationship model
- self.instance = super().save(commit=commit)
+ self.instance = super().save(commit=False)
+ self.instance.person_id = self.initial['person_id']
+ if commit:
+ self.instance.save()
if commit:
# Save answers to relationship questions
diff --git a/people/migrations/0028_person_location_fields.py b/people/migrations/0028_person_location_fields.py
new file mode 100644
index 0000000..860c34a
--- /dev/null
+++ b/people/migrations/0028_person_location_fields.py
@@ -0,0 +1,23 @@
+# Generated by Django 2.2.10 on 2020-12-15 13:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('people', '0027_multiple_choice_questions'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='personanswerset',
+ name='latitude',
+ field=models.FloatField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='personanswerset',
+ name='longitude',
+ field=models.FloatField(blank=True, null=True),
+ ),
+ ]
diff --git a/people/migrations/0029_organisation_location_fields.py b/people/migrations/0029_organisation_location_fields.py
new file mode 100644
index 0000000..7788b41
--- /dev/null
+++ b/people/migrations/0029_organisation_location_fields.py
@@ -0,0 +1,23 @@
+# Generated by Django 2.2.10 on 2021-01-15 13:38
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('people', '0028_person_location_fields'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='organisation',
+ name='latitude',
+ field=models.FloatField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='organisation',
+ name='longitude',
+ field=models.FloatField(blank=True, null=True),
+ ),
+ ]
diff --git a/people/models/person.py b/people/models/person.py
index e8fff6c..670f0db 100644
--- a/people/models/person.py
+++ b/people/models/person.py
@@ -64,14 +64,21 @@ class User(AbstractUser):
class Organisation(models.Model):
- """
- Organisation to which a :class:`Person` belongs.
- """
+ """Organisation to which a :class:`Person` belongs."""
name = models.CharField(max_length=255, blank=False, null=False)
+ #: Latitude for displaying location on a map
+ latitude = models.FloatField(blank=True, null=True)
+
+ #: Longitude for displaying location on a map
+ longitude = models.FloatField(blank=True, null=True)
+
def __str__(self) -> str:
return self.name
+ def get_absolute_url(self):
+ return reverse('people:organisation.detail', kwargs={'pk': self.pk})
+
class Theme(models.Model):
"""
@@ -177,5 +184,56 @@ class PersonAnswerSet(AnswerSet):
#: Project themes within this person works
themes = models.ManyToManyField(Theme, related_name='people', blank=True)
+ #: Latitude for displaying location on a map
+ latitude = models.FloatField(blank=True, null=True)
+
+ #: Longitude for displaying location on a map
+ longitude = models.FloatField(blank=True, null=True)
+
+ def as_dict(self):
+ """Get the answers from this set as a dictionary for use in Form.initial."""
+ exclude_fields = {
+ 'id',
+ 'timestemp',
+ 'replaced_timestamp',
+ 'person_id',
+ 'question_answers',
+ 'themes',
+ }
+
+ def field_value_repr(field):
+ """Get the representation of a field's value as required by Form.initial."""
+ attr_val = getattr(self, field.attname)
+
+ # Relation fields need to return PKs
+ if isinstance(field, models.ManyToManyField):
+ return [obj.pk for obj in attr_val.all()]
+
+ # But foreign key fields are a PK already so no extra work
+
+ return attr_val
+
+ answers = {
+ # Foreign key fields have _id at end in model _meta but don't in forms
+ field.attname.rstrip('_id'): field_value_repr(field)
+ for field in self._meta.get_fields()
+ if field.attname not in exclude_fields
+ }
+
+ for answer in self.question_answers.all():
+ question = answer.question
+ field_name = f'question_{question.pk}'
+
+ if question.is_multiple_choice:
+ if field_name not in answers:
+ answers[field_name] = []
+
+ answers[field_name].append(answer.pk)
+
+ else:
+ answers[field_name] = answer.pk
+
+ return answers
+
def get_absolute_url(self):
return self.person.get_absolute_url()
diff --git a/people/models/relationship.py b/people/models/relationship.py
index 83346ea..f6cdbe0 100644
--- a/people/models/relationship.py
+++ b/people/models/relationship.py
@@ -52,7 +52,7 @@ class Relationship(models.Model):
class Meta:
constraints = [
- models.UniqueConstraint(fields=['source', 'person'],
+ models.UniqueConstraint(fields=['source', 'target'],
name='unique_relationship'),
]
diff --git a/people/static/js/location_picker.js b/people/static/js/location_picker.js
new file mode 100644
index 0000000..fc15d4d
--- /dev/null
+++ b/people/static/js/location_picker.js
@@ -0,0 +1,91 @@
+
+let marker = null;
+let search_markers = []
+
+/**
+ * Position a map marker at the clicked location and update lat/long form fields.
+ * @param {Event} event - Click event from a Google Map.
+ */
+function selectLocation(event) {
+ if (marker === null) {
+ // Generate a new marker
+ marker = new google.maps.Marker({
+ position: event.latLng,
+ map: map,
+ icon: {
+ path: google.maps.SymbolPath.BACKWARD_CLOSED_ARROW,
+ strokeColor: marker_edge_colour,
+ strokeWeight: marker_edge_width,
+ strokeOpacity: marker_edge_alpha,
+ fillColor: marker_fill_colour,
+ fillOpacity: marker_fill_alpha,
+ scale: marker_scale,
+ labelOrigin: new google.maps.Point(0, -marker_label_offset)
+ },
+ });
+ } else {
+ marker.setPosition(event.latLng);
+ }
+
+ const pos = marker.getPosition();
+ document.getElementById('id_latitude').value = pos.lat();
+ document.getElementById('id_longitude').value = pos.lng();
+}
+
+function displaySearchResults() {
+ const places = search_box.getPlaces()
+
+ if (places.length === 0) return
+
+ for (const marker of markers) marker.setMap(null)
+ search_markers = []
+
+ const bounds = new google.maps.LatLngBounds()
+ for (const place of places) {
+ if (!place.geometry) {
+ console.error('Place contains no geometry')
+ continue
+ }
+
+ const icon = {
+ size: new google.maps.Size(71, 71),
+ origin: new google.maps.Point(0, 0),
+ anchor: new google.maps.Point(17, 34),
+ scaledSize: new google.maps.Size(25, 25),
+ }
+
+ search_markers.push(
+ new google.maps.Marker({
+ map, icon, title: place.name, position: place.geometry.location
+ })
+ )
+
+ if (place.geometry.viewport) {
+ bounds.union(place.geometry.viewport)
+ } else {
+ bounds.extend(place.geometry.location)
+ }
+
+ }
+
+ map.fitBounds(bounds)
+}
+
+/**
+ * Initialise Google Maps element as a location picker.
+ */
+function initPicker() {
+ map = initMap()
+
+ const search_input = document.getElementById('location-search')
+ const search_box = new google.maps.places.SearchBox(search_input)
+ map.controls[google.maps.ControlPosition.TOP_LEFT].push(search_input)
+
+ map.addListener('bounds_changed', () => {
+ search_box.setBounds(map.getBounds())
+ })
+
+ search_box.addListener('places_changed', displaySearchResults)
+
+ map.addListener('click', selectLocation)
+}
diff --git a/people/static/js/map.js b/people/static/js/map.js
new file mode 100644
index 0000000..5945c99
--- /dev/null
+++ b/people/static/js/map.js
@@ -0,0 +1,103 @@
+const marker_fill_alpha = 1.0;
+const marker_edge_colour = 'white';
+const marker_fill_colour = 'gray';
+
+// Size of the arrow markers used on the map
+const marker_scale = 9;
+// Offset for the place type icon (multiplier for marker scale)
+const marker_label_offset = 0.27 * marker_scale;
+// Width and transparency for the edges of the markers
+const marker_edge_alpha = 1.0;
+const marker_edge_width = 1.0;
+
+let map = null;
+let selected_marker_info = null;
+
+function createMarker(map, marker_data) {
+ // Get the lat-long position from the data
+ let lat_lng;
+ if (marker_data.lat != null && marker_data.lng != null) {
+ lat_lng = new google.maps.LatLng(marker_data.lat, marker_data.lng);
+
+ } else if (marker_data.org_lat != null && marker_data.org_lng != null) {
+ lat_lng = new google.maps.LatLng(marker_data.org_lat, marker_data.org_lng);
+
+ } else {
+ throw new Error(`No lat/lng set for marker '${marker_data.name}'`)
+ }
+
+ const marker = new google.maps.Marker({
+ position: lat_lng,
+ map: map,
+ icon: {
+ path: google.maps.SymbolPath.BACKWARD_CLOSED_ARROW,
+ strokeColor: marker_edge_colour,
+ strokeWeight: marker_edge_width,
+ strokeOpacity: marker_edge_alpha,
+ fillColor: marker_fill_colour,
+ fillOpacity: marker_fill_alpha,
+ scale: marker_scale,
+ labelOrigin: new google.maps.Point(0, -marker_label_offset)
+ },
+ });
+
+ marker.info = new google.maps.InfoWindow({
+ content: ""
+ });
+
+ // We bind a listener to the current marker so that if it's clicked, it checks for an open info window,
+ // closes it, then opens the info window attached to it specifically. Then sets that as the last window.
+ google.maps.event.addListener(marker, 'click', function () {
+ if (selected_marker_info) {
+ selected_marker_info.close();
+ }
+
+ selected_marker_info = this.info;
+ this.info.open(map, this);
+ })
+
+ return marker;
+}
+
+// The function called when Google Maps starts up
+function initMap() {
+ map = new google.maps.Map(
+ document.getElementById('map'));
+
+ const bounds = new google.maps.LatLngBounds()
+ const markers_data = JSON.parse(
+ document.getElementById('map-markers').textContent)
+
+ // For each data entry in the json...
+ for (const marker_data of markers_data) {
+ try {
+ const marker = createMarker(map, marker_data);
+ bounds.extend(marker.position);
+
+ } catch (exc) {
+ // Just skip and move on to next
+ }
+ }
+
+ map.fitBounds(bounds)
+ const max_zoom = 10
+ if (map.getZoom() > max_zoom) {
+ map.setZoom(max_zoom)
+ }
+
+ setTimeout(setMaxZoom, 100)
+
+ return map
+}
+
+/**
+ * Zoom to set level if map is zoomed in more than this.
+ */
+function setMaxZoom() {
+ const max_zoom = 10
+ if (map.getZoom() > max_zoom) {
+ map.setZoom(max_zoom)
+ }
+}
diff --git a/people/templates/people/organisation/create.html b/people/templates/people/organisation/create.html
new file mode 100644
index 0000000..5c714ae
--- /dev/null
+++ b/people/templates/people/organisation/create.html
@@ -0,0 +1,29 @@
+{% extends 'base.html' %}
+
+{% block content %}
+
+
+
+ Organisations
+
+ Create
+
+
+
+ New Organisation
+
+
+
+
+
+{% endblock %}
diff --git a/people/templates/people/organisation/detail.html b/people/templates/people/organisation/detail.html
new file mode 100644
index 0000000..5d01d2e
--- /dev/null
+++ b/people/templates/people/organisation/detail.html
@@ -0,0 +1,36 @@
+{% extends 'base.html' %}
+
+{% block extra_head %}
+ {{ map_markers|json_script:'map-markers' }}
+
+ {% load staticfiles %}
+
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+ Organisations
+
+ {{ object }}
+
+
+
+ {{ organisation.name }}
+
+
+
+ Update
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/people/templates/people/organisation/list.html b/people/templates/people/organisation/list.html
new file mode 100644
index 0000000..39370e2
--- /dev/null
+++ b/people/templates/people/organisation/list.html
@@ -0,0 +1,42 @@
+{% extends 'base.html' %}
+
+{% block content %}
+
+
+ Organisation
+
+
+
+ People
+
+
+
+ New Organisation
+
+
+
+
+ Name
+
+
+
+
+ {% for organisation in organisation_list.all %}
+
+ {{ organisation }}
+
+ Details
+
+
+
+ {% empty %}
+
+ No records
+
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/people/templates/people/organisation/update.html b/people/templates/people/organisation/update.html
new file mode 100644
index 0000000..7b5aa44
--- /dev/null
+++ b/people/templates/people/organisation/update.html
@@ -0,0 +1,53 @@
+{% extends 'base.html' %}
+
+{% block extra_head %}
+ {% load staticfiles %}
+ {{ map_markers|json_script:'map-markers' }}
+
+
+
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+ Organisations
+
+
+ {{ organisation }}
+
+ Update
+
+
+
+ {{ organisation.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/people/templates/people/person/detail.html b/people/templates/people/person/detail.html
index 367bac5..b071b83 100644
--- a/people/templates/people/person/detail.html
+++ b/people/templates/people/person/detail.html
@@ -1,5 +1,15 @@
{% extends 'base.html' %}
+{% block extra_head %}
+ {{ map_markers|json_script:'map-markers' }}
+
+ {% load staticfiles %}
+
+
+
+{% endblock %}
+
{% block content %}
@@ -23,70 +33,7 @@
{% endif %}
- {% with person.current_answers as answer_set %}
-
-
-
-
-
-
- Question
- Answer
-
-
-
-
- {% if answer_set.nationality %}
- Nationality {{ answer_set.nationality.name }}
- {% endif %}
-
- {% if answer_set.country_of_residence %}
- Country of Residence {{ answer_set.country_of_residence.name }}
- {% endif %}
-
- {% if answer_set.organisation %}
- Organisation {{ answer_set.organisation }}
- {% endif %}
-
- {% if answer_set.organisation_started_date %}
- Organisation Started Date {{ answer_set.organisation_started_date }}
- {% endif %}
-
- {% if answer_set.job_title %}
- Job Title {{ answer_set.job_title }}
- {% endif %}
-
- {% if answer_set.disciplines %}
- Discipline(s) {{ answer_set.disciplines }}
- {% endif %}
-
- {% if answer_set.themes.exists %}
-
- Project Themes
-
- {% for theme in answer_set.themes.all %}
- {{ theme }}{% if not forloop.last %}, {% endif %}
- {% endfor %}
-
-
- {% endif %}
-
- {% for answer in answer_set.question_answers.all %}
-
- {{ answer.question }}
- {{ answer }}
-
-
- {% empty %}
-
- No records
-
- {% endfor %}
-
-
-
- Last updated: {{ answer_set.timestamp }}
- {% endwith %}
+ {% include 'people/person/includes/answer_set.html' %}
Update
@@ -99,6 +46,10 @@
+
+
+
+
Relationships As Source
@@ -207,3 +158,6 @@
{% endblock %}
+
+{% block extra_script %}
+{% endblock %}
diff --git a/people/templates/people/person/includes/answer_set.html b/people/templates/people/person/includes/answer_set.html
new file mode 100644
index 0000000..8bcb346
--- /dev/null
+++ b/people/templates/people/person/includes/answer_set.html
@@ -0,0 +1,59 @@
+
+
+
+ Question
+ Answer
+
+
+
+
+ {% if answer_set.nationality %}
+ Nationality {{ answer_set.nationality.name }}
+ {% endif %}
+
+ {% if answer_set.country_of_residence %}
+ Country of Residence {{ answer_set.country_of_residence.name }}
+ {% endif %}
+
+ {% if answer_set.organisation %}
+ Organisation {{ answer_set.organisation }}
+ {% endif %}
+
+ {% if answer_set.organisation_started_date %}
+ Organisation Started Date {{ answer_set.organisation_started_date }}
+ {% endif %}
+
+ {% if answer_set.job_title %}
+ Job Title {{ answer_set.job_title }}
+ {% endif %}
+
+ {% if answer_set.disciplines %}
+ Discipline(s) {{ answer_set.disciplines }}
+ {% endif %}
+
+ {% if answer_set.themes.exists %}
+
+ Project Themes
+
+ {% for theme in answer_set.themes.all %}
+ {{ theme }}{% if not forloop.last %}, {% endif %}
+ {% endfor %}
+
+
+ {% endif %}
+
+ {% for answer in answer_set.question_answers.all %}
+
+ {{ answer.question }}
+ {{ answer }}
+
+
+ {% empty %}
+
+ No records
+
+ {% endfor %}
+
+
+
+
Last updated: {{ answer_set.timestamp }}
diff --git a/people/templates/people/person/map.html b/people/templates/people/person/map.html
new file mode 100644
index 0000000..78c7803
--- /dev/null
+++ b/people/templates/people/person/map.html
@@ -0,0 +1,28 @@
+{% extends 'base.html' %}
+
+{% block extra_head %}
+ {{ map_markers|json_script:'map-markers' }}
+
+ {% load staticfiles %}
+
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+ People
+
+ Map
+
+
+
+
Map
+
+
+
+{% endblock %}
diff --git a/people/templates/people/person/update.html b/people/templates/people/person/update.html
index 980f5a9..db4e256 100644
--- a/people/templates/people/person/update.html
+++ b/people/templates/people/person/update.html
@@ -1,5 +1,16 @@
{% extends 'base.html' %}
+{% block extra_head %}
+ {% load staticfiles %}
+ {{ map_markers|json_script:'map-markers' }}
+
+
+
+
+
+{% endblock %}
+
{% block content %}
@@ -22,11 +33,21 @@
{% csrf_token %}
{% load bootstrap4 %}
- {% bootstrap_form form %}
+ {% bootstrap_form form exclude='latitude,longitude' %}
+
+ {% bootstrap_field form.latitude %}
+ {% bootstrap_field form.longitude %}
{% buttons %}
Submit
{% endbuttons %}
+
+
+
+
+
+
+
{% endblock %}
diff --git a/people/urls.py b/people/urls.py
index eb73fe1..955d343 100644
--- a/people/urls.py
+++ b/people/urls.py
@@ -6,6 +6,22 @@ from . import views
app_name = 'people'
urlpatterns = [
+ path('organisations/create',
+ views.organisation.OrganisationCreateView.as_view(),
+ name='organisation.create'),
+
+ path('organisations',
+ views.organisation.OrganisationListView.as_view(),
+ name='organisation.list'),
+
+ path('organisations/',
+ views.organisation.OrganisationDetailView.as_view(),
+ name='organisation.detail'),
+
+ path('organisations//update',
+ views.organisation.OrganisationUpdateView.as_view(),
+ name='organisation.update'),
+
path('profile/',
views.person.ProfileView.as_view(),
name='person.profile'),
@@ -38,6 +54,10 @@ urlpatterns = [
views.relationship.RelationshipUpdateView.as_view(),
name='relationship.update'),
+ path('map',
+ views.person.PersonMapView.as_view(),
+ name='person.map'),
+
path('network',
views.network.NetworkView.as_view(),
name='network'),
diff --git a/people/views/__init__.py b/people/views/__init__.py
index e7ddb60..26a117e 100644
--- a/people/views/__init__.py
+++ b/people/views/__init__.py
@@ -4,6 +4,7 @@ Views for displaying or manipulating models within the `people` app.
from . import (
network,
+ organisation,
person,
relationship
)
@@ -11,6 +12,7 @@ from . import (
__all__ = [
'network',
+ 'organisation',
'person',
'relationship',
]
diff --git a/people/views/organisation.py b/people/views/organisation.py
new file mode 100644
index 0000000..c67542d
--- /dev/null
+++ b/people/views/organisation.py
@@ -0,0 +1,60 @@
+import typing
+
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.views.generic import CreateView, DetailView, ListView, UpdateView
+
+from people import forms, models
+
+
+class OrganisationCreateView(LoginRequiredMixin, CreateView):
+ """View to create a new instance of :class:`Organisation`."""
+ model = models.Organisation
+ template_name = 'people/organisation/create.html'
+ form_class = forms.OrganisationForm
+
+
+class OrganisationListView(LoginRequiredMixin, ListView):
+ """View displaying a list of :class:`organisation` objects."""
+ model = models.Organisation
+ template_name = 'people/organisation/list.html'
+
+
+class OrganisationDetailView(LoginRequiredMixin, DetailView):
+ """View displaying details of a :class:`Organisation`."""
+ model = models.Organisation
+ context_object_name = 'organisation'
+ template_name = 'people/organisation/detail.html'
+
+ def get_context_data(self,
+ **kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
+ """Add map marker to context."""
+ context = super().get_context_data(**kwargs)
+
+ context['map_markers'] = [{
+ 'name': self.object.name,
+ 'lat': self.object.latitude,
+ 'lng': self.object.longitude,
+ }]
+
+ return context
+
+
+class OrganisationUpdateView(LoginRequiredMixin, UpdateView):
+ """View for updating a :class:`Organisation` record."""
+ model = models.Organisation
+ context_object_name = 'organisation'
+ template_name = 'people/organisation/update.html'
+ form_class = forms.OrganisationForm
+
+ def get_context_data(self,
+ **kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
+ """Add map marker to context."""
+ context = super().get_context_data(**kwargs)
+
+ context['map_markers'] = [{
+ 'name': self.object.name,
+ 'lat': self.object.latitude,
+ 'lng': self.object.longitude,
+ }]
+
+ return context
diff --git a/people/views/person.py b/people/views/person.py
index c495a86..73f2e3b 100644
--- a/people/views/person.py
+++ b/people/views/person.py
@@ -2,7 +2,10 @@
Views for displaying or manipulating instances of :class:`Person`.
"""
+import typing
+
from django.contrib.auth.mixins import LoginRequiredMixin
+from django.urls import reverse
from django.utils import timezone
from django.views.generic import CreateView, DetailView, ListView, UpdateView
@@ -54,42 +57,101 @@ class ProfileView(permissions.UserIsLinkedPersonMixin, DetailView):
# pk was not provided in URL
return self.request.user.person
+ def get_context_data(self,
+ **kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
+ """Add current :class:`PersonAnswerSet` to context."""
+ context = super().get_context_data(**kwargs)
+
+ context['answer_set'] = self.object.current_answers
+ context['map_markers'] = [get_map_data(self.object)]
+
+ return context
+
class PersonUpdateView(permissions.UserIsLinkedPersonMixin, UpdateView):
"""View for updating a :class:`Person` record."""
- model = models.PersonAnswerSet
+ model = models.Person
+ context_object_name = 'person'
template_name = 'people/person/update.html'
form_class = forms.PersonAnswerSetForm
- def get_test_person(self) -> models.Person:
- """Get the person instance which should be used for access control checks."""
- return models.Person.objects.get(pk=self.kwargs.get('pk'))
-
- def get(self, request, *args, **kwargs):
- self.person = models.Person.objects.get(pk=self.kwargs.get('pk'))
-
- return super().get(request, *args, **kwargs)
-
- def post(self, request, *args, **kwargs):
- self.person = models.Person.objects.get(pk=self.kwargs.get('pk'))
-
- return super().post(request, *args, **kwargs)
-
- def get_context_data(self, **kwargs):
+ def get_context_data(self,
+ **kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
context = super().get_context_data(**kwargs)
- context['person'] = self.person
+ context['map_markers'] = [get_map_data(self.object)]
return context
+ def get_initial(self) -> typing.Dict[str, typing.Any]:
+ try:
+ previous_answers = self.object.current_answers.as_dict()
+
+ except AttributeError:
+ previous_answers = {}
+
+ previous_answers.update({
+ 'person_id': self.object.id,
+ })
+
+ return previous_answers
+
+ def get_form_kwargs(self) -> typing.Dict[str, typing.Any]:
+ """Remove instance from form kwargs as it's a person, but expects a PersonAnswerSet."""
+ kwargs = super().get_form_kwargs()
+ kwargs.pop('instance')
+
+ return kwargs
+
def form_valid(self, form):
"""Mark any previous answer sets as replaced."""
response = super().form_valid(form)
now_date = timezone.now().date()
+ # Saving the form made self.object a PersonAnswerSet - so go up, then back down
# Shouldn't be more than one after initial updates after migration
- for answer_set in self.person.answer_sets.exclude(pk=self.object.pk):
+ for answer_set in self.object.person.answer_sets.exclude(
+ pk=self.object.pk):
answer_set.replaced_timestamp = now_date
answer_set.save()
return response
+
+
+def get_map_data(person: models.Person) -> typing.Dict[str, typing.Any]:
+ """Prepare data to mark people on a map."""
+ answer_set = person.current_answers
+ organisation = getattr(answer_set, 'organisation', None)
+
+ try:
+ country = answer_set.country_of_residence.name
+
+ except AttributeError:
+ country = None
+
+ return {
+ 'name': person.name,
+ 'lat': getattr(answer_set, 'latitude', None),
+ 'lng': getattr(answer_set, 'longitude', None),
+ 'organisation': getattr(organisation, 'name', None),
+ 'org_lat': getattr(organisation, 'latitude', None),
+ 'org_lng': getattr(organisation, 'longitude', None),
+ 'country': country,
+ 'url': reverse('people:person.detail', kwargs={'pk': person.pk})
+ }
+
+
+class PersonMapView(LoginRequiredMixin, ListView):
+ """View displaying a map of :class:`Person` locations."""
+ model = models.Person
+ template_name = 'people/person/map.html'
+
+ def get_context_data(self,
+ **kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
+ context = super().get_context_data(**kwargs)
+
+ context['map_markers'] = [
+ get_map_data(person) for person in self.object_list
+ ]
+
+ return context
diff --git a/roles/defaults/main.yml b/roles/defaults/main.yml
new file mode 100644
index 0000000..bf12883
--- /dev/null
+++ b/roles/defaults/main.yml
@@ -0,0 +1,4 @@
+---
+db_name: 'breccia'
+db_user: 'breccia'
+db_pass: 'breccia'
diff --git a/roles/webserver/defaults/main.yml b/roles/webserver/defaults/main.yml
index 5d9fbcf..bc0d0af 100644
--- a/roles/webserver/defaults/main.yml
+++ b/roles/webserver/defaults/main.yml
@@ -14,6 +14,8 @@ venv_dir: '{{ project_dir }}/venv'
web_user: nginx
web_group: nginx
db_name: '{{ project_name }}'
+db_user: 'breccia'
+db_pass: 'breccia'
display_short_name: 'BRECcIA'
display_long_name: 'BRECcIA Mapper'
\ No newline at end of file
diff --git a/roles/webserver/templates/settings.j2 b/roles/webserver/templates/settings.j2
index f99d88e..e9ccb89 100644
--- a/roles/webserver/templates/settings.j2
+++ b/roles/webserver/templates/settings.j2
@@ -23,3 +23,7 @@ DEFAULT_FROM_EMAIL={{ default_from_email }}
{% if email_port is defined %}
EMAIL_PORT={{ email_port }}
{% endif %}
+
+{% if google_maps_api_key is defined %}
+GOOGLE_MAPS_API_KEY={{ google_maps_api_key }}
+{% endif %}