Merge pull request #46 from Southampton-RSG/dev

Dev
This commit is contained in:
James Graham
2021-01-18 14:58:33 +00:00
committed by GitHub
24 changed files with 811 additions and 96 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')
@@ -374,17 +379,28 @@ else:
default=(EMAIL_PORT == 465), default=(EMAIL_PORT == 465),
cast=bool) cast=bool)
# Upstream API keys
GOOGLE_MAPS_API_KEY = config('GOOGLE_MAPS_API_KEY', default=None)
# Import customisation app settings if present # Import customisation app settings if present
try: try:
from custom.settings import ( from custom.settings import (
CUSTOMISATION_NAME, CUSTOMISATION_NAME,
TEMPLATE_NAME_INDEX, TEMPLATE_NAME_INDEX,
TEMPLATE_WELCOME_EMAIL_NAME TEMPLATE_WELCOME_EMAIL_NAME,
) CONSTANCE_CONFIG as constance_config_custom,
logger.info("Loaded customisation app: %s", CUSTOMISATION_NAME) 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') INSTALLED_APPS.append('custom')
logger.info("Loaded customisation app: %s", CUSTOMISATION_NAME)
except ImportError as exc: except ImportError as exc:
logger.info("No customisation app loaded: %s", exc) logger.info("No customisation app loaded: %s", exc)

View File

@@ -66,6 +66,10 @@
<a href="{% url 'people:person.list' %}" class="nav-link">People</a> <a href="{% url 'people:person.list' %}" class="nav-link">People</a>
</li> </li>
<li class="nav-item">
<a href="{% url 'people:organisation.list' %}" class="nav-link">Organisations</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a href="{% url 'activities:activity-series.list' %}" class="nav-link">Activity Series</a> <a href="{% url 'activities:activity-series.list' %}" class="nav-link">Activity Series</a>
</li> </li>
@@ -74,6 +78,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

@@ -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 import typing
@@ -24,6 +22,13 @@ def get_date_year_range() -> typing.Iterable[int]:
return range(this_year, this_year - num_years_display, -1) 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): class PersonForm(forms.ModelForm):
"""Form for creating / updating an instance of :class:`Person`.""" """Form for creating / updating an instance of :class:`Person`."""
class Meta: class Meta:
@@ -48,6 +53,8 @@ class DynamicAnswerSetBase(forms.Form):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
initial = kwargs.get('initial', {})
for question in self.question_model.objects.all(): for question in self.question_model.objects.all():
field_class = self.field_class field_class = self.field_class
field_widget = self.field_widget field_widget = self.field_widget
@@ -56,11 +63,14 @@ class DynamicAnswerSetBase(forms.Form):
field_class = forms.ModelMultipleChoiceField field_class = forms.ModelMultipleChoiceField
field_widget = Select2MultipleWidget field_widget = Select2MultipleWidget
field_name = f'question_{question.pk}'
field = field_class(label=question, field = field_class(label=question,
queryset=question.answers, queryset=question.answers,
widget=field_widget, widget=field_widget,
required=self.field_required) required=self.field_required,
self.fields['question_{}'.format(question.pk)] = field initial=initial.get(field_name, None))
self.fields[field_name] = field
class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase): class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
@@ -78,11 +88,15 @@ class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
'job_title', 'job_title',
'disciplines', 'disciplines',
'themes', 'themes',
'latitude',
'longitude',
] ]
widgets = { widgets = {
'nationality': Select2Widget(), 'nationality': Select2Widget(),
'country_of_residence': Select2Widget(), 'country_of_residence': Select2Widget(),
'themes': Select2MultipleWidget(), 'themes': Select2MultipleWidget(),
'latitude': forms.HiddenInput,
'longitude': forms.HiddenInput,
} }
help_texts = { help_texts = {
'organisation_started_date': 'organisation_started_date':
@@ -93,7 +107,10 @@ class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
def save(self, commit=True) -> models.PersonAnswerSet: def save(self, commit=True) -> models.PersonAnswerSet:
# Save Relationship model # 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: if commit:
# Save answers to relationship questions # Save answers to relationship questions

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

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

View File

@@ -64,14 +64,21 @@ class User(AbstractUser):
class Organisation(models.Model): 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) 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: def __str__(self) -> str:
return self.name return self.name
def get_absolute_url(self):
return reverse('people:organisation.detail', kwargs={'pk': self.pk})
class Theme(models.Model): class Theme(models.Model):
""" """
@@ -177,5 +184,56 @@ 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 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): 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,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)
}

103
people/static/js/map.js Normal file
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 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: "<div id='content'>" +
"<h3><a href=" + marker_data.url + ">" + marker_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 (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)
}
}

View File

@@ -0,0 +1,29 @@
{% extends 'base.html' %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="{% url 'people:organisation.list' %}">Organisations</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Create</li>
</ol>
</nav>
<h1>New Organisation</h1>
<hr>
<form class="form"
method="POST">
{% csrf_token %}
{% load bootstrap4 %}
{% bootstrap_form form %}
{% buttons %}
<button class="btn btn-success" type="submit">Submit</button>
{% endbuttons %}
</form>
{% endblock %}

View File

@@ -0,0 +1,36 @@
{% 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:organisation.list' %}">Organisations</a>
</li>
<li class="breadcrumb-item active" aria-current="page">{{ object }}</li>
</ol>
</nav>
<h1>{{ organisation.name }}</h1>
<hr>
<a class="btn btn-success"
href="{% url 'people:organisation.update' pk=organisation.pk %}">Update</a>
<hr>
<div id="map" style="height: 800px; width: 100%"></div>
<hr>
{% endblock %}

View File

@@ -0,0 +1,42 @@
{% extends 'base.html' %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item active" aria-current="page">Organisation</li>
</ol>
</nav>
<h1>People</h1>
<hr>
<a class="btn btn-success"
href="{% url 'people:organisation.create' %}">New Organisation</a>
<table class="table table-borderless">
<thead>
<tr>
<th>Name</th>
</tr>
</thead>
<tbody>
{% for organisation in organisation_list.all %}
<tr>
<td>{{ organisation }}</td>
<td>
<a class="btn btn-sm btn-info"
href="{% url 'people:organisation.detail' pk=organisation.pk %}">Details</a>
</td>
</tr>
{% empty %}
<tr>
<td>No records</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -0,0 +1,53 @@
{% extends 'base.html' %}
{% block extra_head %}
{% load staticfiles %}
{{ map_markers|json_script:'map-markers' }}
<script src="{% static 'js/map.js' %}"></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=initPicker&libraries=places"
type="text/javascript"></script>
{% endblock %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="{% url 'people:organisation.list' %}">Organisations</a>
</li>
<li class="breadcrumb-item">
<a href="{% url 'people:organisation.detail' pk=organisation.pk %}">{{ organisation }}</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Update</li>
</ol>
</nav>
<h1>{{ organisation.name }}</h1>
<hr>
<form class="form"
method="POST">
{% csrf_token %}
{% load bootstrap4 %}
{% bootstrap_form form exclude='latitude,longitude' %}
{% bootstrap_field form.latitude %}
{% bootstrap_field form.longitude %}
{% buttons %}
<button class="btn btn-success" type="submit">Submit</button>
{% endbuttons %}
</form>
<hr>
<input id="location-search" class="controls" type="text" placeholder="Location Search"/>
<div id="map" style="height: 800px; width: 100%"></div>
<hr>
{% endblock %}

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,16 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block extra_head %}
{% load staticfiles %}
{{ map_markers|json_script:'map-markers' }}
<script src="{% static 'js/map.js' %}"></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=initPicker&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 +33,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

@@ -6,6 +6,22 @@ from . import views
app_name = 'people' app_name = 'people'
urlpatterns = [ urlpatterns = [
path('organisations/create',
views.organisation.OrganisationCreateView.as_view(),
name='organisation.create'),
path('organisations',
views.organisation.OrganisationListView.as_view(),
name='organisation.list'),
path('organisations/<int:pk>',
views.organisation.OrganisationDetailView.as_view(),
name='organisation.detail'),
path('organisations/<int:pk>/update',
views.organisation.OrganisationUpdateView.as_view(),
name='organisation.update'),
path('profile/', path('profile/',
views.person.ProfileView.as_view(), views.person.ProfileView.as_view(),
name='person.profile'), name='person.profile'),
@@ -38,6 +54,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

@@ -4,6 +4,7 @@ Views for displaying or manipulating models within the `people` app.
from . import ( from . import (
network, network,
organisation,
person, person,
relationship relationship
) )
@@ -11,6 +12,7 @@ from . import (
__all__ = [ __all__ = [
'network', 'network',
'organisation',
'person', 'person',
'relationship', 'relationship',
] ]

View File

@@ -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

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,42 +57,101 @@ 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."""
model = models.PersonAnswerSet model = models.Person
context_object_name = 'person'
template_name = 'people/person/update.html' template_name = 'people/person/update.html'
form_class = forms.PersonAnswerSetForm form_class = forms.PersonAnswerSetForm
def get_test_person(self) -> models.Person: def get_context_data(self,
"""Get the person instance which should be used for access control checks.""" **kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
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):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['person'] = self.person context['map_markers'] = [get_map_data(self.object)]
return context 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): def form_valid(self, form):
"""Mark any previous answer sets as replaced.""" """Mark any previous answer sets as replaced."""
response = super().form_valid(form) response = super().form_valid(form)
now_date = timezone.now().date() 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 # 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.replaced_timestamp = now_date
answer_set.save() answer_set.save()
return response 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

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