Commit cb51596d authored by Tanner Prestegard's avatar Tanner Prestegard Committed by GraceDB

Overhaul of contacts

Separate phone and email contacts, add verification of contact
information, improve views and forms.
parent d1de4b3b
......@@ -57,6 +57,7 @@ TWIML_BIN = {
'new': 'EH761b6a35102737e3d21830a484a98a08',
'label_added': 'EHb596a53b9c92a41950ce1a47335fd834',
'test': 'EH6c0a168b0c6b011047afa1caeb49b241',
'verify': 'EHfaea274d4d87f6ff152ac39fea3a87d4',
}
# Use timezone-aware datetimes internally
......@@ -443,6 +444,8 @@ GUARDIAN_RENDER_403 = True
# See http://django-guardian.readthedocs.io/en/latest/userguide/custom-user-model.html
GUARDIAN_MONKEY_PATCH = False
# Lifetime of verification codes for contacts
VERIFICATION_CODE_LIFETIME = timedelta(hours=1)
# Basic auth passwords for LVEM scripted access expire after 365 days.
PASSWORD_EXPIRATION_TIME = timedelta(days=365)
......
from collections import defaultdict
import logging
from pyparsing import ParseException
from django import forms
from django.utils.safestring import mark_safe
from django.core.exceptions import NON_FIELD_ERRORS
from django.forms.utils import ErrorList
from django.utils import timezone
from django.utils.encoding import force_text
from django.utils.html import conditional_escape
from django.forms.utils import ErrorList
from django.core.exceptions import NON_FIELD_ERRORS
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from .models import Notification, Contact
from core.forms import MultipleForm
from search.query.labels import parseLabelQuery
from .models import Notification, Contact
# Set up logger
logger = logging.getLogger(__name__)
from pyparsing import ParseException
from collections import defaultdict
import logging
log = logging.getLogger(__name__)
def notificationFormFactory(postdata=None, user=None):
class TF(forms.ModelForm):
......@@ -117,58 +123,62 @@ def process_errors(err):
return "\n".join(out_errs)
class ContactForm(forms.ModelForm):
# Adjust labels.
desc = forms.CharField(label='Description', required=True)
call_phone = forms.BooleanField(label='Call', initial=False,
required=False)
text_phone = forms.BooleanField(label='Text', initial=False,
required=False)
class PhoneContactForm(forms.ModelForm, MultipleForm):
key = 'phone'
class Meta:
model = Contact
fields = ['desc','email','phone','call_phone','text_phone']
help_texts = {
'phone': 'Prototype service: may not be available in the future.'
}
# Custom generator for table format.
def as_table(self):
row_head = '<tr><th><label for="id_{0}">{1}:</label></th>'
row_err = '<td>{2}'
row_input = '<input id="id_{0}" name="{0}" type="{3}" /></td></tr>'
row_str = row_head + row_err + row_input
table_data = {}
# Build table -----------------------------
# Description/email
for field in ['desc','email']:
table_data[field] = row_str.format(field,self[field].label,
process_errors(self[field].errors),"text")
# Phone number
table_data['phone'] = (row_head + row_err +
'<input id="id_{0}" name="{0}" type="text" />').format(
'phone',self['phone'].label,process_errors(self['phone'].errors))
# Add call/text checkboxes.
table_data['phone'] += '<br />'
table_data['phone'] += ('{1}?<input id="id_{0}" name="{0}"'
'type="checkbox" />&nbsp;&nbsp;'.format('call_phone',
self['call_phone'].label))
table_data['phone'] += ('{1}?<input id="id_{0}" name="{0}"'
'type="checkbox" />'.format('text_phone',self['text_phone'].label))
# Add phone help text.
table_data['phone'] += ('<br /><span class="helptext">{0}</span>'
'</td></tr>\n'.format(self['phone'].help_text))
# Compile table_data dict into a list.
td = [table_data[k] for k in ['desc','email','phone']]
# Add non-field errors to beginning.
nfe = self.non_field_errors()
if nfe:
td.insert(0,'<tr><td colspan="2">' \
+ process_errors(nfe) + '</td></tr>')
return mark_safe('\n'.join(td))
fields = ['description', 'phone', 'phone_method', 'key_field']
def __init__(self, *args, **kwargs):
super(PhoneContactForm, self).__init__(*args, **kwargs)
self.fields['phone_method'].required = True
class EmailContactForm(forms.ModelForm, MultipleForm):
key = 'email'
class Meta:
model = Contact
fields = ['description', 'email', 'key_field']
class VerifyContactForm(forms.ModelForm):
code = forms.CharField(required=True, label='Verification code')
class Meta:
model = Contact
fields = ['code']
def clean(self):
data = super(VerifyContactForm, self).clean()
# Already verified
if self.instance.verified:
raise forms.ValidationError(_('This contact is already verified.'))
if (self.instance.verification_code is None):
raise forms.ValidationError(_('No verification code has been '
'generated. Please request one before attempted to verify '
'this contact.'))
if (timezone.now() > self.instance.verification_expiration):
raise forms.ValidationError(_('This verification code has '
'expired. Please request a new one.'))
return data
def clean_code(self):
code = self.cleaned_data['code']
# Convert to an int
try:
code = int(code)
except:
raise forms.ValidationError(_('Incorrect verification code.'))
if (code != self.instance.verification_code):
raise forms.ValidationError(_('Incorrect verification code.'))
return code
# -*- coding: utf-8 -*-
# Generated by Django 1.11.18 on 2019-01-21 15:57
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('alerts', '0001_initial'),
]
operations = [
migrations.RenameField(
model_name='contact',
old_name='desc',
new_name='description',
),
migrations.RemoveField(
model_name='contact',
name='call_phone',
),
migrations.RemoveField(
model_name='contact',
name='text_phone',
),
migrations.AddField(
model_name='contact',
name='phone_method',
field=models.CharField(blank=True, choices=[(b'C', b'Call'), (b'T', b'Text'), (b'B', b'Call and text')], default=None, max_length=1, null=True),
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.11.18 on 2019-01-21 16:57
from __future__ import unicode_literals
import alerts.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('alerts', '0002_auto_20190121_1557'),
]
operations = [
migrations.AlterField(
model_name='contact',
name='email',
field=models.EmailField(blank=True, max_length=254, null=True),
),
migrations.AlterField(
model_name='contact',
name='phone',
field=alerts.fields.PhoneNumberField(blank=True, max_length=255, null=True),
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.11.18 on 2019-01-21 17:00
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('alerts', '0003_auto_20190121_1657'),
]
operations = [
migrations.AddField(
model_name='contact',
name='verified',
field=models.BooleanField(default=False),
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.11.18 on 2019-01-21 17:03
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('alerts', '0004_contact_verified'),
]
operations = [
migrations.AlterField(
model_name='contact',
name='verified',
field=models.BooleanField(default=False, editable=False),
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.11.18 on 2019-01-23 21:19
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('alerts', '0005_auto_20190121_1703'),
]
operations = [
migrations.AddField(
model_name='contact',
name='verification_code',
field=models.IntegerField(editable=False, null=True),
),
migrations.AddField(
model_name='contact',
name='verification_requested',
field=models.DateTimeField(editable=False, null=True),
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.11.18 on 2019-01-23 21:20
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('alerts', '0006_auto_20190123_2119'),
]
operations = [
migrations.RenameField(
model_name='contact',
old_name='verification_requested',
new_name='verification_expires',
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.11.18 on 2019-01-23 21:20
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('alerts', '0007_auto_20190123_2120'),
]
operations = [
migrations.RenameField(
model_name='contact',
old_name='verification_expires',
new_name='verification_expiration',
),
]
from collections import defaultdict
import logging
import random
import textwrap
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from django.core.exceptions import ValidationError, NON_FIELD_ERRORS
from django.core.mail import EmailMessage
from django.db import models
from django.utils import timezone
from django.utils.http import urlencode
from django_twilio.client import twilio_client
from core.models import CleanSaveModel
from events.models import Label, Pipeline
from .fields import PhoneNumberField
from .phone import get_twilio_from
# Set up logger
logger = logging.getLogger(__name__)
# Set up user model
UserModel = get_user_model()
class Contact(models.Model):
class Contact(CleanSaveModel):
# Phone contact methods
CONTACT_PHONE_CALL = 'C'
CONTACT_PHONE_TEXT = 'T'
CONTACT_PHONE_BOTH = 'B'
CONTACT_PHONE_METHODS = (
(CONTACT_PHONE_CALL, 'Call'),
(CONTACT_PHONE_TEXT, 'Text'),
(CONTACT_PHONE_BOTH, 'Call and text'),
)
# Number of digits in verification codes
CODE_DIGITS = 6
# Fields
user = models.ForeignKey(UserModel, null=False)
desc = models.CharField(max_length=20)
email = models.EmailField(blank=True)
phone = PhoneNumberField(blank=True, max_length=255)
# These fields specify whether alert should be a phone
# call or text (or both).
call_phone = models.BooleanField(default=False)
text_phone = models.BooleanField(default=False)
description = models.CharField(max_length=20, blank=False, null=False)
email = models.EmailField(blank=True, null=True)
phone = PhoneNumberField(blank=True, max_length=255, null=True)
phone_method = models.CharField(max_length=1, null=True, blank=True,
choices=CONTACT_PHONE_METHODS, default=None)
verified = models.BooleanField(default=False, editable=False)
verification_code = models.IntegerField(null=True, editable=False)
verification_expiration = models.DateTimeField(null=True, editable=False)
def __unicode__(self):
return u"{0}: {1}".format(self.user.username, self.desc)
return u"{0}: {1}".format(self.user.username, self.description)
def clean(self):
# Mostly used for preventing creation of bad Contact
......@@ -32,33 +61,87 @@ class Contact(models.Model):
err_dict = defaultdict(list)
# If a phone number is given, require either call or text to be True.
if self.phone and not (self.call_phone or self.text_phone):
err_msg = 'Choose "call" or "text" (or both) for phone alerts.'
err_dict['phone'].append(err_msg)
if (self.phone is not None and self.phone_method is None):
err_msg = 'Choose a phone contact method.'
err_dict['phone_method'].append(err_msg)
if not self.phone and (self.call_phone or self.text_phone):
if (self.phone is None and self.phone_method is not None):
err_msg = '"Call" and "text" should be False for non-phone alerts.'
err_dict['phone'].append(err_msg)
# If no e-mail or phone given, raise error.
if not (self.email or self.phone):
err_msg = 'At least one contact method (email, phone) is required.'
# Only one contact method is allowed
if (self.email is not None and self.phone is not None):
err_msg = \
'Only one contact method (email or phone) can be selected.'
err_dict[NON_FIELD_ERRORS].append(err_msg)
# If no e-mail or phone given, raise error.
# We have to skip this due to really annoying behavior with forms.. :(
#if not (self.email or self.phone):
# err_msg = \
# 'One contact method (email or phone) is required.'
# err_dict[NON_FIELD_ERRORS].append(err_msg)
if err_dict:
raise ValidationError(err_dict)
# Override save method by requiring fully_cleaned objects.
def save(self, *args, **kwargs):
self.full_clean()
super(Contact, self).save()
def generate_verification_code(self):
self.verification_code = random.randint(10**(self.CODE_DIGITS-1),
(10**self.CODE_DIGITS)-1)
self.verification_expiration = timezone.now() + \
settings.VERIFICATION_CODE_LIFETIME
self.save(update_fields=['verification_code',
'verification_expiration'])
def send_verification_code(self):
# Message for texts and emails
msg = ('Verification code for contact "{desc}" on {host}: {code}. '
'If you did not request a verification code or do not know what '
'this is, please disregard.').format(desc=self.description,
host=settings.LIGO_FQDN, code=self.verification_code)
if self.email:
subject = 'Verification code for contact "{desc}" on {host}' \
.format(desc=self.description, host=settings.LIGO_FQDN)
email = EmailMessage(subject, msg,
from_email=settings.ALERT_EMAIL_FROM, to=[self.email])
email.send()
elif self.phone:
from_ = get_twilio_from()
if (self.phone_method == self.CONTACT_PHONE_CALL):
# If phone method is only call, send a call
# Convert code to a string with spaces between the numbers
# so it's pronounced properly by text-to-voice
code = " ".join(str(self.verification_code))
urlparams = urlencode({'code': code})
twiml_url = '{base}{twiml_bin}?{params}'.format(
base=settings.TWIML_BASE_URL,
twiml_bin=settings.TWIML_BIN['verify'],
params=urlparams)
twilio_client.calls.create(to=self.phone, from_=from_,
url=twiml_url, method='GET')
else:
# If method is text or both, send a text
twilio_client.messages.create(to=self.phone, from_=from_,
body=msg)
def verify(self):
self.verified = True
self.save(update_fields=['verified'])
def print_info(self):
"""Prints information about Contact object; useful for debugging."""
print('Contact "{0}" (user {1}):'.format(self.desc,self.user.username))
print('\tE-mail: {0}'.format(self.email))
print('\tPhone: {0} (call={1}, text={2})'.format(self.phone,
self.call_phone, self.text_phone))
info_str = textwrap.dedent("""\
Contact "{description}" (user {username})
E-mail: {email}
Phone: {phone} (method={method})
Verified: {verified}
""").format(description=self.description, username=self.user.username,
email=self.email, phone=self.phone, method=self.phone_method,
verified=self.verified)
print(info_str)
class Notification(models.Model):
......@@ -89,5 +172,5 @@ class Notification(models.Model):
"|".join([a.name for a in self.pipelines.all()]) or "any pipeline",
label_disp,
thresh,
", ".join([x.desc for x in self.contacts.all()])
", ".join([x.description for x in self.contacts.all()])
)
# Changed for Django 1.11
from django.conf.urls import url
from . import views
app_name = 'alerts'
urlpatterns = [
# Base /options/ URL
url(r'^$', views.index, name="userprofile-home"),
# /options/contact/
url(r'^contact/create$', views.createContact,
name="userprofile-create-contact"),
url(r'^contact/delete/(?P<id>[\d]+)$', views.deleteContact,
name="userprofile-delete-contact"),
url(r'^contact/test/(?P<id>[\d]+)$', views.testContact,
name="userprofile-test-contact"),
#url(r'^contact/edit/(?P<id>[\d]+)$', views.editContact,
# name="userprofile-edit-contact"),
# /options/notification/
url(r'^notification/create$', views.create, name="userprofile-create"),
url(r'^$', views.index, name="index"),
# Contacts
url(r'^contact/create/', views.CreateContactView.as_view(),
name='create-contact'),
url(r'^contact/(?P<pk>\d+)/edit/$', views.EditContactView.as_view(),
name="edit-contact"),
url(r'^contact/(?P<pk>\d+)/delete/$', views.DeleteContactView.as_view(),
name="delete-contact"),
url(r'^contact/(?P<pk>\d+)/test/$', views.TestContactView.as_view(),
name="test-contact"),
url(r'^contact/(?P<pk>\d+)/request-code/$',
views.RequestVerificationCodeView.as_view(),
name="request-verification-code"),
url(r'^contact/(?P<pk>\d+)/verify/$', views.VerifyContactView.as_view(),
name="verify-contact"),
# Notifications
url(r'^notification/create/$', views.create, name="create-notification"),
url(r'^notification/delete/(?P<id>[\d]+)$', views.delete,
name="userprofile-delete"),
#url(r'^notification/edit/(?P<id>[\d]+)$', views.edit, name="userprofile-edit"),
name="delete-notification"),
#url(r'^notification/edit/(?P<id>[\d]+)$', views.edit,
# name="edit-notification"),
# /options/manage_password
url(r'^manage_password$', views.managePassword,
name="userprofile-manage-password"),
# Manage password
url(r'^manage_password/$', views.managePassword,
name="manage-password"),
]
import phonenumbers
from django.core.exceptions import ValidationError
def validate_phone(value):
# Try to parse phone number
......
This diff is collapsed.
This diff is collapsed.
{% extends "base.html" %}
{% load static %}
{% block headcontents %}
{{ block.super }}
<link rel="stylesheet" href="{% static "jquery-ui/themes/base/jquery-ui.min.css" %}" />
{% endblock %}
{% block jscript %}
<script src="{% static "jquery/dist/jquery.min.js" %}"></script>
<script src="{% static "jquery-ui/jquery-ui.min.js" %}"></script>
<script type="text/javascript">
$(document).ready(function() {
$("#tabs").toggle();
$("#tabs").tabs({active: {{ idx }}});
});
</script>
{% endblock %}
{% block title %}Options | Create Contact{% endblock %}
{% block heading %}Create Contact{% endblock %}
{% block pageid %}userprofile{% endblock %}
{% block content %}
<br />
<div style="padding: 10px;">
<h3>Instructions</h3>
<ul>
<li>Choose an email or phone alert.</li>
<li>A description is required.</li>
<li>For phone alerts, choose call, text, or both.</li>
<li><b>IMPORTANT: after your contact is created, it must be verified before you can receive alerts. You can verify contacts on the previous page where a list of your available contacts is shown.</b></li>
</ul>
</div>
<div id="tabs" style="min-width: 300px; max-width: 400px; display: none;">
<ul>
{% for form in forms %}
<li><a href="#tab-{{ forloop.counter }}">{{ form.key|title }} alert</a></li>
{% endfor %}
</ul>
{% for form in forms %}
<div id="tab-{{ forloop.counter }}">
<form method="POST" class="contactform">
<table>
{{ form.as_table }}
</table>
<input type="submit" value="Submit" />
</form>
</div>
{% endfor %}
</div>
{% endblock %}
{% extends "base.html" %}
{% block title %}Options | Create Contact{% endblock %}
{% block heading %}Create Contact{% endblock %}
{% block pageid %}userprofile{% endblock %}
{% block title %}Options | Edit Contact{% endblock %}
{% block heading %}Edit contact &quot;{{ object.description }}&quot;{% endblock %}
{% block content %}
<br />
<div style="padding: 10px;">
<h3>Instructions</h3>
<ul>
<li>A description of your contact is required.</li>
<li>Choose a contact method (e-mail, phone, or both).</li>
<li>For phone alerts, choose call, text, or both.</li>
</ul>
</div>
<form method="POST">
<table>
......