diff --git a/opentech/apply/dashboard/tables.py b/opentech/apply/dashboard/tables.py index aea08b27d614ccead18e452f1325bb1f8a79d020..2ef02e44fc5f59f114aeca526788283c690268c9 100644 --- a/opentech/apply/dashboard/tables.py +++ b/opentech/apply/dashboard/tables.py @@ -8,5 +8,8 @@ class DashboardTable(tables.Table): class Meta: model = ApplicationSubmission - fields = ('title', 'page', 'round', 'submit_time') + fields = ('title', 'page', 'round', 'submit_time', 'user') template = "dashboard/tables/table.html" + + def render_user(self, value): + return value.get_full_name() diff --git a/opentech/apply/funds/blocks.py b/opentech/apply/funds/blocks.py index 65caa5c9486429be758e3ea724b938523818dda0..b0cd51c6b93b367785d108aff33610c71bd68d55 100644 --- a/opentech/apply/funds/blocks.py +++ b/opentech/apply/funds/blocks.py @@ -148,4 +148,21 @@ class ValueBlock(MustIncludeFieldBlock): widget = forms.NumberInput +class EmailBlock(MustIncludeFieldBlock): + name = 'email' + description = 'The applicant email address' + widget = forms.EmailInput + + class Meta: + icon = 'mail' + + +class FullNameBlock(MustIncludeFieldBlock): + name = 'full_name' + description = 'Full name' + + class Meta: + icon = 'user' + + REQUIRED_BLOCK_NAMES = [block.name for block in MustIncludeFieldBlock.__subclasses__()] diff --git a/opentech/apply/funds/migrations/00014_add_meta_names.py b/opentech/apply/funds/migrations/0014_add_meta_names.py similarity index 100% rename from opentech/apply/funds/migrations/00014_add_meta_names.py rename to opentech/apply/funds/migrations/0014_add_meta_names.py diff --git a/opentech/apply/funds/migrations/0015_link_user_to_application.py b/opentech/apply/funds/migrations/0015_link_user_to_application.py new file mode 100644 index 0000000000000000000000000000000000000000..b71b0ab26574fa9b2200e0192f104cdfadbdc13f --- /dev/null +++ b/opentech/apply/funds/migrations/0015_link_user_to_application.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.8 on 2018-02-01 16:45 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import opentech.apply.categories.blocks +import wagtail.wagtailcore.blocks +import wagtail.wagtailcore.blocks.static_block +import wagtail.wagtailcore.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('funds', '0014_add_meta_names'), + ] + + operations = [ + migrations.AddField( + model_name='applicationsubmission', + name='user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='fundtype', + name='confirmation_text_extra', + field=models.TextField(blank=True, help_text='Additional text for the application confirmation message.'), + ), + migrations.AddField( + model_name='fundtype', + name='from_address', + field=models.CharField(blank=True, max_length=255, verbose_name='from address'), + ), + migrations.AddField( + model_name='fundtype', + name='subject', + field=models.CharField(blank=True, max_length=255, verbose_name='subject'), + ), + migrations.AddField( + model_name='fundtype', + name='to_address', + field=models.CharField(blank=True, help_text='Optional - form submissions will be emailed to these addresses. Separate multiple addresses by comma.', max_length=255, verbose_name='to address'), + ), + migrations.AddField( + model_name='labtype', + name='confirmation_text_extra', + field=models.TextField(blank=True, help_text='Additional text for the application confirmation message.'), + ), + migrations.AddField( + model_name='labtype', + name='from_address', + field=models.CharField(blank=True, max_length=255, verbose_name='from address'), + ), + migrations.AddField( + model_name='labtype', + name='subject', + field=models.CharField(blank=True, max_length=255, verbose_name='subject'), + ), + migrations.AddField( + model_name='labtype', + name='to_address', + field=models.CharField(blank=True, help_text='Optional - form submissions will be emailed to these addresses. Separate multiple addresses by comma.', max_length=255, verbose_name='to address'), + ), + migrations.AlterField( + model_name='applicationform', + name='form_fields', + field=wagtail.wagtailcore.fields.StreamField((('text_markup', wagtail.wagtailcore.blocks.RichTextBlock(group='Other', label='Paragraph')), ('char', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('format', wagtail.wagtailcore.blocks.ChoiceBlock(choices=[('email', 'Email'), ('url', 'URL')], label='Format', required=False)), ('default_value', wagtail.wagtailcore.blocks.CharBlock(label='Default value', required=False))), group='Fields')), ('text', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.wagtailcore.blocks.TextBlock(label='Default value', required=False))), group='Fields')), ('number', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.wagtailcore.blocks.CharBlock(label='Default value', required=False))), group='Fields')), ('checkbox', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('default_value', wagtail.wagtailcore.blocks.BooleanBlock(required=False))), group='Fields')), ('radios', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.wagtailcore.blocks.ListBlock(wagtail.wagtailcore.blocks.CharBlock(label='Choice')))), group='Fields')), ('dropdown', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.wagtailcore.blocks.ListBlock(wagtail.wagtailcore.blocks.CharBlock(label='Choice')))), group='Fields')), ('checkboxes', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('checkboxes', wagtail.wagtailcore.blocks.ListBlock(wagtail.wagtailcore.blocks.CharBlock(label='Checkbox')))), group='Fields')), ('date', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.wagtailcore.blocks.DateBlock(required=False))), group='Fields')), ('time', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.wagtailcore.blocks.TimeBlock(required=False))), group='Fields')), ('datetime', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.wagtailcore.blocks.DateTimeBlock(required=False))), group='Fields')), ('image', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False))), group='Fields')), ('file', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False))), group='Fields')), ('rich_text', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.wagtailcore.blocks.TextBlock(label='Default value', required=False))), group='Fields')), ('category', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(help_text='Leave blank to use the default Category label', label='Label', required=False)), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Leave blank to use the default Category help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('category', opentech.apply.categories.blocks.ModelChooserBlock('categories.Category')), ('multi', wagtail.wagtailcore.blocks.BooleanBlock(label='Multi select', required=False))), group='Custom')), ('title', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('info', wagtail.wagtailcore.blocks.static_block.StaticBlock())), group='Required')), ('value', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('info', wagtail.wagtailcore.blocks.static_block.StaticBlock())), group='Required')), ('email', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('info', wagtail.wagtailcore.blocks.static_block.StaticBlock())), group='Required')), ('full_name', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('info', wagtail.wagtailcore.blocks.static_block.StaticBlock())), group='Required')))), + ), + ] diff --git a/opentech/apply/funds/models.py b/opentech/apply/funds/models.py index f0b7c597932f2d8c5bf58ad04732221b75be08f6..7b368cdf49e6e121c6b99a5c7cc7c34fb1444630 100644 --- a/opentech/apply/funds/models.py +++ b/opentech/apply/funds/models.py @@ -1,5 +1,7 @@ from datetime import date +from django.conf import settings +from django.contrib.auth import get_user_model from django.contrib.postgres.fields import JSONField from django.core.exceptions import ValidationError from django.core.serializers.json import DjangoJSONEncoder @@ -7,6 +9,7 @@ from django.db import models from django.db.models import Q from django.db.models.expressions import RawSQL, OrderBy from django.http import Http404 +from django.template.loader import render_to_string from django.urls import reverse from django.utils.text import mark_safe from django.utils.translation import ugettext_lazy as _ @@ -14,14 +17,18 @@ from django.utils.translation import ugettext_lazy as _ from modelcluster.fields import ParentalKey from wagtail.wagtailadmin.edit_handlers import ( FieldPanel, - InlinePanel, FieldRowPanel, + InlinePanel, MultiFieldPanel, + ObjectList, StreamFieldPanel, + TabbedInterface ) + +from wagtail.wagtailadmin.utils import send_mail from wagtail.wagtailcore.fields import StreamField -from wagtail.wagtailcore.models import Orderable -from wagtail.wagtailforms.models import AbstractFormSubmission +from wagtail.wagtailcore.models import Orderable, Page +from wagtail.wagtailforms.models import AbstractEmailForm, AbstractFormSubmission from opentech.apply.stream_forms.models import AbstractStreamForm @@ -55,16 +62,29 @@ class SubmittableStreamForm(AbstractStreamForm): response = cleaned_data.pop(field.id) cleaned_data[field.block.name] = response + if form.user.is_authenticated(): + user = form.user + cleaned_data['email'] = user.email + cleaned_data['full_name'] = user.get_full_name() + else: + User = get_user_model() + email = cleaned_data.get('email') + full_name = cleaned_data.get('full_name') + user, _ = User.objects.get_or_create_and_notify( + email=email, + defaults={'full_name': full_name} + ) + return self.get_submission_class().objects.create( form_data=cleaned_data, - **self.get_submit_meta_data(), + **self.get_submit_meta_data(user=user), ) def get_submit_meta_data(self, **kwargs): return kwargs -class DefinableWorkflowStreamForm(AbstractStreamForm): +class DefinableWorkflowStreamForm(AbstractEmailForm, AbstractStreamForm): class Meta: abstract = True @@ -76,6 +96,7 @@ class DefinableWorkflowStreamForm(AbstractStreamForm): } workflow = models.CharField(choices=WORKFLOWS.items(), max_length=100, default='single') + confirmation_text_extra = models.TextField(blank=True, help_text="Additional text for the application confirmation message.") def get_defined_fields(self): # Only return the first form, will need updating for when working with 2 stage WF @@ -85,11 +106,51 @@ class DefinableWorkflowStreamForm(AbstractStreamForm): def workflow_class(self): return WORKFLOW_CLASS[self.get_workflow_display()] + def process_form_submission(self, form): + submission = super().process_form_submission(form) + self.send_mail(form) + return submission + + def send_mail(self, form): + data = form.cleaned_data + email = data.get('email') + context = { + 'name': data.get('full_name'), + 'email': email, + 'project_name': data.get('title'), + 'extra_text': self.confirmation_text_extra, + 'fund_type': self.title, + } + + subject = self.subject if self.subject else 'Thank You for Your submission to Open Technology Fund' + send_mail(subject, render_to_string('funds/email/confirmation.txt', context), (email,), self.from_address, ) + content_panels = AbstractStreamForm.content_panels + [ FieldPanel('workflow'), InlinePanel('forms', label="Forms"), ] + email_confirmation_panels = [ + MultiFieldPanel( + [ + FieldRowPanel([ + FieldPanel('from_address', classname="col6"), + FieldPanel('to_address', classname="col6"), + ]), + FieldPanel('subject'), + FieldPanel('confirmation_text_extra'), + ], + heading="Confirmation email", + ) + ] + + edit_handler = TabbedInterface([ + ObjectList(content_panels, heading='Content'), + ObjectList(email_confirmation_panels, heading='Confirmation email'), + ObjectList(Page.promote_panels, heading='Promote'), + ObjectList(Page.settings_panels, heading='Settings', classname="settings"), + ]) + class FundType(DefinableWorkflowStreamForm): class Meta: @@ -168,6 +229,11 @@ class Round(SubmittableStreamForm): **kwargs, ) + def process_form_submission(self, form): + submission = super().process_form_submission(form) + self.get_parent().specific.send_mail(form) + return submission + def get_defined_fields(self): # Only return the first form, will need updating for when working with 2 stage WF return self.get_parent().specific.forms.all()[0].fields @@ -235,6 +301,10 @@ class LabType(DefinableWorkflowStreamForm, SubmittableStreamForm): # type: igno parent_page_types = ['apply_home.ApplyHomePage'] subpage_types = [] # type: ignore + def get_defined_fields(self): + # Only return the first form, will need updating for when working with 2 stage WF + return self.specific.forms.all()[0].fields + def get_submit_meta_data(self, **kwargs): return super().get_submit_meta_data( page=self, @@ -275,6 +345,7 @@ class JSONOrderable(models.QuerySet): class ApplicationSubmission(AbstractFormSubmission): form_data = JSONField(encoder=DjangoJSONEncoder) round = models.ForeignKey('wagtailcore.Page', on_delete=models.CASCADE, related_name='submissions', null=True) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) objects = JSONOrderable.as_manager() diff --git a/opentech/apply/funds/templates/funds/email/confirmation.txt b/opentech/apply/funds/templates/funds/email/confirmation.txt new file mode 100644 index 0000000000000000000000000000000000000000..6756377e3ca2e9ed4f5726981776b543484a050e --- /dev/null +++ b/opentech/apply/funds/templates/funds/email/confirmation.txt @@ -0,0 +1,20 @@ +Dear {{ name|default:"applicant" }}, + +We appreciate your {{ fund_type }} application submission to the Open Technology Fund. We will review and reply to your +submission as quickly as possible. Our reply will have the next steps for your {{ fund_type }} application. +You can find more information about our support options, review process and selection criteria on our website. + +{% if extra_text %}{{ extra_text }}{% endif %} + +If you have any questions, please email us at info@opentechfund.org. + +{% if project_name %}Project name: {{ project_name }}{% endif %} +{% if name %}Contact name: {{ name }}{% endif %} +{% if email %}Contact email: {{ email }}{% endif %} + +Thanks again, +The OTF Team + +-- +Open Technology Fund +https://www.opentech.fund/ diff --git a/opentech/apply/funds/tests/factories/__init__.py b/opentech/apply/funds/tests/factories/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c9d2b8ebadf12898359444c6059e84c4383a94cc --- /dev/null +++ b/opentech/apply/funds/tests/factories/__init__.py @@ -0,0 +1,9 @@ +from . import models +from .models import * # noqa +from . import blocks +from .blocks import * # noqa + +__all__ = [] + +__all__.extend(blocks.__all__) +__all__.extend(models.__all__) diff --git a/opentech/apply/funds/tests/factories/blocks.py b/opentech/apply/funds/tests/factories/blocks.py new file mode 100644 index 0000000000000000000000000000000000000000..de04acc9acf1ea0a87f318f0481023bf121c5049 --- /dev/null +++ b/opentech/apply/funds/tests/factories/blocks.py @@ -0,0 +1,22 @@ +import wagtail_factories + +from opentech.apply.stream_forms.blocks import FormFieldBlock +from opentech.apply.funds import blocks + + +__all__ = ['FormFieldBlock', 'FullNameBlockFactory', 'EmailBlockFactory'] + + +class FormFieldBlockFactory(wagtail_factories.StructBlockFactory): + class Meta: + model = FormFieldBlock + + +class EmailBlockFactory(FormFieldBlockFactory): + class Meta: + model = blocks.EmailBlock + + +class FullNameBlockFactory(FormFieldBlockFactory): + class Meta: + model = blocks.FullNameBlock diff --git a/opentech/apply/funds/tests/factories.py b/opentech/apply/funds/tests/factories/models.py similarity index 80% rename from opentech/apply/funds/tests/factories.py rename to opentech/apply/funds/tests/factories/models.py index aa37d55a8e2ad6bfe00e33916aa381eedaac16d4..3850da9b1bffa3dffa3af600011bbc51788c6492 100644 --- a/opentech/apply/funds/tests/factories.py +++ b/opentech/apply/funds/tests/factories/models.py @@ -4,9 +4,32 @@ from django.forms import Form import factory import wagtail_factories -from opentech.apply.funds.models import ApplicationForm, FundType, FundForm, Round +from opentech.apply.funds.models import ( + ApplicationForm, + FundType, + FundForm, + LabForm, + LabType, + Round, +) from opentech.apply.funds.workflow import Action, Phase, Stage, Workflow +from . import blocks + + +__all__ = [ + 'ActionFactory', + 'PhaseFactory', + 'StageFactory', + 'WorkflowFactory', + 'FundTypeFactory', + 'FundFormFactory', + 'ApplicationFormFactory', + 'RoundFactory', + 'LabFactory', + 'LabFormFactory', +] + class ListSubFactory(factory.SubFactory): def __init__(self, *args, count=0, **kwargs): @@ -137,6 +160,10 @@ class ApplicationFormFactory(factory.DjangoModelFactory): model = ApplicationForm name = factory.Faker('word') + form_fields = wagtail_factories.StreamFieldFactory({ + 'email': blocks.EmailBlockFactory, + 'full_name': blocks.FullNameBlockFactory, + }) class RoundFactory(wagtail_factories.PageFactory): @@ -146,3 +173,22 @@ class RoundFactory(wagtail_factories.PageFactory): title = factory.Sequence('Round {}'.format) start_date = factory.LazyFunction(datetime.date.today) end_date = factory.LazyFunction(lambda: datetime.date.today() + datetime.timedelta(days=7)) + + +class LabFactory(wagtail_factories.PageFactory): + class Meta: + model = LabType + + class Params: + workflow_stages = 1 + number_forms = 1 + + # Will need to update how the stages are identified as Fund Page changes + workflow = factory.LazyAttribute(lambda o: list(FundType.WORKFLOWS.keys())[o.workflow_stages - 1]) + + +class LabFormFactory(factory.DjangoModelFactory): + class Meta: + model = LabForm + lab = factory.SubFactory(LabFactory, parent=None) + form = factory.SubFactory('opentech.apply.tests.factories.ApplicationFormFactory') diff --git a/opentech/apply/funds/tests/test_models.py b/opentech/apply/funds/tests/test_models.py index 0c6fcc20cfecf8f907c63b447bd295cfab81275c..fe534f3818c9d0491d04ffaaf56cd521f4b1c522 100644 --- a/opentech/apply/funds/tests/test_models.py +++ b/opentech/apply/funds/tests/test_models.py @@ -1,11 +1,24 @@ from datetime import date, timedelta +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser +from django.core import mail from django.core.exceptions import ValidationError -from django.test import TestCase +from django.test import RequestFactory, TestCase +from wagtail.wagtailcore.models import Site + +from opentech.apply.funds.models import ApplicationSubmission from opentech.apply.funds.workflow import SingleStage -from .factories import FundTypeFactory, RoundFactory +from .factories import ( + ApplicationFormFactory, + FundFormFactory, + FundTypeFactory, + LabFactory, + LabFormFactory, + RoundFactory, +) def days_from_today(days): @@ -124,3 +137,113 @@ class TestRoundModel(TestCase): with self.assertRaises(ValidationError): new_round.clean() + + +class TestFormSubmission(TestCase): + def setUp(self): + self.site = Site.objects.first() + self.User = get_user_model() + + self.email = 'test@test.com' + self.name = 'My Name' + + self.request_factory = RequestFactory() + # set up application form with minimal requirement for creating user + application_form = { + 'form_fields__0__email__': '', + 'form_fields__1__full_name__': '', + } + form = ApplicationFormFactory(**application_form) + fund = FundTypeFactory() + FundFormFactory(fund=fund, form=form) + self.round_page = RoundFactory(parent=fund) + self.lab_page = LabFactory() + LabFormFactory(lab=self.lab_page, form=form) + + def submit_form(self, page=None, email=None, name=None, user=AnonymousUser()): + if email is None: + email = self.email + if name is None: + name = self.name + + page = page or self.round_page + fields = page.get_form_fields() + data = {k: v for k, v in zip(fields, [email, name])} + + request = self.request_factory.post('', data) + request.user = user + request.site = self.site + + try: + return page.get_parent().serve(request) + except AttributeError: + return page.serve(request) + + def test_can_submit_if_new(self): + self.submit_form() + + self.assertEqual(self.User.objects.count(), 1) + new_user = self.User.objects.get(email=self.email) + self.assertEqual(new_user.get_full_name(), self.name) + + self.assertEqual(ApplicationSubmission.objects.count(), 1) + self.assertEqual(ApplicationSubmission.objects.first().user, new_user) + + def test_associated_if_not_new(self): + self.submit_form() + self.submit_form() + + self.assertEqual(self.User.objects.count(), 1) + + user = self.User.objects.get(email=self.email) + self.assertEqual(ApplicationSubmission.objects.count(), 2) + self.assertEqual(ApplicationSubmission.objects.first().user, user) + + def test_associated_if_another_user_exists(self): + self.submit_form() + # Someone else submits a form + self.submit_form(email='another@email.com') + + self.assertEqual(self.User.objects.count(), 2) + + first_user, second_user = self.User.objects.all() + self.assertEqual(ApplicationSubmission.objects.count(), 2) + self.assertEqual(ApplicationSubmission.objects.first().user, first_user) + self.assertEqual(ApplicationSubmission.objects.last().user, second_user) + + def test_associated_if_logged_in(self): + user, _ = self.User.objects.get_or_create(email=self.email, defaults={'full_name': self.name}) + + self.assertEqual(self.User.objects.count(), 1) + + self.submit_form(email=self.email, name=self.name, user=user) + + self.assertEqual(self.User.objects.count(), 1) + + self.assertEqual(ApplicationSubmission.objects.count(), 1) + self.assertEqual(ApplicationSubmission.objects.first().user, user) + + # This will need to be updated when we hide user information contextually + def test_errors_if_blank_user_data_even_if_logged_in(self): + user, _ = self.User.objects.get_or_create(email=self.email, defaults={'full_name': self.name}) + + self.assertEqual(self.User.objects.count(), 1) + + response = self.submit_form(email='', name='', user=user) + self.assertContains(response, 'This field is required') + + self.assertEqual(self.User.objects.count(), 1) + + self.assertEqual(ApplicationSubmission.objects.count(), 0) + + def test_email_sent_to_user_on_submission_fund(self): + self.submit_form() + # "Thank you for your submission" and "Account Creation" + self.assertEqual(len(mail.outbox), 2) + self.assertEqual(mail.outbox[0].to[0], self.email) + + def test_email_sent_to_user_on_submission_lab(self): + self.submit_form(page=self.lab_page) + # "Thank you for your submission" and "Account Creation" + self.assertEqual(len(mail.outbox), 2) + self.assertEqual(mail.outbox[0].to[0], self.email) diff --git a/opentech/apply/users/migrations/0003_make_email_username.py b/opentech/apply/users/migrations/0003_make_email_username.py new file mode 100644 index 0000000000000000000000000000000000000000..117321f644af34da3ff44901d645af3c8795a9d6 --- /dev/null +++ b/opentech/apply/users/migrations/0003_make_email_username.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.8 on 2018-02-05 10:52 +from __future__ import unicode_literals + +from django.db import migrations, models + +import opentech.apply.users.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_initial_data'), + ] + + operations = [ + migrations.AlterModelManagers( + name='user', + managers=[ + ('objects', opentech.apply.users.models.UserManager()), + ], + ), + migrations.AlterField( + model_name='user', + name='email', + field=models.EmailField(max_length=254, unique=True, verbose_name='email address'), + ), + migrations.RemoveField( + model_name='user', + name='username', + ), + ] diff --git a/opentech/apply/users/models.py b/opentech/apply/users/models.py index 3d305253bf8157dcb4249315ffef41fa60655ab5..67b022049b54c88f23f7c9a040e6ec3b04895c90 100644 --- a/opentech/apply/users/models.py +++ b/opentech/apply/users/models.py @@ -1,5 +1,71 @@ -from django.contrib.auth.models import AbstractUser +from django.db import models +from django.contrib.auth.models import AbstractUser, BaseUserManager +from django.utils.translation import gettext_lazy as _ + +from .utils import send_activation_email + + +def convert_full_name_to_parts(defaults): + full_name = defaults.pop('full_name', ' ') + first_name, *last_name = full_name.split(' ') + if first_name: + defaults.update(first_name=first_name) + if last_name: + defaults.update(last_name=' '.join(last_name)) + return defaults + + +class UserManager(BaseUserManager): + use_in_migrations = True + + def _create_user(self, email, password, **extra_fields): + """ + Creates and saves a User with the given username, email and password. + """ + if not email: + raise ValueError('The given email must be set') + email = self.normalize_email(email) + extra_fields = convert_full_name_to_parts(extra_fields) + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_user(self, email, password=None, **extra_fields): + extra_fields.setdefault('is_staff', False) + extra_fields.setdefault('is_superuser', False) + return self._create_user(email, password, **extra_fields) + + def create_superuser(self, email, password, **extra_fields): + extra_fields.setdefault('is_staff', True) + extra_fields.setdefault('is_superuser', True) + + if extra_fields.get('is_staff') is not True: + raise ValueError('Superuser must have is_staff=True.') + if extra_fields.get('is_superuser') is not True: + raise ValueError('Superuser must have is_superuser=True.') + + return self._create_user(email, password, **extra_fields) + + def get_or_create(self, defaults, **kwargs): + # Allow passing of 'full_name' but replace it with actual database fields + defaults = convert_full_name_to_parts(defaults) + return super().get_or_create(defaults=defaults, **kwargs) + + def get_or_create_and_notify(self, defaults=dict(), **kwargs): + defaults.update(is_active=False) + user, created = self.get_or_create(defaults=defaults, **kwargs) + if created: + send_activation_email(user) + return user, created class User(AbstractUser): - pass + USERNAME_FIELD = 'email' + email = models.EmailField(_('email address'), unique=True) + REQUIRED_FIELDS = [] + + # Remove the username field which is no longer used + username = None + + objects = UserManager() diff --git a/opentech/apply/users/templates/users/activation/email.txt b/opentech/apply/users/templates/users/activation/email.txt new file mode 100644 index 0000000000000000000000000000000000000000..3f01c1865629f7bbb1fa18737d892c97f93de5b3 --- /dev/null +++ b/opentech/apply/users/templates/users/activation/email.txt @@ -0,0 +1,20 @@ +{% load wagtailadmin_tags %}{% base_url_setting as base_url %} +Dear {{ name|default:username }}, + +An account on Open Technology Fund has been created. Activate your account by clicking this link or copying and pasting it to your browser: + +{% if base_url %}{{ base_url }}{% else %}{{ protocol }}://{{ domain }}{% endif %}{% url 'users:activate' uidb64=uid token=token %} + +This link can be used once to log in and will lead you to a page where you can set your password. + +After setting your password, you will be able to log in at {% if base_url %}{{ base_url }}{% else %}{{ protocol }}://{{ domain }}{% endif %} in the future using: + +username: {{ username }} +password: Your chosen password + +Thanks, +The OTF Team + +-- +Open Technology Fund +https://www.opentech.fund/ diff --git a/opentech/apply/users/templates/users/activation/email_subject.txt b/opentech/apply/users/templates/users/activation/email_subject.txt new file mode 100644 index 0000000000000000000000000000000000000000..1f1a8daafc3057f4227badf39bc239a6c131b73a --- /dev/null +++ b/opentech/apply/users/templates/users/activation/email_subject.txt @@ -0,0 +1 @@ +Account details for {{ username }} at Open Technology Fund diff --git a/opentech/apply/users/templates/users/activation/invalid.html b/opentech/apply/users/templates/users/activation/invalid.html new file mode 100644 index 0000000000000000000000000000000000000000..df12380a7ae5089993432b54a7a3201050afc583 --- /dev/null +++ b/opentech/apply/users/templates/users/activation/invalid.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} + +{% block title %}Invalid activation{% endblock %} + +{% block content %} + <h2>Invalid activation URL</h2> +{% endblock %} diff --git a/opentech/apply/users/templates/users/change_password.html b/opentech/apply/users/templates/users/change_password.html index 26799c69da341f713f390e9eb7139740fbbb8c1a..178f25355ec310ee23a30bab009fc1665c9e60ec 100644 --- a/opentech/apply/users/templates/users/change_password.html +++ b/opentech/apply/users/templates/users/change_password.html @@ -25,7 +25,7 @@ </div> {% endif %} - <form action="{% url 'users:password_change' %}" method="POST" novalidate> + <form action="" method="POST" novalidate> {% csrf_token %} {% for field in form %} diff --git a/opentech/apply/users/tests/test_oauth_access.py b/opentech/apply/users/tests/test_oauth_access.py index bfc57741741767c454e6231c1060e7fb352973d8..92d3f2ae25fa42123fb5d1530574e3604a1c2d72 100644 --- a/opentech/apply/users/tests/test_oauth_access.py +++ b/opentech/apply/users/tests/test_oauth_access.py @@ -5,6 +5,13 @@ from django.urls import reverse class TestOAuthAccess(TestCase): + def login(self): + email = 'test@email.com' + password = 'password' + user = get_user_model().objects.create_user(email=email, password=password) + logged_in = self.client.login(email=email, password=password) + self.assertTrue(logged_in) + return user def test_oauth_page_requires_login(self): """ @@ -48,11 +55,3 @@ class TestOAuthAccess(TestCase): self.assertNotContains(response, 'Disconnect Google OAuth') self.assertTemplateUsed(response, 'users/oauth.html') - - def login(self): - user = get_user_model().objects.create_user(username='test', email='test@email.com', password='password') - self.assertTrue( - self.client.login(username='test', password='password') - ) - - return user diff --git a/opentech/apply/users/urls.py b/opentech/apply/users/urls.py index 9ade202f71d16202266ca7d6085c4703b2f8fd92..877b4113b84a781a744b5f55215bdcd75159f982 100644 --- a/opentech/apply/users/urls.py +++ b/opentech/apply/users/urls.py @@ -2,7 +2,7 @@ from django.conf.urls import url from django.contrib.auth import views as auth_views from django.urls import reverse_lazy -from opentech.apply.users.views import account, oauth +from opentech.apply.users.views import account, oauth, ActivationView, create_password urlpatterns = [ url(r'^$', account, name='account'), @@ -54,5 +54,11 @@ urlpatterns = [ auth_views.PasswordResetCompleteView.as_view(template_name='users/password_reset/complete.html'), name='password_reset_complete' ), + url( + r'^activate/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', + ActivationView.as_view(), + name='activate' + ), + url(r'^activate/password/', create_password, name="activate_password"), url(r'^oauth$', oauth, name='oauth'), ] diff --git a/opentech/apply/users/utils.py b/opentech/apply/users/utils.py index 0e290b3203a99a774513b2f2dc5bd9134b4b3cfd..7f4ae6d4c233f1ed4b8b142dcc4c3f73bb3a5074 100644 --- a/opentech/apply/users/utils.py +++ b/opentech/apply/users/utils.py @@ -1,4 +1,8 @@ from django.conf import settings +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.template.loader import render_to_string +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode def can_use_oauth_check(user): @@ -14,3 +18,24 @@ def can_use_oauth_check(user): # Anonymous user or setting not defined pass return False + + +def send_activation_email(user): + """ + Send the activation email. The activation key is the username, + signed using TimestampSigner. + """ + token_generator = PasswordResetTokenGenerator() + context = { + 'user': user, + 'name': user.get_full_name(), + 'username': user.get_username(), + 'uid': urlsafe_base64_encode(force_bytes(user.pk)), + 'token': token_generator.make_token(user), + } + + subject = render_to_string('users/activation/email_subject.txt', context) + # Force subject to a single line to avoid header-injection issues. + subject = ''.join(subject.splitlines()) + message = render_to_string('users/activation/email.txt', context) + user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL) diff --git a/opentech/apply/users/views.py b/opentech/apply/users/views.py index c9bad14dfb283aa4c1c8caa5f2e769cf20708025..6ad1534218120026b627d9df57ef0e5b293fc4ea 100644 --- a/opentech/apply/users/views.py +++ b/opentech/apply/users/views.py @@ -1,7 +1,14 @@ +from django.contrib import messages +from django.contrib.auth import get_user_model, login, update_session_auth_hash from django.contrib.auth.decorators import login_required -from django.shortcuts import render +from django.contrib.auth.forms import AdminPasswordChangeForm +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.shortcuts import redirect, render from django.template.response import TemplateResponse from django.urls import reverse_lazy +from django.utils.encoding import force_text +from django.utils.http import urlsafe_base64_decode +from django.views.generic.base import TemplateView from wagtail.wagtailadmin.views.account import password_management_enabled @@ -10,7 +17,7 @@ from .decorators import require_oauth_whitelist @login_required(login_url=reverse_lazy('users:login')) def account(request): - "Account page placeholder view" + """Account page placeholder view""" return render(request, 'users/account.html', { 'show_change_password': password_management_enabled() and request.user.has_usable_password(), @@ -20,8 +27,68 @@ def account(request): @login_required(login_url=reverse_lazy('users:login')) @require_oauth_whitelist def oauth(request): + """Generic, empty view for the OAuth associations.""" + + return TemplateResponse(request, 'users/oauth.html', {}) + + +class ActivationView(TemplateView): + def get(self, request, *args, **kwargs): + user = self.get_user(kwargs.get('uidb64')) + + if self.valid(user, kwargs.get('token')): + user.is_active = True + user.save() + + user.backend = 'django.contrib.auth.backends.ModelBackend' + login(request, user) + return redirect('users:activate_password') + + return render(request, 'users/activation/invalid.html') + + def valid(self, user, token): + """ + Verify that the activation token is valid and within the + permitted activation time window. + """ + + token_generator = PasswordResetTokenGenerator() + return user is not None and token_generator.check_token(user, token) + + def get_user(self, uidb64): + """ + Given the verified uid, look up and return the + corresponding user account if it exists, or ``None`` if it + doesn't. + """ + User = get_user_model() + + try: + user = User.objects.get(**{ + 'pk': force_text(urlsafe_base64_decode(uidb64)), + 'is_active': False + }) + return user + except (TypeError, ValueError, OverflowError, User.DoesNotExist): + return None + + +def create_password(request): """ - Generic, empty view for the OAuth associations. + A custom view for the admin password change form used for account activation. """ - return TemplateResponse(request, 'users/oauth.html', {}) + if request.method == 'POST': + form = AdminPasswordChangeForm(request.user, request.POST) + if form.is_valid(): + user = form.save() + update_session_auth_hash(request, user) # Important! + messages.success(request, 'Your password was successfully updated!') + return redirect('users:account') + else: + messages.error(request, 'Please correct the errors below.') + else: + form = AdminPasswordChangeForm(request.user) + return render(request, 'users/change_password.html', { + 'form': form + }) diff --git a/opentech/settings/base.py b/opentech/settings/base.py index 299bbb8948dae30f06bc9db15cd8fb7fc37e93da..c4b14f5094d3e1845b9c434d1113cd8035c6c339 100644 --- a/opentech/settings/base.py +++ b/opentech/settings/base.py @@ -200,6 +200,8 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media') MEDIA_URL = '/media/' AUTH_USER_MODEL = 'users.User' + + # TODO populate me with the dashboard URL when ready LOGIN_URL = 'users:login' LOGIN_REDIRECT_URL = '/' diff --git a/requirements.txt b/requirements.txt index 1921779a9b06174f743dc5f66270d8a6abec7506..dd5f1a023bee983b59baf9266838c33505d375a4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,8 @@ uwsgidecorators==1.1.0 mypy==0.550 factory_boy==2.9.2 -wagtail_factories==0.3.0 +# wagtail_factories - waiting on merge and release form master branch +git+git://github.com/todd-dembrey/wagtail-factories.git#egg=wagtail_factories flake8