diff --git a/config/settings/base.py b/config/settings/base.py index 6a32f0456bfd6f9768e47e51380fd1abcc323f32..74e02545c56d8b048c009cc7b12fd19362b305ae 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -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) diff --git a/gracedb/alerts/forms.py b/gracedb/alerts/forms.py index dbb3f42d29a57aef759ed6662954dfd96f25620f..025be65de6c159de64deb295f9e5beca38e7ee27 100644 --- a/gracedb/alerts/forms.py +++ b/gracedb/alerts/forms.py @@ -1,17 +1,23 @@ +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" /> '.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 diff --git a/gracedb/alerts/migrations/0002_auto_20190121_1557.py b/gracedb/alerts/migrations/0002_auto_20190121_1557.py new file mode 100644 index 0000000000000000000000000000000000000000..b0603f3e92554580b47a88e203538cf653bcbd36 --- /dev/null +++ b/gracedb/alerts/migrations/0002_auto_20190121_1557.py @@ -0,0 +1,33 @@ +# -*- 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), + ), + ] diff --git a/gracedb/alerts/migrations/0003_auto_20190121_1657.py b/gracedb/alerts/migrations/0003_auto_20190121_1657.py new file mode 100644 index 0000000000000000000000000000000000000000..d7cd635d1dd637bd56314f1ca84341b1d24caa8d --- /dev/null +++ b/gracedb/alerts/migrations/0003_auto_20190121_1657.py @@ -0,0 +1,26 @@ +# -*- 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), + ), + ] diff --git a/gracedb/alerts/migrations/0004_contact_verified.py b/gracedb/alerts/migrations/0004_contact_verified.py new file mode 100644 index 0000000000000000000000000000000000000000..0610c38e2be823ffa904a12dae791d6582c7390f --- /dev/null +++ b/gracedb/alerts/migrations/0004_contact_verified.py @@ -0,0 +1,20 @@ +# -*- 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), + ), + ] diff --git a/gracedb/alerts/migrations/0005_auto_20190121_1703.py b/gracedb/alerts/migrations/0005_auto_20190121_1703.py new file mode 100644 index 0000000000000000000000000000000000000000..bcd398948fd7ae61b0006727eca288510362553e --- /dev/null +++ b/gracedb/alerts/migrations/0005_auto_20190121_1703.py @@ -0,0 +1,20 @@ +# -*- 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), + ), + ] diff --git a/gracedb/alerts/migrations/0006_auto_20190123_2119.py b/gracedb/alerts/migrations/0006_auto_20190123_2119.py new file mode 100644 index 0000000000000000000000000000000000000000..e56930778ecfaff2bcb89833f80bec243fc760b1 --- /dev/null +++ b/gracedb/alerts/migrations/0006_auto_20190123_2119.py @@ -0,0 +1,25 @@ +# -*- 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), + ), + ] diff --git a/gracedb/alerts/migrations/0007_auto_20190123_2120.py b/gracedb/alerts/migrations/0007_auto_20190123_2120.py new file mode 100644 index 0000000000000000000000000000000000000000..8d576f920c1bcd0358962eda2914d1f6b32a3e04 --- /dev/null +++ b/gracedb/alerts/migrations/0007_auto_20190123_2120.py @@ -0,0 +1,20 @@ +# -*- 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', + ), + ] diff --git a/gracedb/alerts/migrations/0008_auto_20190123_2120.py b/gracedb/alerts/migrations/0008_auto_20190123_2120.py new file mode 100644 index 0000000000000000000000000000000000000000..70dac577b51bd0ed5da2c5a52e52f613b92f01fb --- /dev/null +++ b/gracedb/alerts/migrations/0008_auto_20190123_2120.py @@ -0,0 +1,20 @@ +# -*- 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', + ), + ] diff --git a/gracedb/alerts/models.py b/gracedb/alerts/models.py index 801f12df993efb948c02eb22b4b12b616ae73b2d..83251c3d55d3e2f476e7fd5946165ffc9587681b 100644 --- a/gracedb/alerts/models.py +++ b/gracedb/alerts/models.py @@ -1,29 +1,58 @@ 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()]) ) diff --git a/gracedb/alerts/urls.py b/gracedb/alerts/urls.py index 3a13cdac66da71f5ba550cd8d2faf277b48d0991..a8b4077e50cc89fb2a903eee4cc33d736fe3bdac 100644 --- a/gracedb/alerts/urls.py +++ b/gracedb/alerts/urls.py @@ -1,30 +1,38 @@ - -# 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"), ] diff --git a/gracedb/alerts/validators.py b/gracedb/alerts/validators.py index 36d17bbfed9c89612bd8ccecded038740a28bb08..267c1ca2dbdaa9b07a22655566614bcb2e89ae08 100644 --- a/gracedb/alerts/validators.py +++ b/gracedb/alerts/validators.py @@ -1,5 +1,7 @@ import phonenumbers +from django.core.exceptions import ValidationError + def validate_phone(value): # Try to parse phone number diff --git a/gracedb/alerts/views.py b/gracedb/alerts/views.py index 88c6dd2e848639e1330f2aaf91098bd3016deb01..4fa2c98a8cbf9ecce2818281d6adf5140436f64f 100644 --- a/gracedb/alerts/views.py +++ b/gracedb/alerts/views.py @@ -1,37 +1,46 @@ +import logging -from django.contrib.auth.decorators import login_required -from django.http import (HttpResponse, HttpResponseRedirect, - HttpResponseNotFound, Http404, HttpResponseForbidden, - HttpResponseBadRequest) from django.conf import settings -from django.urls import reverse -from django.core.mail import EmailMessage +from django.contrib import messages +from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User +from django.core.mail import EmailMessage +from django.db.models import Q +from django.http import ( + HttpResponse, HttpResponseRedirect, HttpResponseNotFound, + Http404, HttpResponseForbidden, HttpResponseBadRequest +) from django.template import RequestContext from django.shortcuts import render +from django.urls import reverse, reverse_lazy from django.utils import timezone +from django.utils.decorators import method_decorator +from django.utils.http import urlencode from django.utils.safestring import mark_safe -from django.db.models import Q - -from django.contrib import messages +from django.views.generic.edit import FormView, DeleteView, UpdateView +from django.views.generic.base import ContextMixin +from django.views.generic.detail import SingleObjectMixin, DetailView from django_twilio.client import twilio_client -import socket -# Set up logger -import logging -log = logging.getLogger(__name__) -from .models import Notification, Contact -from .forms import ContactForm, notificationFormFactory -from alerts.phone import get_twilio_from -from events.permission_utils import internal_user_required, \ - lvem_user_required, is_external +from core.views import MultipleFormView +from events.permission_utils import lvem_user_required, is_external from events.models import Label +from ligoauth.decorators import internal_user_required from search.query.labels import labelQuery +from .forms import ( + PhoneContactForm, EmailContactForm, VerifyContactForm, + notificationFormFactory, +) +from .models import Notification, Contact +from .phone import get_twilio_from + + +# Set up logger +logger = log = logging.getLogger(__name__) + -# Let's let everybody onto the index view. -#@internal_user_required @login_required def index(request): notifications = Notification.objects.filter(user=request.user) @@ -40,6 +49,7 @@ def index(request): return render(request, 'profile/notifications.html', context=d) + @lvem_user_required def managePassword(request): # lvem_user_required only checks for LVEM group membership, @@ -76,6 +86,7 @@ def managePassword(request): return render(request, 'profile/manage_password.html', context=d) + @internal_user_required def create(request): """Create a notification (Notification) via the web interface""" @@ -129,7 +140,7 @@ def create(request): '{e}.').format(n=t.userlessDisplay(), e=e)) t.delete() - return HttpResponseRedirect(reverse(index)) + return HttpResponseRedirect(reverse('alerts:index')) else: form = notificationFormFactory(user=request.user) return render(request, 'profile/createNotification.html', @@ -151,108 +162,208 @@ def delete(request, id): messages.info(request,'Notification "{nname}" has been deleted.' \ .format(nname=t.userlessDisplay())) t.delete() - return HttpResponseRedirect(reverse(index)) + return HttpResponseRedirect(reverse('alerts:index')) -#-------------- -#-- Contacts -- -#-------------- -@internal_user_required -def createContact(request): +############################################################################### +# Contact views ############################################################### +############################################################################### +@method_decorator(internal_user_required, name='dispatch') +class CreateContactView(MultipleFormView): + """Create a contact""" + template_name = 'alerts/create_contact.html' + success_url = reverse_lazy('alerts:index') + form_classes = [PhoneContactForm, EmailContactForm] - # Handle form. - if request.method == "POST": - form = ContactForm(request.POST) - if form.is_valid(): - # Create the Contact - c = Contact( - user = request.user, - desc = form.cleaned_data['desc'], - email = form.cleaned_data['email'], - phone = form.cleaned_data['phone'], - call_phone = form.cleaned_data['call_phone'], - text_phone = form.cleaned_data['text_phone'], - ) - c.save() - messages.info(request, 'Created contact "{cname}".'.format( - cname=c.desc)) - return HttpResponseRedirect(reverse(index)) - else: - form = ContactForm() - return render(request, 'profile/createContact.html', - context={"form": form}) + def get_context_data(self, **kwargs): + kwargs['idx'] = 0 + if (self.request.method in ('POST', 'PUT')): + form_keys = [f.key for f in self.form_classes] + idx = form_keys.index(self.request.POST['key_field']) + kwargs['idx'] = idx + return kwargs -@internal_user_required -def testContact(request, id): - """Users can test their Contacts through the web interface""" - try: - c = Contact.objects.get(id=id) - except Contact.DoesNotExist: - raise Http404 - if request.user != c.user: - return HttpResponseForbidden("Can't test a Contact that isn't yours.") - else: - messages.info(request, 'Testing contact "{0}".'.format(c.desc)) - hostname = socket.gethostname() - if c.email: - # Send test e-mail - try: - subject = 'Test of contact "{0}" from {1}' \ - .format(c.desc, hostname) - msg = ('This is a test of contact "{0}" from ' - 'https://{1}.ligo.org.').format(c.desc, hostname) - email = EmailMessage(subject, msg, - from_email=settings.ALERT_EMAIL_FROM, to=[c.email]) - email.send() - log.debug('Sent test e-mail to {0}'.format(c.email)) - except Exception as e: - messages.error(request, ("Error sending test e-mail to {0}: " - "{1}.").format(c.email, e)) - log.exception('Error sending test e-mail to {0}'.format(c.email)) + def form_valid(self, form): + + # Remove key_field, add user, and save form + if form.cleaned_data.has_key('key_field'): + form.cleaned_data.pop('key_field') + form.instance.user = self.request.user + form.save() + + # Generate message and return + messages.info(self.request, 'Created contact "{cname}".'.format( + cname=form.instance.description)) + return super(CreateContactView, self).form_valid(form) + + email_form_valid = phone_form_valid = form_valid + + +@method_decorator(internal_user_required, name='dispatch') +class EditContactView(UpdateView): + """ + Edit a contact. Users shouldn't be able to edit the actual email address + or phone number since that would allow them to circumvent the verification + process. + """ + template_name = 'alerts/edit_contact.html' + # Have to provide form_class, but it will be dynamically selected below in + # get_form() + form_class = PhoneContactForm + success_url = reverse_lazy('alerts:index') + + def get_form_class(self): + if self.object.phone is not None: + return PhoneContactForm + else: + return EmailContactForm + return self.form_class + + def get_form(self, form_class=None): + form = super(EditContactView, self).get_form(form_class) + if isinstance(form, PhoneContactForm): + form.fields['phone'].disabled = True + elif isinstance(form, EmailContactForm): + form.fields['email'].disabled = True + return form + + def get_queryset(self): + return self.request.user.contact_set.all() + + +@method_decorator(internal_user_required, name='dispatch') +class DeleteContactView(DeleteView): + """Delete a contact""" + model = Contact + success_url = reverse_lazy('alerts:index') + + def get(self, request, *args, **kwargs): + # Override this so that we don't require a confirmation page + # for deletion + return self.delete(request, *args, **kwargs) + + def delete(self, request, *args, **kwargs): + response = super(DeleteContactView, self).delete(request, *args, + **kwargs) + messages.info(request, 'Contact "{cname}" has been deleted.'.format( + cname=self.object.description)) + return response + + def get_queryset(self): + # Queryset should only contain the user's contacts + return self.request.user.contact_set.all() + + +@method_decorator(internal_user_required, name='dispatch') +class TestContactView(DetailView): + """Test a contact (must be verified already)""" + # Send alerts to all contact methods + model = Contact + success_url = reverse_lazy('alerts:index') + + def get_queryset(self): + return self.request.user.contact_set.all() + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + + # Handle case where contact is not verified + if not self.object.verified: + msg = ('Contact "{desc}" must be verified before it can be ' + 'tested.').format(desc=self.object.description) + messages.info(request, msg) + return HttpResponseRedirect(self.success_url) + + # Send test notifications + msg = 'This is a test of contact "{desc}" from {host}.'.format( + desc=self.object.description, host=settings.LIGO_FQDN) + if self.object.email: + subject = 'Test of contact "{desc}" from {host}'.format( + desc=self.object.description, host=settings.LIGO_FQDN) + email = EmailMessage(subject, msg, settings.ALERT_EMAIL_FROM, + [self.object.email], []) + email.send() + if self.object.phone: + # Get "from" phone number. + from_ = get_twilio_from() + # Send test call + if (self.object.phone_method == Contact.CONTACT_PHONE_CALL or + self.object.phone_method == Contact.CONTACT_PHONE_BOTH): - if c.phone: - # Send test phone alert - try: - # Get "from" phone number. - from_ = get_twilio_from() # Construct URL of TwiML bin - if c.call_phone: - twiml_url = settings.TWIML_BASE_URL \ - + settings.TWIML_BIN['test'] - twiml_url += "?server={0}".format(hostname) - # Make call - twilio_client.calls.create(to=c.phone, from_=from_, - url=twiml_url, method='GET') - log.debug('Making test call to {0}'.format(c.phone)) - - if c.text_phone: - twilio_client.messages.create(to=c.phone, from_=from_, - body=('This is a test message from https://{0}' - '.ligo.org.').format(hostname)) - log.debug('Sending test text to {0}'.format(c.phone)) - except Exception as e: - messages.error(request, "Error contacting {0}: {1}." \ - .format(c.phone, e)) - log.exception('Error contacting {0}: {1}'.format(c.phone, e)) + twiml_url = '{base}{twiml_bin}'.format( + base=settings.TWIML_BASE_URL, + twiml_bin=settings.TWIML_BIN['test']) - return HttpResponseRedirect(reverse(index)) + # Make call + twilio_client.calls.create(to=self.object.phone, from_=from_, + url=twiml_url, method='GET') -@internal_user_required -def editContact(request, id): - raise Http404 + if (self.object.phone_method == Contact.CONTACT_PHONE_TEXT or + self.object.phone_method == Contact.CONTACT_PHONE_BOTH): + + twilio_client.messages.create(to=self.object.phone, + from_=from_, body=msg) -@internal_user_required -def deleteContact(request, id): - """Users can delete their Contacts through the web interface""" - try: - c = Contact.objects.get(id=id) - except Contact.DoesNotExist: - raise Http404 - if request.user != c.user: - return HttpResponseForbidden(("You are not authorized to modify " - "another user's Contacts.")) - messages.info(request, 'Contact "{cname}" has been deleted.' \ - .format(cname=c.desc)) - c.delete() - return HttpResponseRedirect(reverse(index)) + # Message for web view + messages.info(request, 'Testing contact "{desc}".'.format( + desc=self.object.description)) + + return HttpResponseRedirect(self.success_url) + + +@method_decorator(internal_user_required, name='dispatch') +class VerifyContactView(UpdateView): + """Request a verification code or verify a contact""" + template_name = 'alerts/verify_contact.html' + form_class = VerifyContactForm + success_url = reverse_lazy('alerts:index') + + def get_queryset(self): + return self.request.user.contact_set.all() + + def form_valid(self, form): + self.object.verify() + msg = 'Contact "{cname}" successfully verified.'.format( + cname=self.object.description) + messages.info(self.request, msg) + return super(VerifyContactView, self).form_valid(form) + + def get_context_data(self, **kwargs): + context = super(VerifyContactView, self).get_context_data(**kwargs) + + # Determine if verification code exists and is expired + if (self.object.verification_code is not None and + timezone.now() > self.object.verification_expiration): + context['code_expired'] = True + + return context + + +@method_decorator(internal_user_required, name='dispatch') +class RequestVerificationCodeView(DetailView): + """Redirect view for requesting a contact verification code""" + model = Contact + + def get_queryset(self): + return self.request.user.contact_set.all() + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + + # Handle case where contact is already verified + if self.object.verified: + msg = 'Contact "{desc}" is already verified.'.format( + desc=self.object.description) + messages.info(request, msg) + return HttpResponseRedirect(reverse('alerts:index')) + + # Otherwise, set up verification code for contact + self.object.generate_verification_code() + + # Send verification code + self.object.send_verification_code() + messages.info(request, "Verification code sent.") + return HttpResponseRedirect(reverse('alerts:verify-contact', + args=[self.object.pk])) diff --git a/gracedb/static/css/bootstrap_buttons.css b/gracedb/static/css/bootstrap_buttons.css new file mode 100644 index 0000000000000000000000000000000000000000..1f34909ee892c299b0d55f42badbd75205f971ec --- /dev/null +++ b/gracedb/static/css/bootstrap_buttons.css @@ -0,0 +1,671 @@ +/*! + * Bootstrap v4.2.1 (https://getbootstrap.com/) + * Copyright 2011-2018 The Bootstrap Authors + * Copyright 2011-2018 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ +button { + border-radius: 0; +} + +button:focus { + outline: 1px dotted; + outline: 5px auto -webkit-focus-ring-color; +} + +button { + margin: 0; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +button { + overflow: visible; +} + +button { + text-transform: none; +} + +button, +[type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; +} + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + padding: 0; + border-style: none; +} + + +.btn { + display: inline-block; + font-weight: 400; + color: #212529; + text-align: center; + vertical-align: middle; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + background-color: transparent; + border: 1px solid transparent; + padding: 0.375rem 0.75rem; + font-size: 1.4rem; + line-height: 1.5; + border-radius: 0.3rem; + transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + text-decoration: none; +} + +@media screen and (prefers-reduced-motion: reduce) { + .btn { + transition: none; + } +} + +.btn:hover { + color: #212529; + text-decoration: none; +} + +.btn:focus, .btn.focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +.btn.disabled, .btn:disabled { + opacity: 0.65; +} + +.btn:not(:disabled):not(.disabled) { + cursor: pointer; +} + +a.btn.disabled, +fieldset:disabled a.btn { + pointer-events: none; +} + +.btn-primary { + color: #fff; + background-color: #007bff; + border-color: #007bff; +} + +.btn-primary:hover { + color: #fff; + background-color: #0069d9; + border-color: #0062cc; +} + +.btn-primary:focus, .btn-primary.focus { + box-shadow: 0 0 0 0.2rem rgba(38, 143, 255, 0.5); +} + +.btn-primary.disabled, .btn-primary:disabled { + color: #fff; + background-color: #007bff; + border-color: #007bff; +} + +.btn-primary:not(:disabled):not(.disabled):active, .btn-primary:not(:disabled):not(.disabled).active, +.show > .btn-primary.dropdown-toggle { + color: #fff; + background-color: #0062cc; + border-color: #005cbf; +} + +.btn-primary:not(:disabled):not(.disabled):active:focus, .btn-primary:not(:disabled):not(.disabled).active:focus, +.show > .btn-primary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(38, 143, 255, 0.5); +} + +.btn-secondary { + color: #fff; + background-color: #6c757d; + border-color: #6c757d; +} + +.btn-secondary:hover { + color: #fff; + background-color: #5a6268; + border-color: #545b62; +} + +.btn-secondary:focus, .btn-secondary.focus { + box-shadow: 0 0 0 0.2rem rgba(130, 138, 145, 0.5); +} + +.btn-secondary.disabled, .btn-secondary:disabled { + color: #fff; + background-color: #6c757d; + border-color: #6c757d; +} + +.btn-secondary:not(:disabled):not(.disabled):active, .btn-secondary:not(:disabled):not(.disabled).active, +.show > .btn-secondary.dropdown-toggle { + color: #fff; + background-color: #545b62; + border-color: #4e555b; +} + +.btn-secondary:not(:disabled):not(.disabled):active:focus, .btn-secondary:not(:disabled):not(.disabled).active:focus, +.show > .btn-secondary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(130, 138, 145, 0.5); +} + +.btn-success { + color: #fff; + background-color: #28a745; + border-color: #28a745; +} + +.btn-success:hover { + color: #fff; + background-color: #218838; + border-color: #1e7e34; +} + +.btn-success:focus, .btn-success.focus { + box-shadow: 0 0 0 0.2rem rgba(72, 180, 97, 0.5); +} + +.btn-success.disabled, .btn-success:disabled { + color: #fff; + background-color: #28a745; + border-color: #28a745; +} + +.btn-success:not(:disabled):not(.disabled):active, .btn-success:not(:disabled):not(.disabled).active, +.show > .btn-success.dropdown-toggle { + color: #fff; + background-color: #1e7e34; + border-color: #1c7430; +} + +.btn-success:not(:disabled):not(.disabled):active:focus, .btn-success:not(:disabled):not(.disabled).active:focus, +.show > .btn-success.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(72, 180, 97, 0.5); +} + +.btn-info { + color: #fff; + background-color: #17a2b8; + border-color: #17a2b8; +} + +.btn-info:hover { + color: #fff; + background-color: #138496; + border-color: #117a8b; +} + +.btn-info:focus, .btn-info.focus { + box-shadow: 0 0 0 0.2rem rgba(58, 176, 195, 0.5); +} + +.btn-info.disabled, .btn-info:disabled { + color: #fff; + background-color: #17a2b8; + border-color: #17a2b8; +} + +.btn-info:not(:disabled):not(.disabled):active, .btn-info:not(:disabled):not(.disabled).active, +.show > .btn-info.dropdown-toggle { + color: #fff; + background-color: #117a8b; + border-color: #10707f; +} + +.btn-info:not(:disabled):not(.disabled):active:focus, .btn-info:not(:disabled):not(.disabled).active:focus, +.show > .btn-info.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(58, 176, 195, 0.5); +} + +.btn-warning { + color: #212529; + background-color: #ffc107; + border-color: #ffc107; +} + +.btn-warning:hover { + color: #212529; + background-color: #e0a800; + border-color: #d39e00; +} + +.btn-warning:focus, .btn-warning.focus { + box-shadow: 0 0 0 0.2rem rgba(222, 170, 12, 0.5); +} + +.btn-warning.disabled, .btn-warning:disabled { + color: #212529; + background-color: #ffc107; + border-color: #ffc107; +} + +.btn-warning:not(:disabled):not(.disabled):active, .btn-warning:not(:disabled):not(.disabled).active, +.show > .btn-warning.dropdown-toggle { + color: #212529; + background-color: #d39e00; + border-color: #c69500; +} + +.btn-warning:not(:disabled):not(.disabled):active:focus, .btn-warning:not(:disabled):not(.disabled).active:focus, +.show > .btn-warning.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(222, 170, 12, 0.5); +} + +.btn-danger { + color: #fff; + background-color: #dc3545; + border-color: #dc3545; +} + +.btn-danger:hover { + color: #fff; + background-color: #c82333; + border-color: #bd2130; +} + +.btn-danger:focus, .btn-danger.focus { + box-shadow: 0 0 0 0.2rem rgba(225, 83, 97, 0.5); +} + +.btn-danger.disabled, .btn-danger:disabled { + color: #fff; + background-color: #dc3545; + border-color: #dc3545; +} + +.btn-danger:not(:disabled):not(.disabled):active, .btn-danger:not(:disabled):not(.disabled).active, +.show > .btn-danger.dropdown-toggle { + color: #fff; + background-color: #bd2130; + border-color: #b21f2d; +} + +.btn-danger:not(:disabled):not(.disabled):active:focus, .btn-danger:not(:disabled):not(.disabled).active:focus, +.show > .btn-danger.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(225, 83, 97, 0.5); +} + +.btn-light { + color: #212529; + background-color: #f8f9fa; + border-color: #f8f9fa; +} + +.btn-light:hover { + color: #212529; + background-color: #e2e6ea; + border-color: #dae0e5; +} + +.btn-light:focus, .btn-light.focus { + box-shadow: 0 0 0 0.2rem rgba(216, 217, 219, 0.5); +} + +.btn-light.disabled, .btn-light:disabled { + color: #212529; + background-color: #f8f9fa; + border-color: #f8f9fa; +} + +.btn-light:not(:disabled):not(.disabled):active, .btn-light:not(:disabled):not(.disabled).active, +.show > .btn-light.dropdown-toggle { + color: #212529; + background-color: #dae0e5; + border-color: #d3d9df; +} + +.btn-light:not(:disabled):not(.disabled):active:focus, .btn-light:not(:disabled):not(.disabled).active:focus, +.show > .btn-light.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(216, 217, 219, 0.5); +} + +.btn-dark { + color: #fff; + background-color: #343a40; + border-color: #343a40; +} + +.btn-dark:hover { + color: #fff; + background-color: #23272b; + border-color: #1d2124; +} + +.btn-dark:focus, .btn-dark.focus { + box-shadow: 0 0 0 0.2rem rgba(82, 88, 93, 0.5); +} + +.btn-dark.disabled, .btn-dark:disabled { + color: #fff; + background-color: #343a40; + border-color: #343a40; +} + +.btn-dark:not(:disabled):not(.disabled):active, .btn-dark:not(:disabled):not(.disabled).active, +.show > .btn-dark.dropdown-toggle { + color: #fff; + background-color: #1d2124; + border-color: #171a1d; +} + +.btn-dark:not(:disabled):not(.disabled):active:focus, .btn-dark:not(:disabled):not(.disabled).active:focus, +.show > .btn-dark.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(82, 88, 93, 0.5); +} + +.btn-outline-primary { + color: #007bff; + border-color: #007bff; +} + +.btn-outline-primary:hover { + color: #fff; + background-color: #007bff; + border-color: #007bff; +} + +.btn-outline-primary:focus, .btn-outline-primary.focus { + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5); +} + +.btn-outline-primary.disabled, .btn-outline-primary:disabled { + color: #007bff; + background-color: transparent; +} + +.btn-outline-primary:not(:disabled):not(.disabled):active, .btn-outline-primary:not(:disabled):not(.disabled).active, +.show > .btn-outline-primary.dropdown-toggle { + color: #fff; + background-color: #007bff; + border-color: #007bff; +} + +.btn-outline-primary:not(:disabled):not(.disabled):active:focus, .btn-outline-primary:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-primary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5); +} + +.btn-outline-secondary { + color: #6c757d; + border-color: #6c757d; +} + +.btn-outline-secondary:hover { + color: #fff; + background-color: #6c757d; + border-color: #6c757d; +} + +.btn-outline-secondary:focus, .btn-outline-secondary.focus { + box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5); +} + +.btn-outline-secondary.disabled, .btn-outline-secondary:disabled { + color: #6c757d; + background-color: transparent; +} + +.btn-outline-secondary:not(:disabled):not(.disabled):active, .btn-outline-secondary:not(:disabled):not(.disabled).active, +.show > .btn-outline-secondary.dropdown-toggle { + color: #fff; + background-color: #6c757d; + border-color: #6c757d; +} + +.btn-outline-secondary:not(:disabled):not(.disabled):active:focus, .btn-outline-secondary:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-secondary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5); +} + +.btn-outline-success { + color: #28a745; + border-color: #28a745; +} + +.btn-outline-success:hover { + color: #fff; + background-color: #28a745; + border-color: #28a745; +} + +.btn-outline-success:focus, .btn-outline-success.focus { + box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5); +} + +.btn-outline-success.disabled, .btn-outline-success:disabled { + color: #28a745; + background-color: transparent; +} + +.btn-outline-success:not(:disabled):not(.disabled):active, .btn-outline-success:not(:disabled):not(.disabled).active, +.show > .btn-outline-success.dropdown-toggle { + color: #fff; + background-color: #28a745; + border-color: #28a745; +} + +.btn-outline-success:not(:disabled):not(.disabled):active:focus, .btn-outline-success:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-success.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5); +} + +.btn-outline-info { + color: #17a2b8; + border-color: #17a2b8; +} + +.btn-outline-info:hover { + color: #fff; + background-color: #17a2b8; + border-color: #17a2b8; +} + +.btn-outline-info:focus, .btn-outline-info.focus { + box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5); +} + +.btn-outline-info.disabled, .btn-outline-info:disabled { + color: #17a2b8; + background-color: transparent; +} + +.btn-outline-info:not(:disabled):not(.disabled):active, .btn-outline-info:not(:disabled):not(.disabled).active, +.show > .btn-outline-info.dropdown-toggle { + color: #fff; + background-color: #17a2b8; + border-color: #17a2b8; +} + +.btn-outline-info:not(:disabled):not(.disabled):active:focus, .btn-outline-info:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-info.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5); +} + +.btn-outline-warning { + color: #ffc107; + border-color: #ffc107; +} + +.btn-outline-warning:hover { + color: #212529; + background-color: #ffc107; + border-color: #ffc107; +} + +.btn-outline-warning:focus, .btn-outline-warning.focus { + box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5); +} + +.btn-outline-warning.disabled, .btn-outline-warning:disabled { + color: #ffc107; + background-color: transparent; +} + +.btn-outline-warning:not(:disabled):not(.disabled):active, .btn-outline-warning:not(:disabled):not(.disabled).active, +.show > .btn-outline-warning.dropdown-toggle { + color: #212529; + background-color: #ffc107; + border-color: #ffc107; +} + +.btn-outline-warning:not(:disabled):not(.disabled):active:focus, .btn-outline-warning:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-warning.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5); +} + +.btn-outline-danger { + color: #dc3545; + border-color: #dc3545; +} + +.btn-outline-danger:hover { + color: #fff; + background-color: #dc3545; + border-color: #dc3545; +} + +.btn-outline-danger:focus, .btn-outline-danger.focus { + box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5); +} + +.btn-outline-danger.disabled, .btn-outline-danger:disabled { + color: #dc3545; + background-color: transparent; +} + +.btn-outline-danger:not(:disabled):not(.disabled):active, .btn-outline-danger:not(:disabled):not(.disabled).active, +.show > .btn-outline-danger.dropdown-toggle { + color: #fff; + background-color: #dc3545; + border-color: #dc3545; +} + +.btn-outline-danger:not(:disabled):not(.disabled):active:focus, .btn-outline-danger:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-danger.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5); +} + +.btn-outline-light { + color: #f8f9fa; + border-color: #f8f9fa; +} + +.btn-outline-light:hover { + color: #212529; + background-color: #f8f9fa; + border-color: #f8f9fa; +} + +.btn-outline-light:focus, .btn-outline-light.focus { + box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5); +} + +.btn-outline-light.disabled, .btn-outline-light:disabled { + color: #f8f9fa; + background-color: transparent; +} + +.btn-outline-light:not(:disabled):not(.disabled):active, .btn-outline-light:not(:disabled):not(.disabled).active, +.show > .btn-outline-light.dropdown-toggle { + color: #212529; + background-color: #f8f9fa; + border-color: #f8f9fa; +} + +.btn-outline-light:not(:disabled):not(.disabled):active:focus, .btn-outline-light:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-light.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5); +} + +.btn-outline-dark { + color: #343a40; + border-color: #343a40; +} + +.btn-outline-dark:hover { + color: #fff; + background-color: #343a40; + border-color: #343a40; +} + +.btn-outline-dark:focus, .btn-outline-dark.focus { + box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5); +} + +.btn-outline-dark.disabled, .btn-outline-dark:disabled { + color: #343a40; + background-color: transparent; +} + +.btn-outline-dark:not(:disabled):not(.disabled):active, .btn-outline-dark:not(:disabled):not(.disabled).active, +.show > .btn-outline-dark.dropdown-toggle { + color: #fff; + background-color: #343a40; + border-color: #343a40; +} + +.btn-outline-dark:not(:disabled):not(.disabled):active:focus, .btn-outline-dark:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-dark.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5); +} + +.btn-link { + font-weight: 400; + color: #007bff; +} + +.btn-link:hover { + color: #0056b3; + text-decoration: underline; +} + +.btn-link:focus, .btn-link.focus { + text-decoration: underline; + box-shadow: none; +} + +.btn-link:disabled, .btn-link.disabled { + color: #6c757d; + pointer-events: none; +} + +.btn-lg, .btn-group-lg > .btn { + padding: 0.5rem 1rem; + font-size: 1.25rem; + line-height: 1.5; + border-radius: 0.3rem; +} + +.btn-sm, .btn-group-sm > .btn { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + line-height: 1.5; + border-radius: 0.2rem; +} + +.btn-block { + display: block; + width: 100%; +} + +.btn-block + .btn-block { + margin-top: 0.5rem; +} + +input[type="submit"].btn-block, +input[type="reset"].btn-block, +input[type="button"].btn-block { + width: 100%; +} diff --git a/gracedb/templates/alerts/create_contact.html b/gracedb/templates/alerts/create_contact.html new file mode 100644 index 0000000000000000000000000000000000000000..f8887d32a98bf04687ab1e29c8d800942570ed89 --- /dev/null +++ b/gracedb/templates/alerts/create_contact.html @@ -0,0 +1,58 @@ +{% 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 %} diff --git a/gracedb/templates/alerts/edit_contact.html b/gracedb/templates/alerts/edit_contact.html new file mode 100644 index 0000000000000000000000000000000000000000..f71c712b27dc585af8232d4514ac9516f617a2f1 --- /dev/null +++ b/gracedb/templates/alerts/edit_contact.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% block title %}Options | Edit Contact{% endblock %} +{% block heading %}Edit contact "{{ object.description }}"{% endblock %} + +{% block content %} +<br /> + +<form method="POST"> + <table> + {{ form.as_table }} + </table> + <input type="submit" value="Submit" /> +</form> + +{% endblock %} diff --git a/gracedb/templates/alerts/verify_contact.html b/gracedb/templates/alerts/verify_contact.html new file mode 100644 index 0000000000000000000000000000000000000000..b9b238518ed1f41de1bf54af99bbcc88927541a2 --- /dev/null +++ b/gracedb/templates/alerts/verify_contact.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% load static %} + +{% block headcontents %} + <link rel="stylesheet" href="{% static "css/bootstrap_buttons.css" %}"></script> + {{ block.super }} +{% endblock %} + +{% block title %}Verify contact{% endblock %} +{% block heading %} +Verify contact "{{ contact.description }}" +{% endblock %} + + +{% block content %} + +<br /> +{% if messages %} +<div class="flash"> + {% for m in messages %} + <p>{{ m }}</p> + {% endfor %} +</div> +{% endif %} + +{% if object.verified %} +This contact is already verified. +{% else %} + <h3>A verification code will be sent via:</h3> + <ul> + <li>Email for email contacts</li> + <li>Text message for phone contacts who have specified "text" or "call and text"</li> + <li>Voice call for phone contacts who have specified "call"</li> + </ul> + <a class="btn btn-primary" href="{% url "alerts:request-verification-code" object.pk %}"> + {% if contact.verification_code %} + Request a new verification code + {% else %} + Request a verification code + {% endif %} + </a> + {% if contact.verification_code %} + <br /> + <br /> + {% if code_expired %} + Your verification code has expired. Use the button above to request a new one. + {% else %} + Your current verification code expires in <b>{{ contact.verification_expiration|timeuntil }}</b>. + <form method="POST"> + <table>{{ form.as_table }}</table> + <input type="submit" value="Submit" /> + </form> + {% endif %} + {% endif %} +{% endif %} + +{% endblock %} diff --git a/gracedb/templates/navbar_frag.html b/gracedb/templates/navbar_frag.html index 918dd140a9489a701b9f311a7a2894ee95057a1b..82c48b9a102f78bc699159db873258e9d60ec344 100644 --- a/gracedb/templates/navbar_frag.html +++ b/gracedb/templates/navbar_frag.html @@ -9,7 +9,7 @@ {% endif %} <li id="nav-latest"><a href="{% url "latest" %}">Latest</a></li> {% if user.is_authenticated %} - <li id="nav-userprofile"><a href="{% url "userprofile-home" %}">Options</a></li> + <li id="nav-userprofile"><a href="{% url "alerts:index" %}">Options</a></li> {% endif %} <li id="nav-docs"><a href="{% url "home" %}documentation/">Documentation</a></li> {% if user %} diff --git a/gracedb/templates/profile/createContact.html b/gracedb/templates/profile/createContact.html deleted file mode 100644 index 7e47febfbd076e952333be902a14b020e4f0ceb4..0000000000000000000000000000000000000000 --- a/gracedb/templates/profile/createContact.html +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "base.html" %} - -{% 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>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> - {{ form.as_table }} - </table> - <input type="submit" value="Submit" /> -</form> - -{% endblock %} diff --git a/gracedb/templates/profile/notifications.html b/gracedb/templates/profile/notifications.html index 09ae79d757c2cbf3f4d21bd7e9c5e11916a42cb6..cd394e2295fb79a1683992c29cf72e023388e21a 100644 --- a/gracedb/templates/profile/notifications.html +++ b/gracedb/templates/profile/notifications.html @@ -4,6 +4,12 @@ {% block heading %}<!-- leave heading blank -->{% endblock %} {% block pageid %}userprofile{% endblock %} +{% load static %} +{% block headcontents %} + <link rel="stylesheet" href="{% static "css/bootstrap_buttons.css" %}"></script> + {{ block.super }} +{% endblock %} + {% block content %} {% if messages %} @@ -14,55 +20,74 @@ </div> {% endif %} -<h2>Contacts (LVC Users)</h2> +{# Show contacts and notification to internal users only#} +{% if user_is_internal %} +<h2>Contacts</h2> +{% if contacts %} +<ul style="list-style-type: none; padding-left: 10px;"> {% for contact in contacts %} - <ul> - <li> - <a href="{% url "userprofile-test-contact" contact.id %}">Test</a> - <a href="{% url "userprofile-delete-contact" contact.id %}">Delete</a> - {% comment %} - This next part is gross but necessary due to Django whitespace messiness. - {% endcomment %} - {{contact.desc}} | - {% if contact.email and contact.call_phone and contact.text_phone %} - Email {{contact.email}}, call/text {{contact.phone}} - {% elif contact.email and contact.call_phone %} - Email {{contact.email}}, call {{contact.phone}} - {% elif contact.email and contact.text_phone %} - Email {{contact.email}}, text {{contact.phone}} - {% elif contact.email %} - Email {{contact.email}} - {% elif contact.call_phone and contact.text_phone %} + <li style="padding-bottom: 5px;"> + {% if contact.verified %} + <a class="btn btn-primary" href="{% url "alerts:test-contact" contact.id %}">Test</a> + {% else %} + <button class="btn btn-primary" disabled>Test</button> + {% endif %} + <a class="btn btn-secondary" href="{% url "alerts:edit-contact" contact.id %}">Edit</a> + <a class="btn btn-danger" href="{% url "alerts:delete-contact" contact.id %}">Delete</a> + <b>{{ contact.description }}</b> | + {% if contact.email %} + Email {{ contact.email }} + {% elif contact.phone %} + {% if contact.phone_method == 'B' %} Call/text {{contact.phone}} - {% elif contact.call_phone %} - Call {{contact.phone}} - {% elif contact.text_phone %} + {% elif contact.phone_method == 'T' %} Text {{contact.phone}} - {% else %} - ERROR: this contact has no contact method. + {% elif contact.phone_method == 'C' %} + Call {{contact.phone}} {% endif %} - </li> - </ul> + {% else %} + <b>ERROR: no contact method found.</b> + {% endif %} + <b> + {% if contact.verified %} + <div style="color: green; display: inline-block;">Verified</div> + {% else %} + <div style="color: red; display: inline-block;">Not verified</div> + <a class="btn btn-warning" href="{% url "alerts:verify-contact" contact.id %}">Verify</a> + {% endif %} + </b> + </li> {% endfor %} +</ul> +{% endif %} -<a href="{% url "userprofile-create-contact" %}">Create New Contact</a> +<a href="{% url "alerts:create-contact" %}">Create New Contact</a> <br/><br/> -<h2>Notifications (LVC Users)</h2> +<h2>Notifications</h2> +{% if notifications %} +<ul> {% for notification in notifications %} - <ul> - <li> - <a href="{% url "userprofile-delete" notification.id %}">Delete</a> + <li> + <a href="{% url "alerts:delete-notification" notification.id %}">Delete</a> {{ notification.userlessDisplay }} - </li> - </ul> + </li> {% endfor %} +</ul> +{% endif %} -<a href="{% url "userprofile-create" %}">Create New Notification (LVC users)</a> +<a href="{% url "alerts:create-notification" %}">Create New Notification</a> <br/><br/> +{% endif %} -<h2>Passwords for Scripted Access (LV-EM users)</h2> - -<a href="{% url "userprofile-manage-password" %}">Manage Password</a> +{# Show password page only to LV-EM users who are not also internal #} +{# But let the admins (superusers) see it with some additions #} +{% if user_is_lvem and not user_is_internal or user.is_superuser %} + {% if user.is_superuser %} + <h2 style="border: 2px solid red; color: red; display: inline-block;"><b>The following section is only visible to non-internal LV-EM members. You can see it (and this message) because you are a superuser.</b></h2> + {% endif %} +<h2>Passwords for Scripted Access</h2> +<a href="{% url "alerts:manage-password" %}">Manage Password</a> +{% endif %} {% endblock %}