Merge branch 'maps' into dev

This commit is contained in:
James Graham
2020-12-16 15:35:16 +00:00
17 changed files with 442 additions and 66 deletions

View File

@@ -94,6 +94,10 @@ The most likely required settings are: SECRET_KEY, DEBUG, ALLOWED_HOSTS, DATABAS
- EMAIL_USE_SSL - EMAIL_USE_SSL
default: True if EMAIL_PORT == 465 else False default: True if EMAIL_PORT == 465 else False
Use SSL to communicate with SMTP server? Usually on port 465 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 import collections
@@ -113,6 +117,7 @@ SETTINGS_EXPORT = [
'DEBUG', 'DEBUG',
'PROJECT_LONG_NAME', 'PROJECT_LONG_NAME',
'PROJECT_SHORT_NAME', 'PROJECT_SHORT_NAME',
'GOOGLE_MAPS_API_KEY',
] ]
PROJECT_LONG_NAME = config('PROJECT_LONG_NAME', default='Project Long Name') PROJECT_LONG_NAME = config('PROJECT_LONG_NAME', default='Project Long Name')
@@ -393,3 +398,7 @@ except ImportError as exc:
CUSTOMISATION_NAME = None CUSTOMISATION_NAME = None
TEMPLATE_NAME_INDEX = 'index.html' TEMPLATE_NAME_INDEX = 'index.html'
TEMPLATE_WELCOME_EMAIL_NAME = 'welcome-email' TEMPLATE_WELCOME_EMAIL_NAME = 'welcome-email'
# Upstream API keys
GOOGLE_MAPS_API_KEY = config('GOOGLE_MAPS_API_KEY', default=None)

View File

@@ -74,6 +74,10 @@
<a href="{% url 'activities:activity.list' %}" class="nav-link">Activities</a> <a href="{% url 'activities:activity.list' %}" class="nav-link">Activities</a>
</li> </li>
<li class="nav-item">
<a href="{% url 'people:person.map' %}" class="nav-link">Map</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a href="{% url 'people:network' %}" class="nav-link">Network</a> <a href="{% url 'people:network' %}" class="nav-link">Network</a>
</li> </li>

View File

@@ -78,6 +78,8 @@ class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
'job_title', 'job_title',
'disciplines', 'disciplines',
'themes', 'themes',
'latitude',
'longitude',
] ]
widgets = { widgets = {
'nationality': Select2Widget(), 'nationality': Select2Widget(),

View File

@@ -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),
),
]

View File

@@ -177,5 +177,11 @@ class PersonAnswerSet(AnswerSet):
#: Project themes within this person works #: Project themes within this person works
themes = models.ManyToManyField(Theme, related_name='people', blank=True) themes = models.ManyToManyField(Theme, related_name='people', blank=True)
#: Latitude for displaying locaiton on a map
latitude = models.FloatField(blank=True, null=True)
#: Longitude for displaying locaiton on a map
longitude = models.FloatField(blank=True, null=True)
def get_absolute_url(self): def get_absolute_url(self):
return self.person.get_absolute_url() return self.person.get_absolute_url()

View File

@@ -52,7 +52,7 @@ class Relationship(models.Model):
class Meta: class Meta:
constraints = [ constraints = [
models.UniqueConstraint(fields=['source', 'person'], models.UniqueConstraint(fields=['source', 'target'],
name='unique_relationship'), name='unique_relationship'),
] ]

View File

@@ -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 marker = null;
/**
* 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();
}
/**
* Initialise Google Maps element after library is loaded.
*/
function initMap() {
const centre_latlng = new google.maps.LatLng(settings.centre_lat | 0, settings.centre_lng | 0);
const map = new google.maps.Map(
document.getElementById('map'), { zoom: settings.zoom, center: centre_latlng });
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())
})
let markers = []
search_box.addListener('places_changed', () => {
const places = search_box.getPlaces()
if (places.length === 0) return
for (const marker of markers) marker.setMap(null)
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),
}
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)
})
map.addListener('click', selectLocation)
}

93
people/static/js/map.js Normal file
View File

@@ -0,0 +1,93 @@
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
// The function called when Google Maps starts up
function initMap() {
// const centre_latlng = new google.maps.LatLng(settings.centre_lat, settings.centre_lng);
// The map, centered at Soton
map = new google.maps.Map(
// document.getElementById('map'), { zoom: settings.zoom, center: centre_latlng });
document.getElementById('map'));
const bounds = new google.maps.LatLngBounds()
const markers_data = JSON.parse(
document.getElementById('map-markers').textContent
).filter(data => data.lat !== null && data.lng !== null);
// For each data entry in the json...
for (const pin_data of markers_data) {
// Get the lat-long position from the data
const lat_lng = new google.maps.LatLng(pin_data.lat, pin_data.lng);
console.log(lat_lng)
// Generate a new marker
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)
},
});
console.log(marker)
bounds.extend(marker.position)
// Build the info window content to tell the user the last time it was visited.
marker.info = new google.maps.InfoWindow({
content: "<div id='content'>" +
"<h3><a href=" + pin_data.url + ">" + pin_data.name.replace('&apos;', "'") + "</a></h3>" +
"</div>"
});
// 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 (last_info) {
last_info.close();
}
last_info = this.info;
this.info.open(map, this);
})
}
map.fitBounds(bounds)
const max_zoom = 10
if (map.getZoom() > max_zoom) {
map.setZoom(max_zoom)
}
// Set the last info window to null
var last_info = null;
setTimeout(setMaxZoom, 100)
}
/**
* 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)
}
}

View File

@@ -1,5 +1,15 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block extra_head %}
{{ map_markers|json_script:'map-markers' }}
{% load staticfiles %}
<script src="{% static 'js/map.js' %}"></script>
<script async defer src="https://maps.googleapis.com/maps/api/js?key={{ settings.GOOGLE_MAPS_API_KEY }}&callback=initMap"
type="text/javascript"></script>
{% endblock %}
{% block content %} {% block content %}
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
<ol class="breadcrumb"> <ol class="breadcrumb">
@@ -23,70 +33,7 @@
{% endif %} {% endif %}
{% with person.current_answers as answer_set %} {% include 'people/person/includes/answer_set.html' %}
<dl>
</dl>
<table class="table table-borderless">
<thead>
<tr>
<th>Question</th>
<th>Answer</th>
</tr>
</thead>
<tbody>
{% if answer_set.nationality %}
<tr><td>Nationality</td><td>{{ answer_set.nationality.name }}</td>
{% endif %}
{% if answer_set.country_of_residence %}
<tr><td>Country of Residence</td><td>{{ answer_set.country_of_residence.name }}</td></tr>
{% endif %}
{% if answer_set.organisation %}
<tr><td>Organisation</td><td>{{ answer_set.organisation }}</td></tr>
{% endif %}
{% if answer_set.organisation_started_date %}
<tr><td>Organisation Started Date</td><td>{{ answer_set.organisation_started_date }}</td></tr>
{% endif %}
{% if answer_set.job_title %}
<tr><td>Job Title</td><td>{{ answer_set.job_title }}</td></tr>
{% endif %}
{% if answer_set.disciplines %}
<tr><td>Discipline(s)</td><td>{{ answer_set.disciplines }}</td></tr>
{% endif %}
{% if answer_set.themes.exists %}
<tr>
<td>Project Themes</td>
<td>
{% for theme in answer_set.themes.all %}
{{ theme }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</td>
</tr>
{% endif %}
{% for answer in answer_set.question_answers.all %}
<tr>
<td>{{ answer.question }}</td>
<td>{{ answer }}</td>
</tr>
{% empty %}
<tr>
<td>No records</td>
</tr>
{% endfor %}
</tbody>
</table>
<p>Last updated: {{ answer_set.timestamp }}</p>
{% endwith %}
<a class="btn btn-success" <a class="btn btn-success"
href="{% url 'people:person.update' pk=person.pk %}">Update</a> href="{% url 'people:person.update' pk=person.pk %}">Update</a>
@@ -99,6 +46,10 @@
<hr> <hr>
<div id="map" style="height: 800px; width: 100%"></div>
<hr>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<h2>Relationships As Source</h2> <h2>Relationships As Source</h2>
@@ -207,3 +158,6 @@
</tbody> </tbody>
</table> </table>
{% endblock %} {% endblock %}
{% block extra_script %}
{% endblock %}

View File

@@ -0,0 +1,59 @@
<table class="table table-borderless">
<thead>
<tr>
<th>Question</th>
<th>Answer</th>
</tr>
</thead>
<tbody>
{% if answer_set.nationality %}
<tr><td>Nationality</td><td>{{ answer_set.nationality.name }}</td>
{% endif %}
{% if answer_set.country_of_residence %}
<tr><td>Country of Residence</td><td>{{ answer_set.country_of_residence.name }}</td></tr>
{% endif %}
{% if answer_set.organisation %}
<tr><td>Organisation</td><td>{{ answer_set.organisation }}</td></tr>
{% endif %}
{% if answer_set.organisation_started_date %}
<tr><td>Organisation Started Date</td><td>{{ answer_set.organisation_started_date }}</td></tr>
{% endif %}
{% if answer_set.job_title %}
<tr><td>Job Title</td><td>{{ answer_set.job_title }}</td></tr>
{% endif %}
{% if answer_set.disciplines %}
<tr><td>Discipline(s)</td><td>{{ answer_set.disciplines }}</td></tr>
{% endif %}
{% if answer_set.themes.exists %}
<tr>
<td>Project Themes</td>
<td>
{% for theme in answer_set.themes.all %}
{{ theme }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</td>
</tr>
{% endif %}
{% for answer in answer_set.question_answers.all %}
<tr>
<td>{{ answer.question }}</td>
<td>{{ answer }}</td>
</tr>
{% empty %}
<tr>
<td>No records</td>
</tr>
{% endfor %}
</tbody>
</table>
<p>Last updated: {{ answer_set.timestamp }}</p>

View File

@@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% block extra_head %}
{{ map_markers|json_script:'map-markers' }}
{% load staticfiles %}
<script src="{% static 'js/map.js' %}"></script>
<script async defer
src="https://maps.googleapis.com/maps/api/js?key={{ settings.GOOGLE_MAPS_API_KEY }}&callback=initMap"
type="text/javascript"></script>
{% endblock %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="{% url 'people:person.list' %}">People</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Map</li>
</ol>
</nav>
<h1>Map</h1>
<div id="map" style="height: 800px; width: 100%"></div>
{% endblock %}

View File

@@ -1,5 +1,29 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block extra_head %}
{% load staticfiles %}
<script type="application/javascript">
const data = [
{
name: '{{ person.name }}',
lat: '{{ answer_set.latitude }}',
lng: '{{ answer_set.longitude }}'
},
]
const settings = {
zoom: 2,
centre_lat: '{{ answer_set.latitude }}',
centre_lng: '{{ answer_set.longitude }}',
}
</script>
<script src="{% static 'js/location_picker.js' %}"></script>
<script async defer src="https://maps.googleapis.com/maps/api/js?key={{ settings.GOOGLE_MAPS_API_KEY }}&callback=initMap&libraries=places"
type="text/javascript"></script>
{% endblock %}
{% block content %} {% block content %}
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
<ol class="breadcrumb"> <ol class="breadcrumb">
@@ -22,11 +46,21 @@
{% csrf_token %} {% csrf_token %}
{% load bootstrap4 %} {% load bootstrap4 %}
{% bootstrap_form form %} {% bootstrap_form form exclude='latitude,longitude' %}
{% bootstrap_field form.latitude %}
{% bootstrap_field form.longitude %}
{% buttons %} {% buttons %}
<button class="btn btn-success" type="submit">Submit</button> <button class="btn btn-success" type="submit">Submit</button>
{% endbuttons %} {% endbuttons %}
</form> </form>
<hr>
<input id="location-search" class="controls" type="text" placeholder="Location Search"/>
<div id="map" style="height: 800px; width: 100%"></div>
<hr>
{% endblock %} {% endblock %}

View File

@@ -38,6 +38,10 @@ urlpatterns = [
views.relationship.RelationshipUpdateView.as_view(), views.relationship.RelationshipUpdateView.as_view(),
name='relationship.update'), name='relationship.update'),
path('map',
views.person.PersonMapView.as_view(),
name='person.map'),
path('network', path('network',
views.network.NetworkView.as_view(), views.network.NetworkView.as_view(),
name='network'), name='network'),

View File

@@ -2,7 +2,10 @@
Views for displaying or manipulating instances of :class:`Person`. Views for displaying or manipulating instances of :class:`Person`.
""" """
import typing
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.views.generic import CreateView, DetailView, ListView, UpdateView from django.views.generic import CreateView, DetailView, ListView, UpdateView
@@ -54,6 +57,15 @@ class ProfileView(permissions.UserIsLinkedPersonMixin, DetailView):
# pk was not provided in URL # pk was not provided in URL
return self.request.user.person 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): class PersonUpdateView(permissions.UserIsLinkedPersonMixin, UpdateView):
"""View for updating a :class:`Person` record.""" """View for updating a :class:`Person` record."""
@@ -93,3 +105,38 @@ class PersonUpdateView(permissions.UserIsLinkedPersonMixin, UpdateView):
answer_set.save() answer_set.save()
return response return response
def get_map_data(person: models.Person) -> typing.Dict[str, typing.Any]:
answer_set = person.current_answers
try:
latitude = answer_set.latitude or None
longitude = answer_set.longitude or None
except AttributeError:
latitude = None
longitude = None
return {
'name': person.name,
'lat': latitude,
'lng': longitude,
'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

4
roles/defaults/main.yml Normal file
View File

@@ -0,0 +1,4 @@
---
db_name: 'breccia'
db_user: 'breccia'
db_pass: 'breccia'

View File

@@ -14,6 +14,8 @@ venv_dir: '{{ project_dir }}/venv'
web_user: nginx web_user: nginx
web_group: nginx web_group: nginx
db_name: '{{ project_name }}' db_name: '{{ project_name }}'
db_user: 'breccia'
db_pass: 'breccia'
display_short_name: 'BRECcIA' display_short_name: 'BRECcIA'
display_long_name: 'BRECcIA Mapper' display_long_name: 'BRECcIA Mapper'

View File

@@ -23,3 +23,7 @@ DEFAULT_FROM_EMAIL={{ default_from_email }}
{% if email_port is defined %} {% if email_port is defined %}
EMAIL_PORT={{ email_port }} EMAIL_PORT={{ email_port }}
{% endif %} {% endif %}
{% if google_maps_api_key is defined %}
GOOGLE_MAPS_API_KEY={{ google_maps_api_key }}
{% endif %}