diff --git a/opentech/apply/categories/blocks.py b/opentech/apply/categories/blocks.py index e0c92bb60efdcaa50081e015a7c3c4c35853e9f4..5a0c5cf11c20bea90e7aa9c9e37a1178755059a7 100644 --- a/opentech/apply/categories/blocks.py +++ b/opentech/apply/categories/blocks.py @@ -21,6 +21,9 @@ class ModelChooserBlock(ChooserBlock): class CategoryQuestionBlock(OptionalFormFieldBlock): + class Meta: + template = 'stream_forms/render_list_field.html' + # Overwrite field label and help text so we can defer to the category # as required field_label = CharBlock( @@ -63,3 +66,12 @@ class CategoryQuestionBlock(OptionalFormFieldBlock): return forms.CheckboxSelectMultiple else: return forms.RadioSelect + + def render(self, value, context): + data = context['data'] + category = value['category'] + context['data'] = category.options.filter(id__in=data).values_list('value', flat=True) + return super().render(value, context) + + def get_searchable_content(self, value, data): + return None diff --git a/opentech/apply/dashboard/static/js/django_select2-checkboxes.js b/opentech/apply/dashboard/static/js/django_select2-checkboxes.js new file mode 100644 index 0000000000000000000000000000000000000000..a024d320e07b3437ce0572680ab76b26f97c5dc9 --- /dev/null +++ b/opentech/apply/dashboard/static/js/django_select2-checkboxes.js @@ -0,0 +1,23 @@ +(function ($) { + var init = function ($element) { + options = { + placeholder: $element.data('placeholder'), + templateSelection: function(selected, total) { + let filterType = this.placeholder; + if ( !selected.length ) { + return filterType; + } else if ( selected.length===total ) { + return 'All ' + filterType + ' selected'; + } + return selected.length + ' of ' + total + ' ' + filterType + ' selected'; + } + }; + $element.select2MultiCheckboxes(options); + }; + $(function () { + $('.django-select2-checkboxes').each(function (i, element) { + var $element = $(element); + init($element); + }); + }); +}(this.jQuery)); diff --git a/opentech/apply/dashboard/static/js/select2.multi-checkboxes.js b/opentech/apply/dashboard/static/js/select2.multi-checkboxes.js new file mode 100644 index 0000000000000000000000000000000000000000..ec6182afb5654175c7aeb51643c9329943f315fc --- /dev/null +++ b/opentech/apply/dashboard/static/js/select2.multi-checkboxes.js @@ -0,0 +1,79 @@ +/** + * jQuery Select2 Multi checkboxes + * - allow to select multi values via normal dropdown control + * + * author : wasikuss + * repo : https://github.com/wasikuss/select2-multi-checkboxes + * inspired by : https://github.com/select2/select2/issues/411 + * License : MIT + */ +(function($) { + var S2MultiCheckboxes = function(options, element) { + var self = this; + self.options = options; + self.$element = $(element); + var values = self.$element.val(); + self.$element.removeAttr('multiple'); + self.select2 = self.$element.select2({ + allowClear: true, + minimumResultsForSearch: options.minimumResultsForSearch, + placeholder: options.placeholder, + closeOnSelect: false, + templateSelection: function() { + return self.options.templateSelection(self.$element.val() || [], $('option', self.$element).length); + }, + templateResult: function(result) { + if (result.loading !== undefined) + return result.text; + return $('<div>').text(result.text).addClass(self.options.wrapClass); + }, + matcher: function(params, data) { + var original_matcher = $.fn.select2.defaults.defaults.matcher; + var result = original_matcher(params, data); + if (result && self.options.searchMatchOptGroups && data.children && result.children && data.children.length != result.children.length) { + result.children = data.children; + } + return result; + } + }).data('select2'); + self.select2.$results.off("mouseup").on("mouseup", ".select2-results__option[aria-selected]", (function(self) { + return function(evt) { + var $this = $(this); + + var data = $this.data('data'); + + if ($this.attr('aria-selected') === 'true') { + self.trigger('unselect', { + originalEvent: evt, + data: data + }); + return; + } + + self.trigger('select', { + originalEvent: evt, + data: data + }); + } + })(self.select2)); + self.$element.attr('multiple', 'multiple').val(values).trigger('change.select2'); + } + + $.fn.extend({ + select2MultiCheckboxes: function() { + var options = $.extend({ + placeholder: 'Choose elements', + templateSelection: function(selected, total) { + return selected.length + ' > ' + total + ' total'; + }, + wrapClass: 'wrap', + minimumResultsForSearch: -1, + searchMatchOptGroups: true + }, arguments[0]); + + this.each(function() { + new S2MultiCheckboxes(options, this); + }); + } + }); +})(jQuery); diff --git a/opentech/apply/dashboard/tables.py b/opentech/apply/dashboard/tables.py index 3ae000d46e2ae10636743f3e77b624528b0679cc..4e85da55a0e1612b90c0e19d342ae3dd93724e15 100644 --- a/opentech/apply/dashboard/tables.py +++ b/opentech/apply/dashboard/tables.py @@ -1,17 +1,78 @@ +from django import forms +from django.contrib.auth import get_user_model +from django.utils.text import mark_safe + +import django_filters as filters import django_tables2 as tables -from opentech.apply.funds.models import ApplicationSubmission +from django_tables2.utils import A + +from wagtail.wagtailcore.models import Page + +from opentech.apply.funds.models import ApplicationSubmission, Round +from opentech.apply.funds.workflow import status_options +from .widgets import Select2MultiCheckboxesWidget class DashboardTable(tables.Table): + title = tables.LinkColumn('funds:submission', args=[A('pk')], orderable=True) submit_time = tables.DateColumn(verbose_name="Submitted") status_name = tables.Column(verbose_name="Status") stage = tables.Column(verbose_name="Type") page = tables.Column(verbose_name="Fund") + lead = tables.Column(accessor='round.specific.lead', verbose_name='Lead') class Meta: model = ApplicationSubmission - fields = ('title', 'status_name', 'stage', 'page', 'round', 'submit_time', 'user') + fields = ('title', 'status_name', 'stage', 'page', 'round', 'submit_time') + sequence = ('title', 'status_name', 'stage', 'page', 'round', 'lead', 'submit_time') template = "dashboard/tables/table.html" def render_user(self, value): return value.get_full_name() + + def render_status_name(self, value): + return mark_safe(f'<span>{ value }</span>') + + +def get_used_rounds(request): + return Round.objects.filter(submissions__isnull=False).distinct() + + +def get_used_funds(request): + # Use page to pick up on both Labs and Funds + return Page.objects.filter(applicationsubmission__isnull=False).distinct() + + +def get_round_leads(request): + User = get_user_model() + return User.objects.filter(round__isnull=False).distinct() + + +class Select2CheckboxWidgetMixin: + def __init__(self, *args, **kwargs): + label = kwargs.get('label') + kwargs.setdefault('widget', Select2MultiCheckboxesWidget(attrs={'data-placeholder': label})) + super().__init__(*args, **kwargs) + + +class Select2MultipleChoiceFilter(Select2CheckboxWidgetMixin, filters.MultipleChoiceFilter): + pass + + +class Select2ModelMultipleChoiceFilter(Select2MultipleChoiceFilter, filters.ModelMultipleChoiceFilter): + pass + + +class SubmissionFilter(filters.FilterSet): + round = Select2ModelMultipleChoiceFilter(queryset=get_used_rounds, label='Rounds') + funds = Select2ModelMultipleChoiceFilter(name='page', queryset=get_used_funds, label='Funds') + status = Select2MultipleChoiceFilter(name='status__contains', choices=status_options, label='Statuses') + lead = Select2ModelMultipleChoiceFilter(name='round__round__lead', queryset=get_round_leads, label='Leads') + + class Meta: + model = ApplicationSubmission + fields = ('funds', 'round', 'status') + + +class SubmissionFilterAndSearch(SubmissionFilter): + query = filters.CharFilter(name='search_data', lookup_expr="search", widget=forms.HiddenInput) diff --git a/opentech/apply/dashboard/templates/dashboard/dashboard.html b/opentech/apply/dashboard/templates/dashboard/dashboard.html index ee2f7fc260f32525cd43019b34d9ff0487df6f5d..1a71bf96fe3b3dd7757793eb59c23fa84f1efbf2 100644 --- a/opentech/apply/dashboard/templates/dashboard/dashboard.html +++ b/opentech/apply/dashboard/templates/dashboard/dashboard.html @@ -1,20 +1,29 @@ {% extends "base-apply.html" %} {% load render_table from django_tables2 %} - -{% block extra_css %} - {# To remove after the demo and we have some style #} - <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.2.9/semantic.min.css"/> -{% endblock %} - +{% block title %}OTF Dashboard{% endblock %} {% block content %} <div class="wrapper wrapper--breakout wrapper--admin"> - <div class="wrapper wrapper--medium"> - <h3>Received Content</h3> - <h5>Track and explore recent submissions</h5> + <div class="wrapper wrapper--large"> + {% block page_header %} + <h3>Received Submissions</h3> + <h5>Track and explore recent submissions</h5> + {% endblock %} </div> + {% include "dashboard/includes/search.html" %} </div> <div class="wrapper wrapper--medium wrapper--top-bottom-inner-space"> - {% render_table object_list %} + {% if filter %} + <form action="" method="get" class="form form-inline"> + {{ filter.form.as_p }} + <button type="submit" value="Filter">Filter</button> + </form> + {% endif %} + {% render_table table %} </div> + +{% endblock %} + +{% block extra_js %} + {{ filter.form.media }} {% endblock %} diff --git a/opentech/apply/dashboard/templates/dashboard/includes/search.html b/opentech/apply/dashboard/templates/dashboard/includes/search.html new file mode 100644 index 0000000000000000000000000000000000000000..8797eff8dee3b0fb978c7ce731d76f8d2347d0d5 --- /dev/null +++ b/opentech/apply/dashboard/templates/dashboard/includes/search.html @@ -0,0 +1,6 @@ +<form action="{% url 'dashboard:search' %}" method="get" role="search" class="form form--header-search-desktop"> + <button class="button" type="submit" aria-label="Search"> + <svg class="icon icon--magnifying-glass icon--search"><use xlink:href="#magnifying-glass"></use></svg> + </button> + <input class="input input--transparent input--secondary" type="text" placeholder="Search…" name="query"{% if search_query %} value="{{ search_query }}{% endif %}" aria-label="Search input"> +</form> diff --git a/opentech/apply/dashboard/templates/dashboard/search.html b/opentech/apply/dashboard/templates/dashboard/search.html new file mode 100644 index 0000000000000000000000000000000000000000..bbf644442b04139ff5de5b02756e62d2c3a7915c --- /dev/null +++ b/opentech/apply/dashboard/templates/dashboard/search.html @@ -0,0 +1,10 @@ +{% extends "dashboard/dashboard.html" %} + +{% block page_header %} + <div class="wrapper wrapper--medium"> + <h3>Search Results</h3> + {% if search_term %} + <h5>There are {{ object_list|length }} results for: {{ search_term }}</h5> + {% endif %} + </div> +{% endblock %} diff --git a/opentech/apply/dashboard/templates/dashboard/tables/table.html b/opentech/apply/dashboard/templates/dashboard/tables/table.html index 6ff5e60321ee8084988b7726392a5c40e4ab3dcf..49e9c41a1f8093cebb452a68e321a11eb78d72aa 100644 --- a/opentech/apply/dashboard/templates/dashboard/tables/table.html +++ b/opentech/apply/dashboard/templates/dashboard/tables/table.html @@ -1,5 +1,4 @@ -{% extends 'django_tables2/semantic.html' %} -{# Change back to after demo: extends 'django_tables2/table.html' #} +{% extends 'django_tables2/table.html' %} {# example of how to extend the table template #} {% block table.tbody.row %} diff --git a/opentech/apply/dashboard/urls.py b/opentech/apply/dashboard/urls.py index b5bfdcafa3957fc4753d43a4d1055be469ff971c..07b7da83c765958c13cf34d29fa614abdfe84ab5 100644 --- a/opentech/apply/dashboard/urls.py +++ b/opentech/apply/dashboard/urls.py @@ -1,8 +1,9 @@ from django.conf.urls import url -from .views import DashboardView +from .views import DashboardView, SearchView urlpatterns = [ - url(r'^$', DashboardView.as_view(), name="dashboard") + url(r'^$', DashboardView.as_view(), name="dashboard"), + url(r'^search$', SearchView.as_view(), name="search"), ] diff --git a/opentech/apply/dashboard/views.py b/opentech/apply/dashboard/views.py index b86e281c466be79136ff303496990da608fd46c3..34d5deb34e4e54d724470ab27352d1ea3ecebb1b 100644 --- a/opentech/apply/dashboard/views.py +++ b/opentech/apply/dashboard/views.py @@ -1,17 +1,25 @@ -from django.views.generic import ListView -from django_tables2 import RequestConfig +from django_filters.views import FilterView +from django_tables2.views import SingleTableMixin from opentech.apply.funds.models import ApplicationSubmission -from .tables import DashboardTable +from .tables import DashboardTable, SubmissionFilter, SubmissionFilterAndSearch -class DashboardView(ListView): +class DashboardView(SingleTableMixin, FilterView): model = ApplicationSubmission template_name = 'dashboard/dashboard.html' + table_class = DashboardTable + + filterset_class = SubmissionFilter + + +class SearchView(SingleTableMixin, FilterView): + template_name = 'dashboard/search.html' + table_class = DashboardTable + + filterset_class = SubmissionFilterAndSearch def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['object_list'] = DashboardTable(context['object_list']) - RequestConfig(self.request).configure(context['object_list']) - return context + search_term = self.request.GET.get('query') + return super().get_context_data(search_term=search_term, **kwargs) diff --git a/opentech/apply/dashboard/widgets.py b/opentech/apply/dashboard/widgets.py new file mode 100644 index 0000000000000000000000000000000000000000..a053acfb0b28717708aee1375c0937d64eaf047c --- /dev/null +++ b/opentech/apply/dashboard/widgets.py @@ -0,0 +1,22 @@ +from django.contrib.staticfiles.templatetags.staticfiles import static + +from django_select2.forms import Select2MultipleWidget + + +class Select2MultiCheckboxesWidget(Select2MultipleWidget): + def __init__(self, *args, **kwargs): + attrs = kwargs.get('attrs', {}) + attrs.setdefault('data-placeholder', 'items') + kwargs['attrs'] = attrs + super().__init__(*args, **kwargs) + + def build_attrs(self, *args, **kwargs): + attrs = super().build_attrs(*args, **kwargs) + attrs['class'] = attrs['class'].replace('django-select2', 'django-select2-checkboxes') + return attrs + + @property + def media(self): + media = super().media + media.add_js([static('js/select2.multi-checkboxes.js'), static('js/django_select2-checkboxes.js')]) + return media diff --git a/opentech/apply/funds/blocks.py b/opentech/apply/funds/blocks.py index 770c321c26e15c2cea647c5a68b689fd2b68d87f..4a759e0ef7821d9eda48f0a9af9db5ba5fcd4386 100644 --- a/opentech/apply/funds/blocks.py +++ b/opentech/apply/funds/blocks.py @@ -1,12 +1,13 @@ from collections import Counter +import bleach from django import forms from django.core.exceptions import ValidationError from django.forms.utils import ErrorList from django.utils.translation import ugettext_lazy as _ from django.utils.text import mark_safe -from wagtail.wagtailcore.blocks import StaticBlock +from wagtail.wagtailcore.blocks import StaticBlock, StreamValue from tinymce.widgets import TinyMCE @@ -39,12 +40,28 @@ class RichTextFieldBlock(TextFieldBlock): widget = TinyMCE(mce_attrs={ 'elementpath': False, 'branding': False, + 'toolbar1': 'undo redo | styleselect | bold italic | bullist numlist | link', + 'style_formats': [ + {'title': 'Headers', 'items': [ + {'title': 'Header 1', 'format': 'h1'}, + {'title': 'Header 2', 'format': 'h2'}, + {'title': 'Header 3', 'format': 'h3'}, + ]}, + {'title': 'Inline', 'items': [ + {'title': 'Bold', 'icon': 'bold', 'format': 'bold'}, + {'title': 'Italic', 'icon': 'italic', 'format': 'italic'}, + {'title': 'Underline', 'icon': 'underline', 'format': 'underline'}, + ]}, + ], }) class Meta: label = _('Rich text field') icon = 'form' + def get_searchable_content(self, value, data): + return bleach.clean(data, tags=[], strip=True) + class CustomFormFieldsBlock(FormFieldsBlock): rich_text = RichTextFieldBlock(group=_('Fields')) @@ -99,6 +116,14 @@ class CustomFormFieldsBlock(FormFieldsBlock): [ValidationError('Error', params={field: new_error})] ) + def to_python(self, value): + # If the data type is missing, fallback to a CharField + for child_data in value: + if child_data['type'] not in self.child_blocks: + child_data['type'] = 'char' + + return StreamValue(self, value, is_lazy=True) + class MustIncludeStatic(StaticBlock): """Helper block which displays additional information about the must include block and diff --git a/opentech/apply/funds/migrations/0022_applicationsubmission_form_fields.py b/opentech/apply/funds/migrations/0022_applicationsubmission_form_fields.py new file mode 100644 index 0000000000000000000000000000000000000000..be99a13260756412bada68dbf73a2b5a5ab175e5 --- /dev/null +++ b/opentech/apply/funds/migrations/0022_applicationsubmission_form_fields.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.8 on 2018-02-13 15:10 +from __future__ import unicode_literals + +from django.db import migrations +import opentech.apply.categories.blocks +import wagtail.wagtailcore.blocks +import wagtail.wagtailcore.blocks.static_block +import wagtail.wagtailcore.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('funds', '0021_rename_workflow_field'), + ] + + operations = [ + migrations.AddField( + model_name='applicationsubmission', + 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')), ('address', 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'))), default=[]), + preserve_default=False, + ), + ] diff --git a/opentech/apply/funds/migrations/0023_round_lead.py b/opentech/apply/funds/migrations/0023_round_lead.py new file mode 100644 index 0000000000000000000000000000000000000000..df3a73882afa0a1f4fa94d1c1d67d8d52af6a839 --- /dev/null +++ b/opentech/apply/funds/migrations/0023_round_lead.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.8 on 2018-02-14 17:21 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('funds', '0022_applicationsubmission_form_fields'), + ] + + operations = [ + migrations.AddField( + model_name='round', + name='lead', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + ] diff --git a/opentech/apply/funds/migrations/0024_applicationsubmission_search_data.py b/opentech/apply/funds/migrations/0024_applicationsubmission_search_data.py new file mode 100644 index 0000000000000000000000000000000000000000..97dc908a30de6f1bb1d71609d4d66dfb6ab262f8 --- /dev/null +++ b/opentech/apply/funds/migrations/0024_applicationsubmission_search_data.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.8 on 2018-02-16 16:29 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('funds', '0023_round_lead'), + ] + + operations = [ + migrations.AddField( + model_name='applicationsubmission', + name='search_data', + field=models.TextField(default=''), + preserve_default=False, + ), + ] diff --git a/opentech/apply/funds/migrations/0025_update_with_file_blocks.py b/opentech/apply/funds/migrations/0025_update_with_file_blocks.py new file mode 100644 index 0000000000000000000000000000000000000000..6b836eaa40822d8465dfa54c6c62da8b12424858 --- /dev/null +++ b/opentech/apply/funds/migrations/0025_update_with_file_blocks.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.8 on 2018-02-21 15:14 +from __future__ import unicode_literals + +from django.db import migrations +import opentech.apply.categories.blocks +import wagtail.wagtailcore.blocks +import wagtail.wagtailcore.blocks.static_block +import wagtail.wagtailcore.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('funds', '0024_applicationsubmission_search_data'), + ] + + operations = [ + 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')), ('multi_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')), ('address', 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')))), + ), + migrations.AlterField( + model_name='applicationsubmission', + 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')), ('multi_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')), ('address', 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 7a1ec0461e3d517d13358d44d33c28a5faf43590..eb486c5799942b755e2dd64aebb933ed978fde66 100644 --- a/opentech/apply/funds/models.py +++ b/opentech/apply/funds/models.py @@ -1,9 +1,11 @@ from datetime import date +import os 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.files.storage import default_storage from django.core.serializers.json import DjangoJSONEncoder from django.db import models from django.db.models import Q @@ -30,7 +32,9 @@ from wagtail.wagtailcore.fields import StreamField from wagtail.wagtailcore.models import Orderable from wagtail.wagtailforms.models import AbstractEmailForm, AbstractFormSubmission +from opentech.apply.stream_forms.blocks import FileFieldBlock from opentech.apply.stream_forms.models import AbstractStreamForm +from opentech.apply.users.groups import STAFF_GROUP_NAME from .blocks import CustomFormFieldsBlock, MustIncludeFieldBlock, REQUIRED_BLOCK_NAMES from .edit_handlers import FilteredFieldPanel, ReadOnlyPanel, ReadOnlyInlinePanel @@ -59,32 +63,12 @@ class SubmittableStreamForm(AbstractStreamForm): return ApplicationSubmission def process_form_submission(self, form): - cleaned_data = form.cleaned_data - for field in self.get_defined_fields(): - # Update the ids which are unique to use the unique name - if isinstance(field.block, MustIncludeFieldBlock): - 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: - # Rely on the form having the following must include fields (see blocks.py) - email = cleaned_data.get('email') - full_name = cleaned_data.get('full_name') - - User = get_user_model() - user, _ = User.objects.get_or_create_and_notify( - email=email, - site=self.get_site(), - defaults={'full_name': full_name} - ) - + if not form.user.is_authenticated(): + form.user = None return self.get_submission_class().objects.create( - form_data=cleaned_data, - **self.get_submit_meta_data(user=user), + form_data=form.cleaned_data, + form_fields=self.get_defined_fields(), + **self.get_submit_meta_data(user=form.user), ) def get_submit_meta_data(self, **kwargs): @@ -150,22 +134,21 @@ class EmailForm(AbstractEmailForm): def process_form_submission(self, form): submission = super().process_form_submission(form) - self.send_mail(form) + self.send_mail(submission) return submission - def send_mail(self, form): - data = form.cleaned_data - email = data.get('email') + def send_mail(self, submission): + user = submission.user context = { - 'name': data.get('full_name'), - 'email': email, - 'project_name': data.get('title'), + 'name': user.get_full_name(), + 'email': user.email, + 'project_name': submission.form_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, ) + send_mail(subject, render_to_string('funds/email/confirmation.txt', context), (user.email,), self.from_address, ) email_confirmation_panels = [ MultiFieldPanel( @@ -207,7 +190,11 @@ class FundType(EmailForm, WorkflowStreamForm): # type: ignore ).first() def next_deadline(self): - return self.open_round.end_date + try: + return self.open_round.end_date + except AttributeError: + # There isn't an open round + return None def serve(self, request): if hasattr(request, 'is_preview') or not self.open_round: @@ -273,6 +260,7 @@ class Round(WorkflowStreamForm, SubmittableStreamForm): # type: ignore parent_page_types = ['funds.FundType'] subpage_types = [] # type: ignore + lead = models.ForeignKey(settings.AUTH_USER_MODEL, limit_choices_to={'groups__name': STAFF_GROUP_NAME}) start_date = models.DateField(default=date.today) end_date = models.DateField( blank=True, @@ -282,6 +270,7 @@ class Round(WorkflowStreamForm, SubmittableStreamForm): # type: ignore ) content_panels = SubmittableStreamForm.content_panels + [ + FieldPanel('lead'), MultiFieldPanel([ FieldRowPanel([ FieldPanel('start_date'), @@ -330,7 +319,7 @@ class Round(WorkflowStreamForm, SubmittableStreamForm): # type: ignore def process_form_submission(self, form): submission = super().process_form_submission(form) - self.get_parent().specific.send_mail(form) + self.get_parent().specific.send_mail(submission) return submission def clean(self): @@ -439,10 +428,14 @@ class JSONOrderable(models.QuerySet): class ApplicationSubmission(WorkflowHelpers, AbstractFormSubmission): + field_template = 'funds/includes/submission_field.html' + form_data = JSONField(encoder=DjangoJSONEncoder) + form_fields = StreamField(CustomFormFieldsBlock()) page = models.ForeignKey('wagtailcore.Page', on_delete=models.PROTECT) round = models.ForeignKey('wagtailcore.Page', on_delete=models.PROTECT, related_name='submissions', null=True) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + search_data = models.TextField() # Workflow inherited from WorkflowHelpers status = models.CharField(max_length=254) @@ -461,7 +454,55 @@ class ApplicationSubmission(WorkflowHelpers, AbstractFormSubmission): def phase(self): return self.workflow.current(self.status) + def ensure_user_has_account(self): + if self.user and self.user.is_authenticated(): + self.form_data['email'] = self.user.email + self.form_data['full_name'] = self.user.get_full_name() + else: + # Rely on the form having the following must include fields (see blocks.py) + email = self.form_data.get('email') + full_name = self.form_data.get('full_name') + + User = get_user_model() + self.user, _ = User.objects.get_or_create_and_notify( + email=email, + site=self.page.get_site(), + defaults={'full_name': full_name} + ) + + def handle_file(self, file): + # File is potentially optional + if file: + file_path = os.path.join('submissions', 'user', str(self.user.id), file.name) + filename = default_storage.generate_filename(file_path) + saved_name = default_storage.save(filename, file) + return { + 'name': file.name, + 'path': saved_name, + 'url': default_storage.url(saved_name) + } + + def handle_files(self, files): + if isinstance(files, list): + return [self.handle_file(file) for file in files] + + return self.handle_file(files) + def save(self, *args, **kwargs): + for field in self.form_fields: + # Update the ids which are unique to use the unique name + if isinstance(field.block, MustIncludeFieldBlock): + response = self.form_data.pop(field.id, None) + if response: + self.form_data[field.block.name] = response + + self.ensure_user_has_account() + + for field in self.form_fields: + if isinstance(field.block, FileFieldBlock): + file = self.form_data[field.id] + self.form_data[field.id] = self.handle_files(file) + if not self.id: # We are creating the object default to first stage try: @@ -471,8 +512,40 @@ class ApplicationSubmission(WorkflowHelpers, AbstractFormSubmission): self.workflow_name = self.page.workflow_name self.status = str(self.workflow.first()) + # add a denormed version of the answer for searching + self.search_data = ' '.join(self.prepare_search_values()) + return super().save(*args, **kwargs) + def data_and_fields(self): + for stream_value in self.form_fields: + try: + data = self.form_data[stream_value.id] + except KeyError: + pass # It was a named field or a paragraph + else: + yield data, stream_value + + def render_answers(self): + fields = [ + field.render(context={'data': data}) + for data, field in self.data_and_fields() + ] + return mark_safe(''.join(fields)) + + def prepare_search_values(self): + for data, stream in self.data_and_fields(): + value = stream.block.get_searchable_content(stream.value, data) + if value: + if isinstance(value, list): + yield ', '.join(value) + else: + yield value + + # Add named fields into the search index + for field in ['email', 'title']: + yield getattr(self, field) + def get_data(self): # Updated for JSONField form_data = self.form_data diff --git a/opentech/apply/funds/templates/funds/applicationsubmission_detail.html b/opentech/apply/funds/templates/funds/applicationsubmission_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..a733b9e6d3b36c9fac9e8bf21ff325cabaca2722 --- /dev/null +++ b/opentech/apply/funds/templates/funds/applicationsubmission_detail.html @@ -0,0 +1,35 @@ +{% extends "base-apply.html" %} + +{% block content %} +<div class="wrapper wrapper--breakout wrapper--admin"> + <div class="wrapper wrapper--medium"> + <h3>{{ object.title }}</h3> + <h5>{{ object.stage }} | {{ object.page }} | {{ object.round }} | Lead: {{ object.round.specific.lead }}</h5> + </div> + + {% include "funds/includes/status_bar.html" with workflow=object.workflow status=object.status %} + +</div> + +<div>Submission Details</div> + +<div class="wrapper wrapper--medium wrapper--top-bottom-inner-space"> + Submitted: {{ object.submit_time.date }} by {{ object.user.get_full_name }} + <h2>Proposal Information</h2> + <div> + Requested Funding {{ object.value }} + Project Duration {{ object.value }} + Legal Name {{ object.full_name }} + Email {{ object.email }} + </div> + <div> + {{ object.render_answers }} + </div> +</div> +<div> + <h3>Other Submissions</h3> + {% for submission in other_submissions %} + <a href="{% url 'funds:submission' submission.id %}">{{ submission.title }}</a> + {% endfor %} +</div> +{% endblock %} diff --git a/opentech/apply/funds/templates/funds/demo_workflow.html b/opentech/apply/funds/templates/funds/demo_workflow.html index 0187115e51dd9930bb31df9825de4903e4f4d906..f7b514e17b36e4f2fec14260959624d9969b66b0 100644 --- a/opentech/apply/funds/templates/funds/demo_workflow.html +++ b/opentech/apply/funds/templates/funds/demo_workflow.html @@ -7,8 +7,8 @@ <main class="wrapper"> <nav> <section class="container"> - <a class="button button-clear" href="{% url 'workflow_demo' 1 %}">Single Stage</a> - <a class="button button-clear" href="{% url 'workflow_demo' 2 %}">Double Stage</a> + <a class="button button-clear" href="{% url 'funds:workflow_demo' 1 %}">Single Stage</a> + <a class="button button-clear" href="{% url 'funds:workflow_demo' 2 %}">Double Stage</a> </section> </nav> <section class="container"> diff --git a/opentech/apply/funds/templates/funds/fund_type.html b/opentech/apply/funds/templates/funds/fund_type.html index 075599e6ce86acc0c96fb2c5daf6953703fac075..1415fd1ebce20830e880cabed094c9168bc0e4e0 100644 --- a/opentech/apply/funds/templates/funds/fund_type.html +++ b/opentech/apply/funds/templates/funds/fund_type.html @@ -26,7 +26,7 @@ {# the fund has no open rounds and we arent on a round page #} <h3>{% trans "Sorry this fund is not accepting applications at the moment" %}</h3> {% else%} - <form class="form" action="" method="POST"> + <form class="form" action="" method="POST" enctype="multipart/form-data"> {{ form.media }} {% csrf_token %} diff --git a/opentech/apply/funds/templates/funds/includes/field.html b/opentech/apply/funds/templates/funds/includes/field.html index 935b4cef3efb5ecea41a7c678fbaa1204419527d..c2b2dca8b13a0992889b7498fcaa8c813e4af5c6 100644 --- a/opentech/apply/funds/templates/funds/includes/field.html +++ b/opentech/apply/funds/templates/funds/includes/field.html @@ -3,7 +3,7 @@ {% with widget_type=field|widget_type field_type=field|field_type %} <div class="form__group {% if field.errors %}form__error{% endif %}"> - {% if widget_type == 'clearable_file_input' %} + {% if widget_type == 'clearable_file_input' or widget_type == 'multi_file_input' %} <span class="form__question">{{ field.label }}</span> <label for="{{ field.id_for_label }}" class="form__question form__question--{{ field_type }} {{ widget_type }}" {% if field.field.required %}required{% endif %}> <span>Upload</span> diff --git a/opentech/apply/funds/templates/funds/includes/status_bar.html b/opentech/apply/funds/templates/funds/includes/status_bar.html new file mode 100644 index 0000000000000000000000000000000000000000..04d26c7bfc1cbd02dd643a1054a8fdd8b20e083e --- /dev/null +++ b/opentech/apply/funds/templates/funds/includes/status_bar.html @@ -0,0 +1,5 @@ +{% for phase in workflow %} +<div class="{% if phase == status %}current{% endif %}" style="{% if phase == status %}font-weight:bold;{% endif %}"> + {{ phase.name }} +</div> +{% endfor %} diff --git a/opentech/apply/funds/tests/factories/blocks.py b/opentech/apply/funds/tests/factories/blocks.py index e568c5c3d3958aaa0c6a715745f1c84f86c180a2..4d5863edce829d2b843f68cf2c600f724eaabfbc 100644 --- a/opentech/apply/funds/tests/factories/blocks.py +++ b/opentech/apply/funds/tests/factories/blocks.py @@ -1,15 +1,46 @@ +import json +import uuid + +from wagtail.wagtailcore.blocks import CharBlock import wagtail_factories -from opentech.apply.stream_forms.blocks import FormFieldBlock +from opentech.apply.stream_forms import blocks as stream_blocks from opentech.apply.funds import blocks -__all__ = ['CustomFormFieldsFactory', 'FormFieldBlock', 'FullNameBlockFactory', 'EmailBlockFactory'] +__all__ = ['CustomFormFieldsFactory', 'FormFieldBlockFactory', 'FullNameBlockFactory', 'EmailBlockFactory'] + + +class CharBlockFactory(wagtail_factories.blocks.BlockFactory): + class Meta: + model = CharBlock class FormFieldBlockFactory(wagtail_factories.StructBlockFactory): class Meta: - model = FormFieldBlock + model = stream_blocks.FormFieldBlock + + +class CharFieldBlockFactory(FormFieldBlockFactory): + class Meta: + model = stream_blocks.CharFieldBlock + + +class NumberFieldBlockFactory(FormFieldBlockFactory): + class Meta: + model = stream_blocks.NumberFieldBlock + + +class RadioFieldBlockFactory(FormFieldBlockFactory): + choices = wagtail_factories.ListBlockFactory(CharBlockFactory) + + class Meta: + model = stream_blocks.RadioButtonsFieldBlock + + +class TitleBlockFactory(FormFieldBlockFactory): + class Meta: + model = blocks.TitleBlock class EmailBlockFactory(FormFieldBlockFactory): @@ -22,7 +53,29 @@ class FullNameBlockFactory(FormFieldBlockFactory): model = blocks.FullNameBlock -CustomFormFieldsFactory = wagtail_factories.StreamFieldFactory({ +class RichTextFieldBlockFactory(FormFieldBlockFactory): + class Meta: + model = blocks.RichTextFieldBlock + + +class StreamFieldUUIDFactory(wagtail_factories.StreamFieldFactory): + def generate(self, *args, **kwargs): + blocks = super().generate(*args, **kwargs) + ret_val = list() + # Convert to JSON so we can add id before create + for block_name, value in blocks: + block = self.factories[block_name]._meta.model() + value = block.get_prep_value(value) + ret_val.append({'type': block_name, 'value': value, 'id': str(uuid.uuid4())}) + return json.dumps(ret_val) + + +CustomFormFieldsFactory = StreamFieldUUIDFactory({ + 'title': TitleBlockFactory, 'email': EmailBlockFactory, 'full_name': FullNameBlockFactory, + 'char': CharFieldBlockFactory, + 'number': NumberFieldBlockFactory, + 'radios': RadioFieldBlockFactory, + 'rich_text': RichTextFieldBlockFactory, }) diff --git a/opentech/apply/funds/tests/factories/models.py b/opentech/apply/funds/tests/factories/models.py index 116b07094810dbda0b7bf52cc8cf1aff315337c1..5fada182149ec8ec79849618c28f25efe78bc8ae 100644 --- a/opentech/apply/funds/tests/factories/models.py +++ b/opentech/apply/funds/tests/factories/models.py @@ -1,10 +1,13 @@ +from collections import defaultdict import datetime +import json import factory import wagtail_factories from opentech.apply.funds.models import ( AbstractRelatedForm, + ApplicationSubmission, ApplicationForm, FundType, FundForm, @@ -13,6 +16,8 @@ from opentech.apply.funds.models import ( Round, RoundForm, ) +from opentech.apply.users.tests.factories import UserFactory +from opentech.apply.users.groups import STAFF_GROUP_NAME from . import blocks @@ -21,6 +26,7 @@ __all__ = [ 'FundTypeFactory', 'FundFormFactory', 'ApplicationFormFactory', + 'ApplicationSubmissionFactory', 'RoundFactory', 'RoundFormFactory', 'LabFactory', @@ -28,6 +34,25 @@ __all__ = [ ] +def build_form(data, prefix=''): + if prefix: + prefix += '__' + + extras = defaultdict(dict) + for key, value in data.items(): + if 'form_fields' in key: + _, field, attr = key.split('__') + extras[field][attr] = value + + form_fields = {} + for i, field in enumerate(blocks.CustomFormFieldsFactory.factories.keys()): + form_fields[f'{prefix}form_fields__{i}__{field}__'] = '' + for attr, value in extras[field].items(): + form_fields[f'{prefix}form_fields__{i}__{field}__{attr}'] = value + + return form_fields + + class FundTypeFactory(wagtail_factories.PageFactory): class Meta: model = FundType @@ -41,11 +66,7 @@ class FundTypeFactory(wagtail_factories.PageFactory): @factory.post_generation def forms(self, create, extracted, **kwargs): if create: - fields = { - f'form__form_fields__{i}__{field}__': '' - for i, field in enumerate(blocks.CustomFormFieldsFactory.factories.keys()) - } - fields.update(**kwargs) + fields = build_form(kwargs, prefix='form') for _ in range(len(self.workflow_class.stage_classes)): # Generate a form based on all defined fields on the model FundFormFactory( @@ -82,6 +103,7 @@ 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)) + lead = factory.SubFactory(UserFactory, groups__name=STAFF_GROUP_NAME) class RoundFormFactory(AbstractRelatedFormFactory): @@ -106,3 +128,33 @@ class LabFormFactory(AbstractRelatedFormFactory): class Meta: model = LabForm lab = factory.SubFactory(LabFactory, parent=None) + + +class FormDataFactory(factory.Factory): + def _create(self, *args, form_fields='{}', **kwargs): + form_fields = json.loads(form_fields) + form_data = {} + for field in form_fields: + try: + answer = kwargs[field['type']] + except KeyError: + answer = 'the answer' + form_data[field['id']] = answer + + return form_data + + +class ApplicationSubmissionFactory(factory.DjangoModelFactory): + class Meta: + model = ApplicationSubmission + + form_fields = blocks.CustomFormFieldsFactory + form_data = factory.SubFactory(FormDataFactory, form_fields=factory.SelfAttribute('..form_fields')) + page = factory.SubFactory(FundTypeFactory) + round = factory.SubFactory(RoundFactory) + user = factory.SubFactory(UserFactory) + + @classmethod + def _generate(cls, strat, params): + params.update(**build_form(params)) + return super()._generate(strat, params) diff --git a/opentech/apply/funds/tests/test_models.py b/opentech/apply/funds/tests/test_models.py index 86c173891275de97020ffb14b3543f80f4fb73e3..487526f6233e910edbd19e1247884f27eb5d4738 100644 --- a/opentech/apply/funds/tests/test_models.py +++ b/opentech/apply/funds/tests/test_models.py @@ -14,6 +14,7 @@ from opentech.apply.funds.workflow import SingleStage from .factories import ( ApplicationFormFactory, + ApplicationSubmissionFactory, CustomFormFieldsFactory, FundTypeFactory, LabFactory, @@ -69,6 +70,9 @@ class TestFundModel(TestCase): new_round.save() self.assertEqual(self.fund.open_round, None) + def test_no_round_exists(self): + self.assertIsNone(self.fund.next_deadline()) + class TestRoundModelDates(TestCase): def setUp(self): @@ -147,6 +151,7 @@ class TestRoundModelWorkflowAndForms(TestCase): self.round = RoundFactory.build() self.round.parent_page = self.fund + self.round.lead = RoundFactory.lead.get_factory()(**RoundFactory.lead.defaults) self.fund.add_child(instance=self.round) @@ -185,6 +190,7 @@ class TestFormSubmission(TestCase): application_form = { 'form_fields__0__email__': '', 'form_fields__1__full_name__': '', + 'form_fields__2__title__': '', } form = ApplicationFormFactory(**application_form) fund = FundTypeFactory() @@ -205,7 +211,7 @@ class TestFormSubmission(TestCase): page = page or self.round_page fields = page.get_form_fields() - data = {k: v for k, v in zip(fields, [email, name])} + data = {k: v for k, v in zip(fields, [email, name, 'project'])} request = self.request_factory.post('', data) request.user = user @@ -235,7 +241,8 @@ class TestFormSubmission(TestCase): def test_can_submit_if_new(self): self.submit_form() - self.assertEqual(self.User.objects.count(), 1) + # Lead + applicant + self.assertEqual(self.User.objects.count(), 2) new_user = self.User.objects.get(email=self.email) self.assertEqual(new_user.get_full_name(), self.name) @@ -246,7 +253,8 @@ class TestFormSubmission(TestCase): self.submit_form() self.submit_form() - self.assertEqual(self.User.objects.count(), 1) + # Lead + applicant + self.assertEqual(self.User.objects.count(), 2) user = self.User.objects.get(email=self.email) self.assertEqual(ApplicationSubmission.objects.count(), 2) @@ -257,9 +265,10 @@ class TestFormSubmission(TestCase): # Someone else submits a form self.submit_form(email='another@email.com') - self.assertEqual(self.User.objects.count(), 2) + # Lead + 2 x applicant + self.assertEqual(self.User.objects.count(), 3) - first_user, second_user = self.User.objects.all() + _, 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) @@ -267,11 +276,13 @@ class TestFormSubmission(TestCase): 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) + # Lead + Applicant + self.assertEqual(self.User.objects.count(), 2) self.submit_form(email=self.email, name=self.name, user=user) - self.assertEqual(self.User.objects.count(), 1) + # Lead + Applicant + self.assertEqual(self.User.objects.count(), 2) self.assertEqual(ApplicationSubmission.objects.count(), 1) self.assertEqual(ApplicationSubmission.objects.first().user, user) @@ -280,12 +291,14 @@ class TestFormSubmission(TestCase): 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) + # Lead + applicant + self.assertEqual(self.User.objects.count(), 2) response = self.submit_form(email='', name='', user=user) self.assertContains(response, 'This field is required') - self.assertEqual(self.User.objects.count(), 1) + # Lead + applicant + self.assertEqual(self.User.objects.count(), 2) self.assertEqual(ApplicationSubmission.objects.count(), 0) @@ -300,3 +313,56 @@ class TestFormSubmission(TestCase): # "Thank you for your submission" and "Account Creation" self.assertEqual(len(mail.outbox), 2) self.assertEqual(mail.outbox[0].to[0], self.email) + + +class TestApplicationSubmission(TestCase): + def make_submission(self, **kwargs): + return ApplicationSubmissionFactory(**kwargs) + + def test_can_get_required_block_names(self): + email = 'test@test.com' + submission = self.make_submission(user__email=email) + self.assertEqual(submission.email, email) + + def test_can_get_ordered_qs(self): + # Emails are created sequentially + submission_a = self.make_submission() + submission_b = self.make_submission(round=submission_a.round) + submissions = [submission_a, submission_b] + self.assertEqual( + list(ApplicationSubmission.objects.order_by('email')), + submissions, + ) + + def test_can_get_reverse_ordered_qs(self): + submission_a = self.make_submission() + submission_b = self.make_submission(round=submission_a.round) + submissions = [submission_b, submission_a] + self.assertEqual( + list(ApplicationSubmission.objects.order_by('-email')), + submissions, + ) + + def test_richtext_in_char_is_removed_for_search(self): + text = 'I am text' + rich_text = f'<b>{text}</b>' + submission = self.make_submission(form_data__char=rich_text) + self.assertNotIn(rich_text, submission.search_data) + self.assertIn(text, submission.search_data) + + def test_richtext_is_removed_for_search(self): + text = 'I am text' + rich_text = f'<b>{text}</b>' + submission = self.make_submission(form_data__rich_text=rich_text) + self.assertNotIn(rich_text, submission.search_data) + self.assertIn(text, submission.search_data) + + def test_choices_added_for_search(self): + choices = ['blah', 'foo'] + submission = self.make_submission(form_fields__radios__choices=choices, form_data__radios=['blah']) + self.assertIn('blah', submission.search_data) + + def test_number_not_in_search(self): + value = 12345 + submission = self.make_submission(form_data__number=value) + self.assertNotIn(str(value), submission.search_data) diff --git a/opentech/apply/funds/urls.py b/opentech/apply/funds/urls.py index 9d1bd77962f3ed7588235332bf0f14ffedc8e163..8df134af8e039de324dab47e08f81b181d61e905 100644 --- a/opentech/apply/funds/urls.py +++ b/opentech/apply/funds/urls.py @@ -1,7 +1,9 @@ from django.conf.urls import url -from .views import demo_workflow +from .views import SubmissionDetailView, demo_workflow + urlpatterns = [ - url(r'^demo/(?P<wf_id>[1-2])/$', demo_workflow, name="workflow_demo") + url(r'^demo/(?P<wf_id>[1-2])/$', demo_workflow, name="workflow_demo"), + url(r'^submission/(?P<pk>\d+)/$', SubmissionDetailView.as_view(), name="submission"), ] diff --git a/opentech/apply/funds/views.py b/opentech/apply/funds/views.py index bb3b10454f1c82a47bebcdcc1b0c48ab2f339241..9dbb30a19f7c0a8b5027e09ba3a7431f3a3a8e31 100644 --- a/opentech/apply/funds/views.py +++ b/opentech/apply/funds/views.py @@ -1,9 +1,21 @@ from django import forms from django.template.response import TemplateResponse +from django.views.generic import DetailView +from .models import ApplicationSubmission from .workflow import SingleStage, DoubleStage +class SubmissionDetailView(DetailView): + model = ApplicationSubmission + + def get_context_data(self, **kwargs): + return super().get_context_data( + other_submissions=self.model.objects.filter(user=self.object.user).exclude(id=self.object.id), + **kwargs + ) + + workflows = [SingleStage, DoubleStage] diff --git a/opentech/apply/funds/workflow.py b/opentech/apply/funds/workflow.py index 6659d734203f337f4ec92059af4a7a0a5810a934..b356dd158876933ec3ab865c374fea8839389a40 100644 --- a/opentech/apply/funds/workflow.py +++ b/opentech/apply/funds/workflow.py @@ -1,6 +1,6 @@ import copy -from typing import List, Sequence, Type, Union +from typing import Iterable, Iterator, List, Sequence, Type, Union from django.forms import Form from django.utils.text import slugify @@ -22,7 +22,7 @@ def phase_name(stage: 'Stage', phase: Union['Phase', str], occurrence: int) -> s return '__'.join([stage.name, phase_name, str(occurrence)]) -class Workflow: +class Workflow(Iterable): """ A Workflow is a collection of Stages an application goes through. When a Stage is complete, it will return the next Stage in the list or `None` if no such Stage exists. @@ -36,6 +36,10 @@ class Workflow: self.stages = [stage(form) for stage, form in zip(self.stage_classes, forms)] + def __iter__(self) -> Iterator['Phase']: + for stage in self.stages: + yield from stage + def current(self, current_phase: Union[str, 'Phase']) -> Union['Phase', None]: if isinstance(current_phase, Phase): return current_phase @@ -94,7 +98,7 @@ class Workflow: return self.name -class Stage: +class Stage(Iterable): """ Holds the Phases that are progressed through as part of the workflow process """ @@ -120,6 +124,9 @@ class Stage: new_phases.append(copy.copy(phase)) self.phases = new_phases + def __iter__(self) -> Iterator['Phase']: + yield from self.phases + def __str__(self) -> str: return self.name @@ -168,7 +175,9 @@ class Phase: self._actions = {action.name: action for action in self.actions} self.occurrence: int = 0 - def __eq__(self, other: object) -> bool: + def __eq__(self, other: Union[object, str]) -> bool: + if isinstance(other, str): + return str(self) == other to_match = ['name', 'occurrence'] return all(getattr(self, attr) == getattr(other, attr) for attr in to_match) @@ -302,3 +311,7 @@ class SingleStage(Workflow): class DoubleStage(Workflow): name = 'Two Stage' stage_classes = [ConceptStage, ProposalStage] + + +statuses = set(phase.name for phase in Phase.__subclasses__()) +status_options = [(slugify(opt), opt) for opt in statuses] diff --git a/opentech/apply/home/templates/apply_home/apply_home_page.html b/opentech/apply/home/templates/apply_home/apply_home_page.html index 455a52f6c236b9b0045267e248b2d2815f0cbb7f..e1a67fcb97c62e0c0bc9edf7f7de4302bc14e70d 100644 --- a/opentech/apply/home/templates/apply_home/apply_home_page.html +++ b/opentech/apply/home/templates/apply_home/apply_home_page.html @@ -6,15 +6,23 @@ {% block header_modifier %}header--light-bg{% endblock %} {% block content %} -<div class="wrapper wrapper--small wrapper--top-bottom-inner-space"> +<div class="wrapper wrapper--small"> {% if page.strapline %} - <h4 class="heading heading--listings-introduction">{{ page.strapline }}</h4> + <h4 class="heading heading--regular">{{ page.strapline }}</h4> {% endif %} + <div class="wrapper wrapper--breakout"> + <svg class="icon icon--body-pixels-right"><use xlink:href="#body-pixels-right"></use></svg> + </div> + <div class="wrapper wrapper--listings"> {% for child_page in page.get_children.public.live %} {% include "apply_home/includes/apply_listing.html" with page=child_page %} {% endfor %} </div> + + <div class="wrapper wrapper--breakout"> + <svg class="icon icon--body-pixels-left"><use xlink:href="#body-pixels-left"></use></svg> + </div> </div> {% endblock %} diff --git a/opentech/apply/home/templates/apply_home/includes/apply_listing.html b/opentech/apply/home/templates/apply_home/includes/apply_listing.html index 1b459f7a6c280fa3be761df5b1f53f5307aed3be..dd1083c61b3a4bc5e4a78b2fb64f0f9e9e0a6ef5 100644 --- a/opentech/apply/home/templates/apply_home/includes/apply_listing.html +++ b/opentech/apply/home/templates/apply_home/includes/apply_listing.html @@ -1,24 +1,45 @@ {% load wagtailcore_tags %} {% with details=page.specific.detail.specific %} -<div class="listing"> - <h4 class="listing__title"> - {# details may be None, so be more verbose in the handling of the title #} - {% if page.title %} - {{ page.title }} - {% else %} - {{ details.listing_title|default:details.title }} - {% endif %} - </h4> +{% if page.specific.open_round %} + <div class="listing listing--not-a-link"> + <div> + <h4 class="listing__title listing__title--link"> + {% if details.deadline %} + <p class="listing__deadline"> + <svg class="icon icon--calendar icon--small"><use xlink:href="#calendar"></use></svg> + <span>Next deadline: {{ details.deadline|date:"M j, Y" }}</span> + </p> + {% endif %} + {# details may be None, so be more verbose in the handling of the title #} + {% if page.title %} + {% if details %} + <a href="{% pageurl details %}"> + {% endif %} - {% if details.listing_summary or details.introduction %} - <h6 class="listing__teaser">{{ details.listing_summary|default:details.introduction|truncatechars_html:155 }}</h6> - {% endif %} + {{ page.title }} - {% if details %} - <a href="{% pageurl details %}">More info...</a> - {% endif %} + {% if details %} + </a> + {% endif %} + {% else %} + {% if details %} + <a href="{% pageurl details %}"> + {% endif %} - <a class="" href="{% pageurl page %}">Apply</a> -</div> + {{ details.listing_title|default:details.title }} + + {% if details %} + </a> + {% endif %} + {% endif %} + </h4> + + {% if details.listing_summary or details.introduction %} + <h6 class="listing__teaser">{{ details.listing_summary|default:details.introduction|truncatechars_html:120 }}</h6> + {% endif %} + </div> + <a class="listing__button" href="{% pageurl page %}">Apply</a> + </div> +{% endif %} {% endwith %} diff --git a/opentech/apply/stream_forms/blocks.py b/opentech/apply/stream_forms/blocks.py index 00ac5026f87581a245ec0fba0982fcd3ed656d24..339f7cab2939c43c426eb810f1464a993747a059 100644 --- a/opentech/apply/stream_forms/blocks.py +++ b/opentech/apply/stream_forms/blocks.py @@ -1,4 +1,6 @@ # Credit to https://github.com/BertrandBordage for initial implementation +import bleach + from django import forms from django.db.models import BLANK_CHOICE_DASH from django.utils.dateparse import parse_datetime @@ -11,6 +13,8 @@ from wagtail.wagtailcore.blocks import ( DateBlock, TimeBlock, DateTimeBlock, ChoiceBlock, RichTextBlock ) +from .fields import MultiFileField + class FormFieldBlock(StructBlock): field_label = CharBlock(label=_('Label')) @@ -19,6 +23,9 @@ class FormFieldBlock(StructBlock): field_class = forms.CharField widget = None + class Meta: + template = 'stream_forms/render_field.html' + def get_slug(self, struct_value): return force_text(slugify(unidecode(struct_value['field_label']))) @@ -43,10 +50,16 @@ class FormFieldBlock(StructBlock): return self.get_field_class(struct_value)( **self.get_field_kwargs(struct_value)) + def get_searchable_content(self, value, data): + return str(data) + class OptionalFormFieldBlock(FormFieldBlock): required = BooleanBlock(label=_('Required'), required=False) + def get_searchable_content(self, value, data): + return data + CHARFIELD_FORMATS = [ ('email', _('Email')), @@ -60,6 +73,7 @@ class CharFieldBlock(OptionalFormFieldBlock): class Meta: label = _('Text field (single line)') + template = 'stream_forms/render_unsafe_field.html' def get_field_class(self, struct_value): text_format = struct_value['format'] @@ -69,6 +83,11 @@ class CharFieldBlock(OptionalFormFieldBlock): return forms.EmailField return super().get_field_class(struct_value) + def get_searchable_content(self, value, data): + # CharField acts as a fallback. Force data to string + data = str(data) + return bleach.clean(data, tags=[], strip=True) + class TextFieldBlock(OptionalFormFieldBlock): default_value = TextBlock(required=False, label=_('Default value')) @@ -77,6 +96,10 @@ class TextFieldBlock(OptionalFormFieldBlock): class Meta: label = _('Text field (multi line)') + template = 'stream_forms/render_unsafe_field.html' + + def get_searchable_content(self, value, data): + return bleach.clean(data, tags=[], strip=True) class NumberFieldBlock(OptionalFormFieldBlock): @@ -87,6 +110,9 @@ class NumberFieldBlock(OptionalFormFieldBlock): class Meta: label = _('Number field') + def get_searchable_content(self, value, data): + return None + class CheckboxFieldBlock(FormFieldBlock): default_value = BooleanBlock(required=False) @@ -97,6 +123,9 @@ class CheckboxFieldBlock(FormFieldBlock): label = _('Checkbox field') icon = 'tick-inverse' + def get_searchable_content(self, value, data): + return None + class RadioButtonsFieldBlock(OptionalFormFieldBlock): choices = ListBlock(CharBlock(label=_('Choice'))) @@ -139,6 +168,7 @@ class CheckboxesFieldBlock(OptionalFormFieldBlock): class Meta: label = _('Multiple checkboxes field') icon = 'list-ul' + template = 'stream_forms/render_list_field.html' def get_field_kwargs(self, struct_value): kwargs = super(CheckboxesFieldBlock, @@ -147,6 +177,9 @@ class CheckboxesFieldBlock(OptionalFormFieldBlock): for choice in struct_value['checkboxes']] return kwargs + def get_searchable_content(self, value, data): + return data + class DatePickerInput(forms.DateInput): def __init__(self, *args, **kwargs): @@ -171,6 +204,9 @@ class DateFieldBlock(OptionalFormFieldBlock): label = _('Date field') icon = 'date' + def get_searchable_content(self, value, data): + return None + class HTML5TimeInput(forms.TimeInput): input_type = 'time' @@ -186,6 +222,9 @@ class TimeFieldBlock(OptionalFormFieldBlock): label = _('Time field') icon = 'time' + def get_searchable_content(self, value, data): + return None + class DateTimePickerInput(forms.SplitDateTimeWidget): def __init__(self, attrs=None, date_format=None, time_format=None): @@ -212,6 +251,9 @@ class DateTimeFieldBlock(OptionalFormFieldBlock): label = _('Date+time field') icon = 'date' + def get_searchable_content(self, value, data): + return None + class ImageFieldBlock(OptionalFormFieldBlock): field_class = forms.ImageField @@ -220,13 +262,35 @@ class ImageFieldBlock(OptionalFormFieldBlock): label = _('Image field') icon = 'image' + def get_searchable_content(self, value, data): + return None + class FileFieldBlock(OptionalFormFieldBlock): + """This doesn't know how to save the uploaded files + + You must implement this if you want to reuse it. + """ field_class = forms.FileField class Meta: label = _('File field') icon = 'download' + template = 'stream_forms/render_file_field.html' + + def get_searchable_content(self, value, data): + return None + + +class MultiFileFieldBlock(FileFieldBlock): + field_class = MultiFileField + + class Meta: + label = _('Multiple File field') + template = 'stream_forms/render_multi_file_field.html' + + def get_searchable_content(self, value, data): + return None class FormFieldsBlock(StreamBlock): @@ -243,6 +307,7 @@ class FormFieldsBlock(StreamBlock): datetime = DateTimeFieldBlock(group=_('Fields')) image = ImageFieldBlock(group=_('Fields')) file = FileFieldBlock(group=_('Fields')) + multi_file = MultiFileFieldBlock(group=_('Fields')) class Meta: label = _('Form fields') diff --git a/opentech/apply/stream_forms/fields.py b/opentech/apply/stream_forms/fields.py new file mode 100644 index 0000000000000000000000000000000000000000..5f14002ec1b3ed6a454620b20903ddbf0b191bbd --- /dev/null +++ b/opentech/apply/stream_forms/fields.py @@ -0,0 +1,23 @@ +from django.forms import FileInput, FileField + + +class MultiFileInput(FileInput): + """ + File Input only returns one file from its clean method. + + This passes all files through the clean method and means we have a list of + files available for post processing + """ + def __init__(self, *args, attrs={}, **kwargs): + attrs['multiple'] = True + super().__init__(*args, attrs=attrs, **kwargs) + + def value_from_datadict(self, data, files, name): + return files.getlist(name) + + +class MultiFileField(FileField): + widget = MultiFileInput + + def clean(self, value, initial): + return [FileField().clean(file, initial) for file in value] diff --git a/opentech/apply/stream_forms/templates/stream_forms/includes/file_field.html b/opentech/apply/stream_forms/templates/stream_forms/includes/file_field.html new file mode 100644 index 0000000000000000000000000000000000000000..b08191a0e2928cf5253158297716419c9d5ed654 --- /dev/null +++ b/opentech/apply/stream_forms/templates/stream_forms/includes/file_field.html @@ -0,0 +1,7 @@ +<a class="link link--download" href="{{ file.url }}"> + <div> + <svg><use xlink:href="#file"></use></svg> + <span>{{ file.name }}</span> + </div> + <svg><use xlink:href="#download"></use></svg> +</a> diff --git a/opentech/apply/stream_forms/templates/stream_forms/render_field.html b/opentech/apply/stream_forms/templates/stream_forms/render_field.html new file mode 100644 index 0000000000000000000000000000000000000000..049375a32c6fc5d22bf2a4516b101452ffbbb17c --- /dev/null +++ b/opentech/apply/stream_forms/templates/stream_forms/render_field.html @@ -0,0 +1,4 @@ +<div> + <h5>{{ value.field_label }}</h5> + <div>{% block data_display %}{{ data|default:"No response" }}{% endblock %}</div> +</div> diff --git a/opentech/apply/stream_forms/templates/stream_forms/render_file_field.html b/opentech/apply/stream_forms/templates/stream_forms/render_file_field.html new file mode 100644 index 0000000000000000000000000000000000000000..cece5ac71e19e02c2a160caa482fbb1a2a72f445 --- /dev/null +++ b/opentech/apply/stream_forms/templates/stream_forms/render_file_field.html @@ -0,0 +1,10 @@ +{% extends "stream_forms/render_field.html" %} +{% block data_display %} + {% if data %} + <div class="wrapper wrapper--top-bottom-space"> + {% include "stream_forms/includes/file_field.html" with file=data %} + </div> + {% else %} + {{ block.super }} + {% endif %} +{% endblock %} diff --git a/opentech/apply/stream_forms/templates/stream_forms/render_list_field.html b/opentech/apply/stream_forms/templates/stream_forms/render_list_field.html new file mode 100644 index 0000000000000000000000000000000000000000..a2cb46ef1a13f2e2dc36641eed5e931fc47a762c --- /dev/null +++ b/opentech/apply/stream_forms/templates/stream_forms/render_list_field.html @@ -0,0 +1,12 @@ +{% extends "stream_forms/render_field.html" %} +{% block data_display %} + {% if data %} + {% for value in data %} + {% if forloop.first %}<ul>{% endif %} + <li>{{ value }}</li> + {% if forloop.last %}</ul>{% endif %} + {% endfor %} + {% else %} + {{ block.super }} + {% endif %} +{% endblock %} diff --git a/opentech/apply/stream_forms/templates/stream_forms/render_multi_file_field.html b/opentech/apply/stream_forms/templates/stream_forms/render_multi_file_field.html new file mode 100644 index 0000000000000000000000000000000000000000..c7997cbfd2407df9ffb17d6ab62466899b328f2c --- /dev/null +++ b/opentech/apply/stream_forms/templates/stream_forms/render_multi_file_field.html @@ -0,0 +1,8 @@ +{% extends "stream_forms/render_field.html" %} +{% block data_display %} + <div class="wrapper wrapper--top-bottom-space"> + {% for file in data %} + {% include "stream_forms/includes/file_field.html" with file=file %} + {% endfor %} + </div> +{% endblock %} diff --git a/opentech/apply/stream_forms/templates/stream_forms/render_unsafe_field.html b/opentech/apply/stream_forms/templates/stream_forms/render_unsafe_field.html new file mode 100644 index 0000000000000000000000000000000000000000..04ea0ffc4395d43f00b90a1fe36a9ec95e039e65 --- /dev/null +++ b/opentech/apply/stream_forms/templates/stream_forms/render_unsafe_field.html @@ -0,0 +1,9 @@ +{% extends "stream_forms/render_field.html" %} +{% load bleach_tags %} +{% block data_display %} + {% if data %} + {{ data|bleach }} + {% else %} + {{ block.super }} + {% endif %} +{% endblock %} diff --git a/opentech/apply/urls.py b/opentech/apply/urls.py index 802e5c0fcfd9eae92a8b82d091a7070abe82e867..6a4d437d8a11358373ae6238415d399796d40b6e 100644 --- a/opentech/apply/urls.py +++ b/opentech/apply/urls.py @@ -6,7 +6,7 @@ from .dashboard import urls as dashboard_urls urlpatterns = [ - url(r'^apply/', include(funds_urls)), + url(r'^apply/', include(funds_urls, namespace='funds')), url(r'^account/', include(users_urls, namespace='users')), url(r'^dashboard/', include(dashboard_urls, namespace='dashboard')), ] diff --git a/opentech/apply/users/groups.py b/opentech/apply/users/groups.py index 4ec30e03170c669f30fe6f8b8a13e4b6f3ec1cee..ec87a6dc6d8d6fe0ea267a1e686fa691d2251581 100644 --- a/opentech/apply/users/groups.py +++ b/opentech/apply/users/groups.py @@ -1,3 +1,5 @@ +STAFF_GROUP_NAME = 'Staff' + GROUPS = [ { 'name': 'Applicant', @@ -12,7 +14,7 @@ GROUPS = [ 'permissions': [], }, { - 'name': 'Staff', + 'name': STAFF_GROUP_NAME, 'permissions': [], }, { diff --git a/opentech/apply/users/models.py b/opentech/apply/users/models.py index 5e39dfb2ef57fd63691a26de9140ec21578b0333..26a0c2c355a20b12d08cdc30126ebbd9073ac29c 100644 --- a/opentech/apply/users/models.py +++ b/opentech/apply/users/models.py @@ -7,6 +7,9 @@ from .utils import send_activation_email def convert_full_name_to_parts(defaults): full_name = defaults.pop('full_name', ' ') + if not full_name: + # full_name was None + full_name = ' ' first_name, *last_name = full_name.split(' ') if first_name: defaults.update(first_name=first_name) @@ -69,3 +72,6 @@ class User(AbstractUser): username = None objects = UserManager() + + def __str__(self): + return self.get_full_name() diff --git a/opentech/apply/users/pipeline.py b/opentech/apply/users/pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..87971ec425d9ab5672b155ee1e09a770ae6349fa --- /dev/null +++ b/opentech/apply/users/pipeline.py @@ -0,0 +1,11 @@ +from django.conf import settings +from django.contrib.auth.models import Group + +from opentech.apply.users.groups import STAFF_GROUP_NAME + + +def make_otf_staff(backend, user, response, *args, **kwargs): + _, email_domain = user.email.split('@') + if email_domain in settings.STAFF_EMAIL_DOMAINS: + staff_group = Group.objects.get(STAFF_GROUP_NAME) + user.groups.add(staff_group) diff --git a/opentech/apply/users/tests/factories.py b/opentech/apply/users/tests/factories.py new file mode 100644 index 0000000000000000000000000000000000000000..9e1ddfb5152e6e8bb4f9a37dc92e81324a9ef439 --- /dev/null +++ b/opentech/apply/users/tests/factories.py @@ -0,0 +1,29 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group + +import factory + + +class GroupFactory(factory.DjangoModelFactory): + class Meta: + model = Group + django_get_or_create = ('name',) + + name = factory.Sequence('group name {}'.format) + + +class UserFactory(factory.DjangoModelFactory): + class Meta: + model = get_user_model() + + email = factory.Sequence('email{}@email.com'.format) + + @factory.PostGeneration + def groups(self, create, extracted, **kwargs): + if create: + if not extracted: + groups = GroupFactory(**kwargs) + else: + groups = extracted + + self.groups.add(groups) diff --git a/opentech/public/navigation/templatetags/navigation_tags.py b/opentech/public/navigation/templatetags/navigation_tags.py index 9682f7aa73db6bb8507c549fe7236184c42ccc23..2a692a83d9a9315f3d384d5793c7fcfb4f3cdf4b 100644 --- a/opentech/public/navigation/templatetags/navigation_tags.py +++ b/opentech/public/navigation/templatetags/navigation_tags.py @@ -13,8 +13,9 @@ esi_inclusion_tag = register_inclusion_tag(register) @esi_inclusion_tag('navigation/primarynav.html') def primarynav(context): request = context['request'] + site = context.get('PUBLIC_SITE', request.site) return { - 'primarynav': NavigationSettings.for_site(request.site).primary_navigation, + 'primarynav': NavigationSettings.for_site(site).primary_navigation, 'request': request, } @@ -23,8 +24,9 @@ def primarynav(context): @esi_inclusion_tag('navigation/secondarynav.html') def secondarynav(context): request = context['request'] + site = context.get('PUBLIC_SITE', request.site) return { - 'secondarynav': NavigationSettings.for_site(request.site).secondary_navigation, + 'secondarynav': NavigationSettings.for_site(site).secondary_navigation, 'request': request, } @@ -33,8 +35,9 @@ def secondarynav(context): @esi_inclusion_tag('navigation/footernav.html') def footernav(context): request = context['request'] + site = context.get('PUBLIC_SITE', request.site) return { - 'footernav': NavigationSettings.for_site(request.site).footer_navigation, + 'footernav': NavigationSettings.for_site(site).footer_navigation, 'request': request, } @@ -52,7 +55,8 @@ def sidebar(context): @esi_inclusion_tag('navigation/footerlinks.html') def footerlinks(context): request = context['request'] + site = context.get('PUBLIC_SITE', request.site) return { - 'footerlinks': NavigationSettings.for_site(request.site).footer_links, + 'footerlinks': NavigationSettings.for_site(site).footer_links, 'request': request, } diff --git a/opentech/public/search/templates/search/includes/search_result.html b/opentech/public/search/templates/search/includes/search_result.html index 020a1b907664c5c2b002167f36caa32789e5c296..e7d7b4c519c6e51537b7715b1090b2a7976fd7ef 100644 --- a/opentech/public/search/templates/search/includes/search_result.html +++ b/opentech/public/search/templates/search/includes/search_result.html @@ -1,33 +1,33 @@ {% load static wagtailcore_tags wagtailsearchpromotions_tags wagtailimages_tags %} -{# breadcrumbs #} -{% if result.get_ancestors|length > 2 %} - {% for ancestor in result.get_ancestors %} - {% if not ancestor.is_root %} - {% if ancestor.depth > 2 %} - {{ ancestor.title }} - {% if ancestor.depth|add:1 < result.depth %} - / +<a class="listing" href="{% pageurl result %}"> + {# breadcrumbs #} + {% if result.get_ancestors|length > 2 %} + <h6 class="listing__path"> + {% for ancestor in result.get_ancestors %} + {% if not ancestor.is_root %} + {% if ancestor.depth > 2 %} + <span>{{ ancestor.title }}</span> + {% if ancestor.depth|add:1 < result.depth %} + <span class="nav__item--breadcrumb"></span> + {% endif %} + {% else %}<span class="nav__item--breadcrumb"></span>{% endif %} {# the first one #} {% endif %} - {% else %}/{% endif %} {# the first one #} - {% endif %} - {% endfor %} -{% endif %} + {% endfor %} + </h6> + {% endif %} -{% if result.listing_image %} - <a href="{% pageurl result %}"> - {% image result.listing_image fill-450x300 %} - </a> -{% endif %} - -<h4> - <a href="{% pageurl result %}"> - {{ result.listing_title|default:result.title }} - </a> -</h4> - -{% if pick.description or result.listing_summary or result.search_description %} - <p>{{ pick.description|default:result.listing_summary|default:result.search_description }}</p> -{% endif %} + {% if result.listing_image or result.icon %} + {% image result.listing_image|default:result.icon fill-180x180 class="listing__image" %} + {% else %} + <div class="listing__image listing__image--default"> + <svg><use xlink:href="#logo-mobile-no-text"></use></svg> + </div> + {% endif %} + <h4 class="listing__title">{{ result.listing_title|default:result.title }}</h4> + {% if pick.description or result.listing_summary or result.search_description or result.listing_summary or result.introduction %} + <h6 class="listing__teaser">{{ pick.description|default:result.listing_summary|default:result.search_description|default:result.listing_summary|default:result.introduction|truncatechars_html:155 }}</h6> + {% endif %} +</a> diff --git a/opentech/public/search/templates/search/search.html b/opentech/public/search/templates/search/search.html index d9e84558902033bcb8988ff6572a6908dc6e3dc5..b15ac6ea856529b114d4e9e65da166c93b943242 100644 --- a/opentech/public/search/templates/search/search.html +++ b/opentech/public/search/templates/search/search.html @@ -1,48 +1,41 @@ {% extends "base.html" %} {% load static wagtailcore_tags wagtailsearchpromotions_tags %} - -{% block body_class %}template-searchresults{% endblock %} - +{% block body_class %}template-searchresults light-grey-bg{% endblock %} +{% block page_title %}Search results{% endblock %} {% block title %}{% if search_query %}Search results for “{{ search_query }}”{% else %}Search{% endif %}{% endblock %} - {% block content %} - - <h1>{% if search_query %}Search results for “{{ search_query }}”{% else %}Search{% endif %}</h1> - - <form action="{% url 'search' %}" method="get" role="search"> - <input type="text" placeholder="Search…" name="query"{% if search_query %} value="{{ search_query }}"{% endif %}> - <input type="submit" value="Search"> - </form> - - {% get_search_promotions search_query as search_picks %} - {% if search_picks %} - <ul> - {% for pick in search_picks %} - <li> + <div class="wrapper wrapper--small wrapper--top-bottom-inner-space"> + <h2 class="heading heading--no-margin">{% if search_query %}Search results for “{{ search_query }}”{% else %}Search{% endif %}</h2> + + {% if search_results %} + {% with count=search_results.paginator.count %} + <p>{{ count }} result{{ count|pluralize }} found.</p> + {% endwith %} + {% elif search_query and not search_picks %} + <p>No results found.</p> + {% endif %} + + <form class="form" action="{% url 'search' %}" method="get" role="search" aria-label="Search form"> + <input class="input input--bottom-space" type="text" placeholder="Search…" name="query"{% if search_query %} value="{{ search_query }}"{% endif %} aria-label="Search input"> + <input class="link link--button" type="submit" value="Search" aria-label="search"> + </form> + + {% get_search_promotions search_query as search_picks %} + {% if search_picks %} + <div class="wrapper wrapper--listings"> + {% for pick in search_picks %} {% include "search/includes/search_result.html" with result=pick.page.specific %} - </li> - {% endfor %} - </ul> - {% endif %} + {% endfor %} + </div> + {% endif %} - {% if search_results %} - - {% with count=search_results.paginator.count %} - {{ count }} result{{ count|pluralize }} found. - {% endwith %} - - <ul> - {% for result in search_results %} - <li> + {% if search_results %} + <div class="wrapper wrapper--listings"> + {% for result in search_results %} {% include "search/includes/search_result.html" with result=result.specific %} - </li> - {% endfor %} - </ul> - - {% include "includes/pagination.html" with paginator_page=search_results %} - - {% elif search_query and not search_picks %} - No results found. - {% endif %} - + {% endfor %} + </div> + {% include "includes/pagination.html" with paginator_page=search_results %} + {% endif %} + </div> {% endblock %} diff --git a/opentech/public/utils/context_processors.py b/opentech/public/utils/context_processors.py index 330053296b778a10ef308e74095f8bf0c2efe668..482ef0e1123321f653f8d821746ac1840a6538f3 100644 --- a/opentech/public/utils/context_processors.py +++ b/opentech/public/utils/context_processors.py @@ -1,10 +1,12 @@ from django.conf import settings from opentech.apply.home.models import ApplyHomePage +from opentech.public.home.models import HomePage def global_vars(request): return { 'GOOGLE_TAG_MANAGER_ID': getattr(settings, 'GOOGLE_TAG_MANAGER_ID', None), - 'APPLY_SITE': ApplyHomePage.objects.first(), + 'APPLY_SITE': ApplyHomePage.objects.first().get_site(), + 'PUBLIC_SITE': HomePage.objects.first().get_site(), } diff --git a/opentech/settings/base.py b/opentech/settings/base.py index 063ce2e71d820323e02d9e943cf5a0d7867bdef4..afcbf80bcd413edbb96719cf3b61a26506721e35 100644 --- a/opentech/settings/base.py +++ b/opentech/settings/base.py @@ -57,13 +57,17 @@ INSTALLED_APPS = [ 'tinymce', 'wagtailcaptcha', 'django_tables2', + 'django_filters', + 'django_select2', 'addressfield', + 'django_bleach', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', + 'django.contrib.postgres', 'django.contrib.staticfiles', 'django.contrib.sitemaps', 'django.forms', @@ -295,7 +299,8 @@ SOCIAL_AUTH_URL_NAMESPACE = 'social' # Set the Google OAuth2 credentials in ENV variables or local.py # To create a new set of credentials, go to https://console.developers.google.com/apis/credentials # Make sure the Google+ API is enabled for your API project -SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS = ['opentechfund.org'] +STAFF_EMAIL_DOMAINS = ['opentechfund.org'] +SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS = STAFF_EMAIL_DOMAINS SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = '' SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = '' @@ -315,4 +320,16 @@ SOCIAL_AUTH_PIPELINE = ( 'social_core.pipeline.social_auth.associate_user', 'social_core.pipeline.social_auth.load_extra_data', 'social_core.pipeline.user.user_details', + 'opentech.apply.users.pipeline.make_otf_staff', ) + +# Bleach Settings +BLEACH_ALLOWED_TAGS = ['h2', 'h3', 'p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li'] + +BLEACH_ALLOWED_ATTRIBUTES = ['href', 'title', 'style'] + +BLEACH_ALLOWED_STYLES = ['font-family', 'font-weight', 'text-decoration', 'font-variant'] + +BLEACH_STRIP_TAGS = True + +BLEACH_STRIP_COMMENTS = True diff --git a/opentech/static_src/src/sass/apply/abstracts/_functions.scss b/opentech/static_src/src/sass/apply/abstracts/_functions.scss new file mode 100644 index 0000000000000000000000000000000000000000..33cdc75e5a4e53f7930dc9595cf36d77fd83b903 --- /dev/null +++ b/opentech/static_src/src/sass/apply/abstracts/_functions.scss @@ -0,0 +1,26 @@ +// Returns the opposite direction of each direction in a list +// @param {List} $directions - List of initial directions +// @return {List} - List of opposite directions +@function opposite-direction($directions) { + $opposite-directions: (); + $direction-map: ( + 'top': 'bottom', + 'right': 'left', + 'bottom': 'top', + 'left': 'right', + 'center': 'center', + 'ltr': 'rtl', + 'rtl': 'ltr' + ); + + @each $direction in $directions { + $direction: to-lower-case($direction); + + @if map-has-key($direction-map, $direction) { + $opposite-directions: append($opposite-directions, unquote(map-get($direction-map, $direction))); + } @else { + @warn 'No opposite direction can be found for `#{$direction}`. Direction omitted.'; + } + } + @return $opposite-directions; +} diff --git a/opentech/static_src/src/sass/apply/abstracts/_mixins.scss b/opentech/static_src/src/sass/apply/abstracts/_mixins.scss index 077351637d705d5ddaba5719f2080731e8b79c65..8ce5a38b932952fa732ac1bc26f2a5bde95d9c55 100755 --- a/opentech/static_src/src/sass/apply/abstracts/_mixins.scss +++ b/opentech/static_src/src/sass/apply/abstracts/_mixins.scss @@ -126,3 +126,29 @@ font-size: $responsive; } + +// Triangle mixin +// @param {Direction} $direction - Triangle direction, either `top`, `right`, `bottom` or `left` +// @param {Color} $color [currentcolor] - Triangle color +// @param {Length} $size [1em] - Triangle size +@mixin triangle($direction, $color: currentcolor, $size: 1em) { + @if not index(top right bottom left, $direction) { + @error 'Direction must be either `top`, `right`, `bottom` or `left`.'; + } + + width: 0; + height: 0; + content: ''; + border-#{opposite-direction($direction)}: ($size * 1.5) solid $color; + + $perpendicular-borders: $size solid transparent; + + @if $direction == top or $direction == bottom { + border-right: $perpendicular-borders; + border-left: $perpendicular-borders; + } @else if $direction == right or $direction == left { + border-top: $perpendicular-borders; + border-bottom: $perpendicular-borders; + } + } + diff --git a/opentech/static_src/src/sass/apply/abstracts/_variables.scss b/opentech/static_src/src/sass/apply/abstracts/_variables.scss index 3b9e796a9e93a1627116d394a75f15b272776939..4627dffb84df838b295f8b3b9f301660025ca548 100755 --- a/opentech/static_src/src/sass/apply/abstracts/_variables.scss +++ b/opentech/static_src/src/sass/apply/abstracts/_variables.scss @@ -1,9 +1,11 @@ // Default $color--white: #fff; $color--black: #141414; -$color--dark-grey: #404041; $color--light-grey: #f7f7f7; +$color--light-mid-grey: #e8e8e8; $color--mid-grey: #cfcfcf; +$color--mid-dark-grey: #919191; +$color--dark-grey: #404041; // Brand $color--light-blue: #43bbf1; @@ -16,12 +18,16 @@ $color--light-pink: #ffe1df; $color--tomato: #f05e54; $color--mint: #40c2ad; +$color--sky-blue: #e7f2f6; +$color--marine: #177da8; + // Social $color--twitter: #1da6f6; $color--linkedin: #137ab8; $color--facebook: #396ab5; // Transparent +$color--black-50: rgba(0, 0, 0, 0.5); $color--black-10: rgba(0, 0, 0, 0.1); // Assignment @@ -62,6 +68,7 @@ $wrapper--small: 790px; $breakpoints: ( 'mob-portrait' '(min-width: 320px)', 'mob-landscape' '(min-width: 480px)', + 'small-tablet' '(min-width: 600px)', 'tablet-portrait' '(min-width: 768px)', 'tablet-landscape' '(min-width: 1024px)', 'desktop' '(min-width: 1280px)', diff --git a/opentech/static_src/src/sass/apply/base/_typography.scss b/opentech/static_src/src/sass/apply/base/_typography.scss index e0aeff91b2984e7783f06f93e317b0ff16e0f412..e897ac69d770e9430a5d14a7e8f6bb484ad0b368 100755 --- a/opentech/static_src/src/sass/apply/base/_typography.scss +++ b/opentech/static_src/src/sass/apply/base/_typography.scss @@ -34,7 +34,7 @@ h1, h2, h3, h4, h5, h6, html, .body-text { - @include responsive-font-sizes(16px, 19px); + font-size: 16px; } // Default sizes diff --git a/opentech/static_src/src/sass/apply/components/_pagination.scss b/opentech/static_src/src/sass/apply/components/_pagination.scss new file mode 100644 index 0000000000000000000000000000000000000000..67f49b2b5d024e8ed9be3f92e2ba5bdb5af8892d --- /dev/null +++ b/opentech/static_src/src/sass/apply/components/_pagination.scss @@ -0,0 +1,47 @@ +.pagination { + @extend %h6; + display: flex; + align-items: center; + justify-content: center; + margin-top: 30px; + + .cardinality { + margin: 0 10px; + } + + .previous, + .next { + a { + position: relative; + display: block; + width: 55px; + height: 55px; + font-size: 0; + color: $color--white; + background: $color--white; + border: 1px solid $color--mid-grey; + + &::after { + position: absolute;; + top: 18.5px; + left: 22.5px; + } + } + } + + .previous { + a { + &::after { + @include triangle(left, $color--primary, 7px); + } + } + } + + .next { + a { + &::after { + @include triangle(right, $color--primary, 7px); + } + } + } +} diff --git a/opentech/static_src/src/sass/apply/components/_table.scss b/opentech/static_src/src/sass/apply/components/_table.scss new file mode 100644 index 0000000000000000000000000000000000000000..176e03d9af96388fe2c68eb09c7020e7cad50b87 --- /dev/null +++ b/opentech/static_src/src/sass/apply/components/_table.scss @@ -0,0 +1,131 @@ +$table-breakpoint: 'small-tablet'; + +table { + width: 100%; + background-color: $color--white; + border-collapse: collapse; + table-layout: fixed; + + thead { + display: none; + + @include media-query($table-breakpoint) { + display: table-header-group; + } + + tr { + &:hover { + box-shadow: none; + } + } + } + + tr { + border: 1px solid $color--light-mid-grey; + transition: box-shadow 0.15s ease; + + @include media-query($table-breakpoint) { + border-top: 0; + border-right: 0; + border-bottom: 2px solid $color--light-grey; + border-left: 0; + + &:hover { + box-shadow: 0 6px 35px -13px $color--black-50; + } + } + + > td { + display: block; + width: 100%; + + + @include media-query($table-breakpoint) { + display: table-cell; + width: initial; + height: 90px; + } + + &:first-child { + padding-top: 20px; + } + + &:last-child { + padding-bottom: 20px; + } + + &.title { + font-weight: $weight--bold; + + a { + color: $color--primary; + + @include media-query($table-breakpoint) { + color: $color--dark-grey; + } + } + } + + &.status_name { + span { + display: inline-block; + padding: 10px; + font-size: 13px; + font-weight: $weight--bold; + color: $color--marine; + text-align: center; + background-color: $color--sky-blue; + } + } + } + } + + td, + th { + padding: 5px 20px; + + @include media-query($table-breakpoint) { + padding: 20px; + } + } + + th { + padding: 20px; + font-size: 15px; + font-weight: 600; + text-align: left; + + a { + color: $color--mid-dark-grey; + transition: color 0.25s ease-out; + } + + &.desc, + &.asc { + position: relative; + color: $color--dark-grey; + + &::after { + position: absolute; + top: 25px; + margin-left: 10px; + } + + a { + color: inherit; + } + } + + &.desc { + &::after { + @include triangle(top, $color--default, 5px); + } + } + + &.asc { + &::after { + @include triangle(bottom, $color--default, 5px); + } + } + } +} diff --git a/opentech/static_src/src/sass/apply/main.scss b/opentech/static_src/src/sass/apply/main.scss index 81828c292f62a2bf71255a90b84a87f462f2d932..0e5ecb06508d8335ca72d7a6d45bf9c7ab2f9517 100755 --- a/opentech/static_src/src/sass/apply/main.scss +++ b/opentech/static_src/src/sass/apply/main.scss @@ -2,6 +2,7 @@ @import 'vendor/normalize'; // Abstracts +@import 'abstracts/functions'; @import 'abstracts/mixins'; @import 'abstracts/variables'; @@ -12,6 +13,8 @@ // Components @import 'components/button'; @import 'components/icon'; +@import 'components/pagination'; +@import 'components/table'; @import 'components/wrapper'; // Layout diff --git a/opentech/static_src/src/sass/public/abstracts/_variables.scss b/opentech/static_src/src/sass/public/abstracts/_variables.scss index b97b1352934d38964cd541de446972975391bb4e..9ec021c031ad892359e03739dbb6d0812dc40fd1 100755 --- a/opentech/static_src/src/sass/public/abstracts/_variables.scss +++ b/opentech/static_src/src/sass/public/abstracts/_variables.scss @@ -3,6 +3,7 @@ $color--white: #fff; $color--black: #141414; $color--dark-grey: #404041; $color--light-grey: #f7f7f7; +$color--light-mid-grey: #e8e8e8; $color--mid-grey: #cfcfcf; // Brand diff --git a/opentech/static_src/src/sass/public/components/_form.scss b/opentech/static_src/src/sass/public/components/_form.scss index ddee807bb320d7f610114a81874f99d1262d8ade..88cf3d5d02f81bde5ebc00de3cec376c64228db8 100644 --- a/opentech/static_src/src/sass/public/components/_form.scss +++ b/opentech/static_src/src/sass/public/components/_form.scss @@ -53,6 +53,7 @@ // sass-lint:disable class-name-format &--image_field, + &--multi_file_field, &--file_field { @include button($color--light-blue, $color--dark-blue); max-width: 290px; diff --git a/opentech/static_src/src/sass/public/components/_icon.scss b/opentech/static_src/src/sass/public/components/_icon.scss index 81c31a98b5754cc21a83226b18743b74e6ffa240..311219b3a15a89ea78143ca381e7e2736b17989e 100644 --- a/opentech/static_src/src/sass/public/components/_icon.scss +++ b/opentech/static_src/src/sass/public/components/_icon.scss @@ -130,4 +130,32 @@ width: 14px; height: 14px; } + + &--body-pixels-right { + position: absolute; + right: 0; + z-index: -1; + display: none; + width: 218px; + height: 457px; + fill: $color--dark-blue; + + @include media-query(desktop) { + display: block; + } + } + + &--body-pixels-left { + position: absolute; + top: -355px; + left: 0; + display: none; + width: 109px; + height: 275px; + fill: $color--dark-blue; + + @include media-query(desktop) { + display: block; + } + } } diff --git a/opentech/static_src/src/sass/public/components/_input.scss b/opentech/static_src/src/sass/public/components/_input.scss index 98d0e903291f2b7ab97f454a34f993d57ebdd089..9af5218697d495fbaecc3d2c650e5bda93c66543 100644 --- a/opentech/static_src/src/sass/public/components/_input.scss +++ b/opentech/static_src/src/sass/public/components/_input.scss @@ -4,4 +4,8 @@ background: transparent; border: 0; } + + &--bottom-space { + margin-bottom: 10px + } } diff --git a/opentech/static_src/src/sass/public/components/_listing.scss b/opentech/static_src/src/sass/public/components/_listing.scss index d9f92f84800b24069b7096dabc49bc4157a69df9..ec908b9b7413f904e6b51dd72c391d5ed73f509f 100644 --- a/opentech/static_src/src/sass/public/components/_listing.scss +++ b/opentech/static_src/src/sass/public/components/_listing.scss @@ -15,6 +15,43 @@ box-shadow: 0 2px 15px 0 $color--black-10; } + &--not-a-link { + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 0 0 20px; + margin: 0 0 20px; + border-bottom: 1px solid $color--light-mid-grey; + + @include media-query(mob-landscape) { + align-items: center; + flex-direction: row; + height: 160px; + } + + &:hover { + box-shadow: none; + } + + &:first-child { + padding-top: 20px; + border-top: 1px solid $color--light-mid-grey; + + @include media-query(mob-landscape) { + padding-top: 0; + border-top: 0; + } + } + + &:last-child { + border-bottom: 0; + } + + > div { + flex-basis: 55%; + } + } + &__title { margin-bottom: 5px; line-height: 1; @@ -23,11 +60,34 @@ #{$root}:hover & { color: $color--dark-blue; } + + &--link { + margin-bottom: 10px; + + #{$root}:hover & { + color: $color--default; + } + + a { + color: $color--default; + transition: color $transition; + + &:hover { + color: $color--primary; + } + } + } } &__teaser { font-weight: $weight--normal; color: $color--default; + + @include media-query(mob-landscape) { + .listing--not-a-link & { + margin: 0; + } + } } &__meta { @@ -60,6 +120,7 @@ } } + &__path, &__category { margin-bottom: 5px; color: $color--default; @@ -77,4 +138,24 @@ margin-left: 5px; } } + + &__button { + @include button(transparent, $color--purple); + color: $color--purple; + text-align: center; + border-color: $color--purple; + + &:hover { + color: $color--white; + } + + @include media-query(mob-landscape) { + margin-right: 30px; + text-align: left; + } + } + + &__path { + display: flex; + } } diff --git a/opentech/static_src/src/sass/public/components/_nav.scss b/opentech/static_src/src/sass/public/components/_nav.scss index 703af0bf45e0a05072046e2363dd583d027a651f..15df1f4b841da1638f638c22493b40e99b74a008 100644 --- a/opentech/static_src/src/sass/public/components/_nav.scss +++ b/opentech/static_src/src/sass/public/components/_nav.scss @@ -112,6 +112,10 @@ font-weight: $weight--normal; border-bottom: 0; + .listing & { + margin-left: 10px; + } + &::after { width: 0; height: 0; @@ -120,9 +124,18 @@ border-bottom: 5px solid transparent; border-left: 5px solid $color--white; content: ''; + + .listing & { + margin-left: 0; + border-left: 5px solid $color--default; + } } &:first-child { + .listing & { + margin-left: 0; + } + a { margin-left: 0; } diff --git a/opentech/static_src/src/sass/public/components/_wrapper.scss b/opentech/static_src/src/sass/public/components/_wrapper.scss index 6317fec16795975edc1a1e5e608f5bf93e25283e..5e6b1c70634b9e8341028f280efc77eacfa3b087 100644 --- a/opentech/static_src/src/sass/public/components/_wrapper.scss +++ b/opentech/static_src/src/sass/public/components/_wrapper.scss @@ -217,7 +217,11 @@ &--listings { display: flex; flex-direction: column; - margin-top: 2rem; + margin-top: 20px; + + @include media-query(tablet-portrait) { + margin-top: 2rem; + } } &--page-title { diff --git a/opentech/static_src/src/sass/public/layout/_header.scss b/opentech/static_src/src/sass/public/layout/_header.scss index 61006af4daafb7a47f5cd25ae4c648577cbf5ff2..5066e650fca755b2b942cfe01e2989af125bb2c1 100644 --- a/opentech/static_src/src/sass/public/layout/_header.scss +++ b/opentech/static_src/src/sass/public/layout/_header.scss @@ -61,11 +61,16 @@ } &__title { + padding: 0 10px; margin: 0 0 20px; line-height: 1; color: $color--white; text-shadow: 0 2px 15px $color--black-10; text-transform: uppercase; + + @include media-query(tablet-portrait) { + padding: 0; + } &--homepage { @include responsive-font-sizes(36px, 72px); @@ -83,8 +88,7 @@ content: ''; transition: height, width, 10s ease; } - } - + .header--light-bg & { color: $color--dark-grey; text-shadow: none; diff --git a/opentech/templates/includes/apply_button.html b/opentech/templates/includes/apply_button.html index 53c5218e43f4e15d8f2b5b2387eb1ac1b24ef27c..3d1cb5df96bfa13473618c99a791a5fd36382924 100644 --- a/opentech/templates/includes/apply_button.html +++ b/opentech/templates/includes/apply_button.html @@ -1,2 +1,2 @@ {% load wagtailcore_tags %} -<a href="{% pageurl APPLY_SITE %}" class="link link--fixed-apply">Apply</a> +<a href="{% pageurl APPLY_SITE.root_page %}" class="link link--fixed-apply">Apply</a> diff --git a/opentech/templates/includes/sprites.html b/opentech/templates/includes/sprites.html index f0c554168a11fab8555d6f5f119cc582dd4c4276..33409ac69f957e6731c427844023c0a5f405b210 100644 --- a/opentech/templates/includes/sprites.html +++ b/opentech/templates/includes/sprites.html @@ -220,4 +220,26 @@ <path d="M30.5 40c9.253.037 18.342-6.296 27.264-19C50.451 9 41.364 3 30.5 3 19.637 3 10.52 9.167 3.15 21.5 12.326 33.797 21.443 39.964 30.5 40z" /> </g> </symbol> + + <symbol id="body-pixels-right" viewBox="0 0 218 457"> + <g fill-rule="nonzero"> + <path opacity=".45" d="M110 402h55v-55h-55z" /> + <path opacity=".65" d="M165 457h55v-55h-55z" /> + <path opacity=".594" d="M165 348h55v-55h-55z" /> + <path d="M165 236h55v-55h-55z" /> + <path opacity=".505" d="M110 55h55V0h-55z" /> + <path opacity=".75" d="M55 112h55V57H55z" /> + <path opacity=".594" d="M0 167h55v-55H0z" /> + <path d="M0 55h55V0H0z" /> + <path opacity=".45" d="M55 347h55v-55H55z" /> + </g> + </symbol> + + <symbol id="body-pixels-left" viewBox="0 0 109 275"> + <g fill-rule="nonzero"> + <path opacity=".594" d="M-1 110h55V55H-1z" /> + <path d="M54 275h55v-55H54zM54 55h55V0H54z" /> + <path opacity=".75" d="M54 165h55v-55H54z" /> + </g> + </symbol> </svg> diff --git a/opentech/urls.py b/opentech/urls.py index 77fbcf1c9c77288cb42263b35e7a100a76c694b7..22c3889c8af9eab7aee4610cb49cfd108c1a32fd 100644 --- a/opentech/urls.py +++ b/opentech/urls.py @@ -24,6 +24,7 @@ urlpatterns = [ url('^', include(apply_urls)), url('^', include('social_django.urls', namespace='social')), url(r'^tinymce/', include('tinymce.urls')), + url(r'^select2/', include('django_select2.urls')), ] diff --git a/requirements.txt b/requirements.txt index 3289a95041273e351a3da4b6fdb048e02b194aee..9a8e1221bbcdd67d36852ee7c27ba38e1461334c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ Django==1.11.8 wagtail==1.13.1 psycopg2==2.7.3.1 Pillow==4.3.0 +django-bleach==0.3.0 django-extensions==1.7.4 django-countries==5.1 Werkzeug==0.11.11 @@ -19,6 +20,8 @@ flake8 social_auth_app_django==2.1.0 django-tables2==1.17.1 +django-filter==1.1.0 +django_select2==6.0.1 # Production dependencies dj-database-url==0.4.1 diff --git a/setup.cfg b/setup.cfg index 4c681954a8aa3443f634e61c372d56e2260b9d74..28cc5bb725f3df9aabb06092a3b443d209a4e9c8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,6 +10,9 @@ ignore_errors = True check_untyped_defs = True ignore_errors = False +[mypy-opentech.apply.funds.tests.factories*] +ignore_errors = True + # Enforce writing type definitions within workflow [mypy-opentech.apply.funds.workflow*] disallow_untyped_defs = True