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 + + @@ -74,6 +78,10 @@ Activities + + 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: "
" + + "

" + marker_data.name.replace(''', "'") + "

" + + "
" + }); + + // 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 %} + + +

New Organisation

+ +
+ +
+ {% csrf_token %} + + {% load bootstrap4 %} + {% bootstrap_form form %} + + {% buttons %} + + {% endbuttons %} +
+ +{% 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 %} + + +

{{ 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 %} + + +

People

+ +
+ + New Organisation + + + + + + + + + + {% for organisation in organisation_list.all %} + + + + + + {% empty %} + + + + {% endfor %} + +
Name
{{ organisation }} + Details +
No records
+ +{% 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 %} + + +

{{ organisation.name }}

+ +
+ +
+ {% csrf_token %} + + {% load bootstrap4 %} + {% bootstrap_form form exclude='latitude,longitude' %} + + {% bootstrap_field form.latitude %} + {% bootstrap_field form.longitude %} + + {% buttons %} + + {% endbuttons %} +
+ +
+ + +
+ +
+ +{% 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 %}