diff --git a/opentech/apply/funds/admin.py b/opentech/apply/funds/admin.py index fe493151f18df7ef9b75179009eb93744298add3..d9596a79b35ab1eb3c6cf91f969339a07a8ccde6 100644 --- a/opentech/apply/funds/admin.py +++ b/opentech/apply/funds/admin.py @@ -1,9 +1,43 @@ +from django.urls import reverse + from wagtail.contrib.modeladmin.options import ModelAdmin, ModelAdminGroup +from wagtail.contrib.modeladmin.helpers import PageButtonHelper -from .models import ApplicationForm, FundType +from .models import ApplicationForm, FundType, Round from opentech.apply.categories.admin import CategoryAdmin +class ButtonsWithPreview(PageButtonHelper): + def preview_button(self, obj, classnames_add, classnames_exclude): + classnames = self.copy_button_classnames + classnames_add + cn = self.finalise_classname(classnames, classnames_exclude) + return { + 'url': reverse('wagtailadmin_pages:view_draft', args=(obj.id,)), + 'label': 'Preview', + 'classname': cn, + 'title': 'Preview this %s' % self.verbose_name, + } + + def get_buttons_for_obj(self, obj, exclude=list(), classnames_add=list(), + classnames_exclude=list()): + btns = super().get_buttons_for_obj(obj, exclude, classnames_add, classnames_exclude) + + # Put preview before delete + btns.insert(-1, self.preview_button(obj, classnames_add, classnames_exclude)) + + return btns + + +class RoundAdmin(ModelAdmin): + model = Round + menu_icon = 'doc-empty' + list_display = ('title', 'fund', 'start_date', 'end_date') + button_helper_class = ButtonsWithPreview + + def fund(self, obj): + return obj.get_parent() + + class FundAdmin(ModelAdmin): model = FundType menu_icon = 'doc-empty' @@ -17,4 +51,4 @@ class ApplicationFormAdmin(ModelAdmin): class ApplyAdminGroup(ModelAdminGroup): menu_label = 'Apply' menu_icon = 'folder-open-inverse' - items = (FundAdmin, ApplicationFormAdmin, CategoryAdmin) + items = (RoundAdmin, FundAdmin, ApplicationFormAdmin, CategoryAdmin) diff --git a/opentech/apply/funds/migrations/0005_round.py b/opentech/apply/funds/migrations/0005_round.py new file mode 100644 index 0000000000000000000000000000000000000000..e839c88745c36b286bf86a7ab58809fe350d5fdc --- /dev/null +++ b/opentech/apply/funds/migrations/0005_round.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.8 on 2018-01-18 15:59 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('wagtailcore', '0040_page_draft_title'), + ('funds', '0004_categoryblock_add_required_option'), + ] + + operations = [ + migrations.CreateModel( + name='Round', + fields=[ + ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), + ], + options={ + 'abstract': False, + }, + bases=('wagtailcore.page',), + ), + ] diff --git a/opentech/apply/funds/migrations/0006_add_dates_to_round.py b/opentech/apply/funds/migrations/0006_add_dates_to_round.py new file mode 100644 index 0000000000000000000000000000000000000000..747ef134788e1689cc12dc9d1e47105361b116c4 --- /dev/null +++ b/opentech/apply/funds/migrations/0006_add_dates_to_round.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.8 on 2018-01-18 16:24 +from __future__ import unicode_literals + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('funds', '0005_round'), + ] + + operations = [ + migrations.AddField( + model_name='round', + name='end_date', + field=models.DateField(blank=True, default=datetime.date.today), + ), + migrations.AddField( + model_name='round', + name='start_date', + field=models.DateField(blank=True, default=datetime.date.today), + ), + ] diff --git a/opentech/apply/funds/migrations/0007_update_date_fields.py b/opentech/apply/funds/migrations/0007_update_date_fields.py new file mode 100644 index 0000000000000000000000000000000000000000..7b6b2829535b0139d491a0be0589e4acad48430e --- /dev/null +++ b/opentech/apply/funds/migrations/0007_update_date_fields.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.8 on 2018-01-19 11:30 +from __future__ import unicode_literals + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('funds', '0006_add_dates_to_round'), + ] + + operations = [ + migrations.AlterField( + model_name='round', + name='end_date', + field=models.DateField(blank=True, default=datetime.date.today, help_text='When no end date is provided the round will remain open indefinitely.', null=True), + ), + migrations.AlterField( + model_name='round', + name='start_date', + field=models.DateField(default=datetime.date.today), + ), + ] diff --git a/opentech/apply/funds/models.py b/opentech/apply/funds/models.py index 6752f5165788346836a0e80d4d03a8d67ded24d7..9f575ea6a3e22748e9fd76da3268daa293cbf0c0 100644 --- a/opentech/apply/funds/models.py +++ b/opentech/apply/funds/models.py @@ -1,8 +1,18 @@ +from datetime import date + +from django.core.exceptions import ValidationError from django.db import models +from django.db.models import Q +from django.http import Http404 +from django.urls import reverse +from django.utils.text import mark_safe + from modelcluster.fields import ParentalKey from wagtail.wagtailadmin.edit_handlers import ( FieldPanel, InlinePanel, + FieldRowPanel, + MultiFieldPanel, StreamFieldPanel, ) from wagtail.wagtailcore.fields import StreamField @@ -21,11 +31,16 @@ WORKFLOW_CLASS = { } +def admin_url(page): + return reverse('wagtailadmin_pages:edit', args=(page.id,)) + + class FundType(AbstractStreamForm): parent_page_types = ['apply_home.ApplyHomePage'] - subpage_types = [] # type: ignore + subpage_types = ['funds.Round'] base_form_class = WorkflowFormAdminForm + WORKFLOWS = { 'single': SingleStage.name, 'double': DoubleStage.name, @@ -41,11 +56,30 @@ class FundType(AbstractStreamForm): def workflow_class(self): return WORKFLOW_CLASS[self.get_workflow_display()] + @property + def open_round(self): + rounds = Round.objects.child_of(self).live().public().specific() + return rounds.filter( + Q(start_date__lte=date.today()) & + Q(Q(end_date__isnull=True) | Q(end_date__gte=date.today())) + ).first() + + def next_deadline(self): + return self.open_round.end_date + content_panels = AbstractStreamForm.content_panels + [ FieldPanel('workflow'), InlinePanel('forms', label="Forms"), ] + def serve(self, request): + if hasattr(request, 'is_preview'): + return super().serve(request) + + # delegate to the open_round to use the latest form instances + request.show_page = True + return self.open_round.serve(request) + class FundForm(Orderable): form = models.ForeignKey('ApplicationForm') @@ -67,3 +101,84 @@ class ApplicationForm(models.Model): def __str__(self): return self.name + + +class Round(AbstractStreamForm): + parent_page_types = ['funds.FundType'] + subpage_types = [] # type: ignore + + start_date = models.DateField(default=date.today) + end_date = models.DateField( + blank=True, + null=True, + default=date.today, + help_text='When no end date is provided the round will remain open indefinitely.' + ) + + content_panels = AbstractStreamForm.content_panels + [ + MultiFieldPanel([ + FieldRowPanel([ + FieldPanel('start_date'), + FieldPanel('end_date'), + ]), + ], heading="Dates") + ] + + def get_defined_fields(self): + # Only return the first form, will need updating for when working with 2 stage WF + return self.get_parent().specific.forms.all()[0].fields + + def clean(self): + super().clean() + + if self.end_date and self.start_date > self.end_date: + raise ValidationError({ + 'end_date': 'End date must come after the start date', + }) + + if self.end_date: + conflict_query = ( + Q(start_date__range=[self.start_date, self.end_date]) | + Q(end_date__range=[self.start_date, self.end_date]) | + Q(start_date__lte=self.start_date, end_date__gte=self.end_date) + ) + else: + conflict_query = ( + Q(start_date__lte=self.start_date, end_date__isnull=True) | + Q(end_date__gte=self.start_date) + ) + + if hasattr(self, 'parent_page'): + # Check if the create hook has added the parent page, we aren't an object yet. + # Ensures we can access related objects during the clean phase instead of save. + base_query = Round.objects.child_of(self.parent_page) + else: + # don't need parent page, we are an actual object now. + base_query = Round.objects.sibling_of(self) + + conflicting_rounds = base_query.filter( + conflict_query + ).exclude(id=self.id) + + if conflicting_rounds.exists(): + error_message = mark_safe('Overlaps with the following rounds:<br> {}'.format( + '<br>'.join([ + f'<a href="{admin_url(round)}">{round.title}</a>: {round.start_date} - {round.end_date}' + for round in conflicting_rounds] + ) + )) + error = { + 'start_date': error_message, + } + if self.end_date: + error['end_date'] = error_message + + raise ValidationError(error) + + def serve(self, request): + if hasattr(request, 'is_preview') or hasattr(request, 'show_page'): + return super().serve(request) + + # We hide the round as only the open round is used which is displayed through the + # fund page + raise Http404() diff --git a/opentech/apply/funds/templates/funds/fund_type.html b/opentech/apply/funds/templates/funds/fund_type.html index 8b61a7da8ef66e6687e719402ce64fadb51f8593..4d3a7bbf85264de9a0cfb14f79cfa5970c8071e1 100644 --- a/opentech/apply/funds/templates/funds/fund_type.html +++ b/opentech/apply/funds/templates/funds/fund_type.html @@ -2,9 +2,9 @@ {% load wagtailcore_tags %} {% block content %} - <h1>{{ page.title }}</h1> + <h1>{% block title %}{{ page.title }}{% endblock %}</h1> - <form action="{% pageurl page %}" method="POST"> + <form action="" method="POST"> {% csrf_token %} {% if form.errors or form.non_field_errors %} diff --git a/opentech/apply/funds/templates/funds/round.html b/opentech/apply/funds/templates/funds/round.html new file mode 100644 index 0000000000000000000000000000000000000000..db8010fbcb27e11a35626b7994e6314024ffcdce --- /dev/null +++ b/opentech/apply/funds/templates/funds/round.html @@ -0,0 +1,3 @@ +{% extends "funds/fund_type.html" %} + +{% block title %}{{ page.get_parent.title }}{% endblock %} diff --git a/opentech/apply/funds/templates/funds/round_landing.html b/opentech/apply/funds/templates/funds/round_landing.html new file mode 100644 index 0000000000000000000000000000000000000000..f4f58593182e3d30195aaece1dbceafd2ba7f6e0 --- /dev/null +++ b/opentech/apply/funds/templates/funds/round_landing.html @@ -0,0 +1,3 @@ +{% extends "funds/fund_type_landing.html" %} + +{% block page_title %}{{ page.get_parent.title }}{% endblock %} diff --git a/opentech/apply/funds/tests/factories.py b/opentech/apply/funds/tests/factories.py index 59ce824cd9c725ae836c50b3fc64927ead04a381..aa37d55a8e2ad6bfe00e33916aa381eedaac16d4 100644 --- a/opentech/apply/funds/tests/factories.py +++ b/opentech/apply/funds/tests/factories.py @@ -1,8 +1,10 @@ +import datetime + from django.forms import Form import factory import wagtail_factories -from opentech.apply.funds.models import ApplicationForm, FundType, FundForm +from opentech.apply.funds.models import ApplicationForm, FundType, FundForm, Round from opentech.apply.funds.workflow import Action, Phase, Stage, Workflow @@ -135,3 +137,12 @@ class ApplicationFormFactory(factory.DjangoModelFactory): model = ApplicationForm name = factory.Faker('word') + + +class RoundFactory(wagtail_factories.PageFactory): + class Meta: + model = Round + + title = factory.Sequence('Round {}'.format) + start_date = factory.LazyFunction(datetime.date.today) + end_date = factory.LazyFunction(lambda: datetime.date.today() + datetime.timedelta(days=7)) diff --git a/opentech/apply/funds/tests/test_models.py b/opentech/apply/funds/tests/test_models.py index a1ba5de9ec4cb22c95db5da41d80ac8a89a8306a..431676d67ef2af9934ae104923530950c794edf3 100644 --- a/opentech/apply/funds/tests/test_models.py +++ b/opentech/apply/funds/tests/test_models.py @@ -1,8 +1,15 @@ +from datetime import date, timedelta + +from django.core.exceptions import ValidationError from django.test import TestCase from opentech.apply.funds.workflow import SingleStage -from .factories import FundTypeFactory +from .factories import FundTypeFactory, RoundFactory + + +def days_from_today(days): + return date.today() + timedelta(days=days) class TestFundModel(TestCase): @@ -10,3 +17,108 @@ class TestFundModel(TestCase): fund = FundTypeFactory(parent=None) self.assertEqual(fund.workflow, 'single') self.assertEqual(fund.workflow_class, SingleStage) + + def test_no_open_rounds(self): + fund = FundTypeFactory(parent=None) + self.assertIsNone(fund.open_round) + + def test_open_ended_round(self): + fund = FundTypeFactory(parent=None) + open_round = RoundFactory(end_date=None, parent=fund) + self.assertEqual(fund.open_round, open_round) + + def test_normal_round(self): + fund = FundTypeFactory(parent=None) + open_round = RoundFactory(parent=fund) + self.assertEqual(fund.open_round, open_round) + + def test_closed_round(self): + fund = FundTypeFactory(parent=None) + yesterday = days_from_today(-1) + last_week = days_from_today(-7) + RoundFactory(start_date=last_week, end_date=yesterday, parent=fund) + self.assertIsNone(fund.open_round) + + def test_round_not_open(self): + fund = FundTypeFactory(parent=None) + tomorrow = days_from_today(1) + RoundFactory(start_date=tomorrow, parent=fund) + self.assertIsNone(fund.open_round) + + def test_multiple_open_rounds(self): + fund = FundTypeFactory(parent=None) + open_round = RoundFactory(parent=fund) + next_round_start = open_round.end_date + timedelta(days=1) + RoundFactory(start_date=next_round_start, end_date=None, parent=fund) + self.assertEqual(fund.open_round, open_round) + + +class TestRoundModel(TestCase): + def setUp(self): + self.fund = FundTypeFactory(parent=None) + + def make_round(self, **kwargs): + data = {'parent': self.fund} + data.update(kwargs) + return RoundFactory(**data) + + def test_normal_start_end_doesnt_error(self): + self.make_round() + + def test_end_before_start(self): + yesterday = date.today() - timedelta(days=1) + with self.assertRaises(ValidationError): + self.make_round(end_date=yesterday) + + def test_end_overlaps(self): + existing_round = self.make_round() + overlapping_end = existing_round.end_date - timedelta(1) + start = existing_round.start_date - timedelta(1) + with self.assertRaises(ValidationError): + self.make_round(start_date=start, end_date=overlapping_end) + + def test_start_overlaps(self): + existing_round = self.make_round() + overlapping_start = existing_round.start_date + timedelta(1) + end = existing_round.end_date + timedelta(1) + with self.assertRaises(ValidationError): + self.make_round(start_date=overlapping_start, end_date=end) + + def test_inside_overlaps(self): + existing_round = self.make_round() + overlapping_start = existing_round.start_date + timedelta(1) + overlapping_end = existing_round.end_date - timedelta(1) + with self.assertRaises(ValidationError): + self.make_round(start_date=overlapping_start, end_date=overlapping_end) + + def test_other_fund_not_impacting(self): + self.make_round() + new_fund = FundTypeFactory(parent=None) + # Will share the same start and end dates + self.make_round(parent=new_fund) + + def test_can_create_without_end_date(self): + self.make_round(end_date=None) + + def test_can_not_create_with_other_open_end_date(self): + existing_round = self.make_round(end_date=None) + start = existing_round.start_date + timedelta(1) + with self.assertRaises(ValidationError): + self.make_round(start_date=start, end_date=None) + + def test_can_not_overlap_with_normal_round(self): + existing_round = self.make_round() + overlapping_start = existing_round.end_date - timedelta(1) + with self.assertRaises(ValidationError): + self.make_round(start_date=overlapping_start, end_date=None) + + def test_can_not_overlap_clean(self): + existing_round = self.make_round() + overlapping_start = existing_round.end_date - timedelta(1) + new_round = RoundFactory.build(start_date=overlapping_start, end_date=None) + + # we add on the parent page which gets included from a pre_create_hook + new_round.parent_page = self.fund + + with self.assertRaises(ValidationError): + new_round.clean() diff --git a/opentech/apply/funds/wagtail_hooks.py b/opentech/apply/funds/wagtail_hooks.py index d9ad3c978b7ba35f8d762f27cde028290befa828..178e17a6694c669f0b8b181bcbc7801cbcf7c7d7 100644 --- a/opentech/apply/funds/wagtail_hooks.py +++ b/opentech/apply/funds/wagtail_hooks.py @@ -1,6 +1,15 @@ +from wagtail.wagtailcore import hooks from wagtail.contrib.modeladmin.options import modeladmin_register from .admin import ApplyAdminGroup +from .models import Round modeladmin_register(ApplyAdminGroup) + + +@hooks.register('before_create_page') +def before_create_page(request, parent_page, page_class): + if page_class == Round: + page_class.parent_page = parent_page + return page_class diff --git a/opentech/public/funds/models.py b/opentech/public/funds/models.py index 0854ec5db9f1ebbf5937f77a0979b18bbb498af5..91d6c118aef8a7cb9bc372fcc84ed958503d24f3 100644 --- a/opentech/public/funds/models.py +++ b/opentech/public/funds/models.py @@ -47,6 +47,14 @@ class FundPage(BasePage): InlinePanel('related_pages', label="Related pages"), ] + @property + def is_open(self): + return bool(self.fund_type.specific.open_round) + + @property + def deadline(self): + return self.fund_type.specific.next_deadline() + class FundIndex(BasePage): subpage_types = ['FundPage'] diff --git a/opentech/public/funds/templates/public_funds/fund_page.html b/opentech/public/funds/templates/public_funds/fund_page.html index 7c6f6f51608f36eee2a489ef095097807322574e..56f758dd6919633d540c6fe75f83b5923e86ef4c 100644 --- a/opentech/public/funds/templates/public_funds/fund_page.html +++ b/opentech/public/funds/templates/public_funds/fund_page.html @@ -2,7 +2,7 @@ {% load wagtailcore_tags wagtailimages_tags navigation_tags static %} {% block content %} - {% include "public_funds/includes/fund_apply_cta.html" with fund_type=page.fund_type.specific %} + {% include "public_funds/includes/fund_apply_cta.html" with fund_page=page %} <div class="wrapper wrapper--flex"> <section class="section section--main"> <h1>{{ page.title }}</h1> @@ -11,6 +11,6 @@ {% include_block page.body %} </section> </div> - {% include "public_funds/includes/fund_apply_cta.html" with fund_type=page.fund_type.specific %} + {% include "public_funds/includes/fund_apply_cta.html" with fund_page=page %} {% include "includes/relatedcontent.html" with related_pages=page.related_pages.all %} {% endblock %} diff --git a/opentech/public/funds/templates/public_funds/includes/fund_apply_cta.html b/opentech/public/funds/templates/public_funds/includes/fund_apply_cta.html index 1f81aa425819a5ce32bce596c0833547d4009086..b76d9adac020f2eed829b81d4cdd319f1023d929 100644 --- a/opentech/public/funds/templates/public_funds/includes/fund_apply_cta.html +++ b/opentech/public/funds/templates/public_funds/includes/fund_apply_cta.html @@ -2,12 +2,14 @@ <div class="wrapper wrapper--flex"> <div class="section section--apply-cta"> - {% if fund_type.deadline %} + {% if fund_page.is_open %} + {% if fund_page.deadline %} <div class="deadline"> - {% trans "Next deadline" %}: {{ fund_type.deadline|date:"M j, Y" }} + {% trans "Next deadline" %}: {{ fund_page.deadline|date:"M j, Y" }} </div> + {% endif %} <div class="apply-link"> - <a class="button" href="{% pageurl fund_type %}">{% trans "Apply for this fund" %}</a> + <a class="button" href="{% pageurl fund_page.fund_type %}">{% trans "Apply for this fund" %}</a> </div> {% else %} <div class="deadline"> diff --git a/opentech/templates/base.html b/opentech/templates/base.html index e2699382f6746b87218a6f5c22d40b4f73d177b0..2400f7871f65d77802e012a14bccc5f25186f267 100644 --- a/opentech/templates/base.html +++ b/opentech/templates/base.html @@ -137,7 +137,7 @@ </div> <div class="wrapper wrapper--small"> - <h1 class="header__title">{{ page.title }}</h1> + <h1 class="header__title">{% block page_title %}{{ page.title }}{% endblock %}</h1> </div> <svg class="header__icon header__icon--pixels header__icon--pixels-left"><use xlink:href="#hero-standard-left-pixels"></use></svg> <svg class="header__icon header__icon--pixels header__icon--pixels-right"><use xlink:href="#hero-standard-right-pixels"></use></svg>