From 383e0336c85c95dd9b1d8edae0e8b37b56e9b4a5 Mon Sep 17 00:00:00 2001
From: Todd Dembrey <todd.dembrey@torchbox.com>
Date: Wed, 1 Aug 2018 15:58:55 +0100
Subject: [PATCH] Create the RFP interface and basic sealed round objects

---
 opentech/apply/funds/admin.py                 | 25 ++++--
 opentech/apply/funds/admin_helpers.py         | 11 ++-
 .../funds/migrations/0037_add_rfp_classes.py  | 67 +++++++++++++++
 .../funds/migrations/0038_round_sealed.py     | 18 ++++
 .../0039_create_sealed_round_page.py          | 41 +++++++++
 .../funds/migrations/0040_sealedroundforn.py  | 28 +++++++
 opentech/apply/funds/models.py                | 84 ++++++++++++++-----
 opentech/apply/home/models.py                 |  2 +-
 8 files changed, 248 insertions(+), 28 deletions(-)
 create mode 100644 opentech/apply/funds/migrations/0037_add_rfp_classes.py
 create mode 100644 opentech/apply/funds/migrations/0038_round_sealed.py
 create mode 100644 opentech/apply/funds/migrations/0039_create_sealed_round_page.py
 create mode 100644 opentech/apply/funds/migrations/0040_sealedroundforn.py

diff --git a/opentech/apply/funds/admin.py b/opentech/apply/funds/admin.py
index 9cde07a20..d49ce33d6 100644
--- a/opentech/apply/funds/admin.py
+++ b/opentech/apply/funds/admin.py
@@ -9,13 +9,11 @@ from .admin_helpers import (
     FormsFundRoundListFilter,
     RoundFundChooserView,
 )
-from .models import ApplicationForm, FundType, LabType, Round
+from .models import ApplicationForm, FundType, LabType, RequestForPartners, Round, SealedRound
 from opentech.apply.categories.admin import CategoryAdmin
 
 
-class RoundAdmin(ModelAdmin):
-    model = Round
-    menu_icon = 'repeat'
+class BaseRoundAdmin(ModelAdmin):
     choose_parent_view_class = RoundFundChooserView
     choose_parent_template_name = 'funds/admin/parent_chooser.html'
     list_display = ('title', 'fund', 'start_date', 'end_date')
@@ -25,12 +23,29 @@ class RoundAdmin(ModelAdmin):
         return obj.get_parent()
 
 
+class RoundAdmin(BaseRoundAdmin):
+    model = Round
+    menu_icon = 'repeat'
+
+
+class SealedRoundAdmin(BaseRoundAdmin):
+    model = SealedRound
+    menu_icon = 'locked'
+    menu_label = 'Sealed Rounds'
+
+
 class FundAdmin(ModelAdmin):
     model = FundType
     menu_icon = 'doc-empty'
     menu_label = 'Funds'
 
 
+class RFPAdmin(ModelAdmin):
+    model = RequestForPartners
+    menu_icon = 'group'
+    menu_label = 'Request For Partners'
+
+
 class LabAdmin(ModelAdmin):
     model = LabType
     menu_icon = 'doc-empty'
@@ -71,4 +86,4 @@ class ApplicationFormAdmin(ModelAdmin):
 class ApplyAdminGroup(ModelAdminGroup):
     menu_label = 'Apply'
     menu_icon = 'folder-open-inverse'
-    items = (RoundAdmin, FundAdmin, LabAdmin, ApplicationFormAdmin, ReviewFormAdmin, CategoryAdmin)
+    items = (RoundAdmin, SealedRoundAdmin, FundAdmin, LabAdmin, RFPAdmin, ApplicationFormAdmin, ReviewFormAdmin, CategoryAdmin)
diff --git a/opentech/apply/funds/admin_helpers.py b/opentech/apply/funds/admin_helpers.py
index 212a5f4be..f0a0002ea 100644
--- a/opentech/apply/funds/admin_helpers.py
+++ b/opentech/apply/funds/admin_helpers.py
@@ -9,10 +9,15 @@ from wagtail.contrib.modeladmin.views import ChooseParentView
 from wagtail.core.models import Page
 
 
+class VerboseLabelModelChoiceField(forms.ModelChoiceField):
+    def label_from_instance(str, obj):
+        return '[{}] {}'.format(obj._meta.verbose_name, obj.title)
+
+
 class FundChooserForm(ParentChooserForm):
     """Changes the default chooser to be fund orientated """
-    parent_page = forms.ModelChoiceField(
-        label=_('Fund'),
+    parent_page = VerboseLabelModelChoiceField(
+        label=_('Fund or RFP'),
         required=True,
         empty_label=None,
         queryset=Page.objects.none(),
@@ -22,7 +27,7 @@ class FundChooserForm(ParentChooserForm):
 
 class RoundFundChooserView(ChooseParentView):
     def get_form(self, request):
-        parents = self.permission_helper.get_valid_parent_pages(request.user)
+        parents = self.permission_helper.get_valid_parent_pages(request.user).specific()
         return FundChooserForm(parents, request.POST or None)
 
 
diff --git a/opentech/apply/funds/migrations/0037_add_rfp_classes.py b/opentech/apply/funds/migrations/0037_add_rfp_classes.py
new file mode 100644
index 000000000..e2c201f30
--- /dev/null
+++ b/opentech/apply/funds/migrations/0037_add_rfp_classes.py
@@ -0,0 +1,67 @@
+# Generated by Django 2.0.2 on 2018-08-01 14:02
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import modelcluster.fields
+import opentech.apply.stream_forms.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('wagtailcore', '0040_page_draft_title'),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('review', '0006_remove_review_review'),
+        ('funds', '0036_fundreviewform_labreviewform'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='RequestForPartners',
+            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')),
+                ('to_address', models.CharField(blank=True, help_text='Optional - form submissions will be emailed to these addresses. Separate multiple addresses by comma.', max_length=255, verbose_name='to address')),
+                ('from_address', models.CharField(blank=True, max_length=255, verbose_name='from address')),
+                ('subject', models.CharField(blank=True, max_length=255, verbose_name='subject')),
+                ('workflow_name', models.CharField(choices=[('single', 'Request'), ('double', 'Concept & Proposal')], default='single', max_length=100, verbose_name='Workflow')),
+                ('confirmation_text_extra', models.TextField(blank=True, help_text='Additional text for the application confirmation message.')),
+                ('reviewers', modelcluster.fields.ParentalManyToManyField(blank=True, limit_choices_to={'groups__name': 'Reviewer'}, related_name='requestforpartners_reviewers', to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'verbose_name': 'RFP',
+            },
+            bases=(opentech.apply.stream_forms.models.BaseStreamForm, 'wagtailcore.page', models.Model),
+        ),
+        migrations.CreateModel(
+            name='RFPForm',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
+                ('form', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='funds.ApplicationForm')),
+                ('rfp', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='forms', to='funds.RequestForPartners')),
+            ],
+            options={
+                'ordering': ['sort_order'],
+                'abstract': False,
+            },
+        ),
+        migrations.CreateModel(
+            name='RFPReviewForm',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
+                ('form', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='review.ReviewForm')),
+                ('rfp', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='review_forms', to='funds.RequestForPartners')),
+            ],
+            options={
+                'ordering': ['sort_order'],
+                'abstract': False,
+            },
+        ),
+        migrations.AlterField(
+            model_name='fundtype',
+            name='reviewers',
+            field=modelcluster.fields.ParentalManyToManyField(blank=True, limit_choices_to={'groups__name': 'Reviewer'}, related_name='fundtype_reviewers', to=settings.AUTH_USER_MODEL),
+        ),
+    ]
diff --git a/opentech/apply/funds/migrations/0038_round_sealed.py b/opentech/apply/funds/migrations/0038_round_sealed.py
new file mode 100644
index 000000000..a4a8c6cb4
--- /dev/null
+++ b/opentech/apply/funds/migrations/0038_round_sealed.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.2 on 2018-08-01 14:17
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('funds', '0037_add_rfp_classes'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='round',
+            name='sealed',
+            field=models.BooleanField(default=False),
+        ),
+    ]
diff --git a/opentech/apply/funds/migrations/0039_create_sealed_round_page.py b/opentech/apply/funds/migrations/0039_create_sealed_round_page.py
new file mode 100644
index 000000000..108a44c47
--- /dev/null
+++ b/opentech/apply/funds/migrations/0039_create_sealed_round_page.py
@@ -0,0 +1,41 @@
+# Generated by Django 2.0.2 on 2018-08-01 16:08
+
+import datetime
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import modelcluster.fields
+import opentech.apply.stream_forms.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('wagtailcore', '0040_page_draft_title'),
+        ('funds', '0038_round_sealed'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='SealedRound',
+            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')),
+                ('workflow_name', models.CharField(choices=[('single', 'Request'), ('double', 'Concept & Proposal')], default='single', max_length=100, verbose_name='Workflow')),
+                ('start_date', models.DateField(default=datetime.date.today)),
+                ('end_date', models.DateField(blank=True, default=datetime.date.today, help_text='When no end date is provided the round will remain open indefinitely.', null=True)),
+                ('sealed', models.BooleanField(default=False)),
+                ('lead', models.ForeignKey(limit_choices_to={'groups__name': 'Staff'}, on_delete=django.db.models.deletion.PROTECT, related_name='sealedround_lead', to=settings.AUTH_USER_MODEL)),
+                ('reviewers', modelcluster.fields.ParentalManyToManyField(blank=True, limit_choices_to={'groups__name': 'Reviewer'}, related_name='sealedround_reviewer', to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'abstract': False,
+            },
+            bases=(opentech.apply.stream_forms.models.BaseStreamForm, 'wagtailcore.page', models.Model),
+        ),
+        migrations.AlterField(
+            model_name='round',
+            name='reviewers',
+            field=modelcluster.fields.ParentalManyToManyField(blank=True, limit_choices_to={'groups__name': 'Reviewer'}, related_name='round_reviewer', to=settings.AUTH_USER_MODEL),
+        ),
+    ]
diff --git a/opentech/apply/funds/migrations/0040_sealedroundforn.py b/opentech/apply/funds/migrations/0040_sealedroundforn.py
new file mode 100644
index 000000000..4884447ae
--- /dev/null
+++ b/opentech/apply/funds/migrations/0040_sealedroundforn.py
@@ -0,0 +1,28 @@
+# Generated by Django 2.0.2 on 2018-08-01 16:28
+
+from django.db import migrations, models
+import django.db.models.deletion
+import modelcluster.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('funds', '0039_create_sealed_round_page'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='SealedRoundForn',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
+                ('form', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='funds.ApplicationForm')),
+                ('round', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='forms', to='funds.SealedRound')),
+            ],
+            options={
+                'ordering': ['sort_order'],
+                'abstract': False,
+            },
+        ),
+    ]
diff --git a/opentech/apply/funds/models.py b/opentech/apply/funds/models.py
index cb4024d94..83abe9c9c 100644
--- a/opentech/apply/funds/models.py
+++ b/opentech/apply/funds/models.py
@@ -168,35 +168,39 @@ class EmailForm(AbstractEmailForm):
     email_tab = ObjectList(email_confirmation_panels, heading='Confirmation email')
 
 
-class FundType(EmailForm, WorkflowStreamForm):  # type: ignore
+class RoundBasedParent(EmailForm, WorkflowStreamForm):  # type: ignore
     class Meta:
-        verbose_name = _("Fund")
+        abstract = True
 
     # Adds validation around forms & workflows. Isn't on Workflow class due to not displaying workflow field on Round
     base_form_class = WorkflowFormAdminForm
 
     reviewers = ParentalManyToManyField(
         settings.AUTH_USER_MODEL,
-        related_name='fund_reviewers',
+        related_name='%(class)s_reviewers',
         limit_choices_to=LIMIT_TO_REVIEWERS,
         blank=True,
     )
 
     parent_page_types = ['apply_home.ApplyHomePage']
-    subpage_types = ['funds.Round']
 
     def detail(self):
         # The location to find out more information
         return self.fund_public.first()
 
-    @property
-    def open_round(self):
-        rounds = Round.objects.child_of(self).live().public().specific()
+    def _open_for(self, round_type):
+        rounds = round_type.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()
 
+    @property
+    def open_round(self):
+        open_round = self._open_for(Round)
+        open_sealed_round = self._open_for(SealedRound)
+        return open_round or open_sealed_round
+
     def next_deadline(self):
         try:
             return self.open_round.end_date
@@ -223,6 +227,20 @@ class FundType(EmailForm, WorkflowStreamForm):  # type: ignore
     ])
 
 
+class FundType(RoundBasedParent):
+    subpage_types = ['funds.Round']
+
+    class Meta:
+        verbose_name = _("Fund")
+
+
+class RequestForPartners(RoundBasedParent):
+    subpage_types = ['funds.Round', 'funds.SealedRound']
+
+    class Meta:
+        verbose_name = _("RFP")
+
+
 class AbstractRelatedForm(Orderable):
     form = models.ForeignKey('ApplicationForm', on_delete=models.PROTECT)
 
@@ -247,6 +265,22 @@ class AbstractRelatedForm(Orderable):
         return self.form.name
 
 
+class FundForm(AbstractRelatedForm):
+    fund = ParentalKey('FundType', related_name='forms')
+
+
+class RoundForm(AbstractRelatedForm):
+    round = ParentalKey('Round', related_name='forms')
+
+
+class SealedRoundForn(AbstractRelatedForm):
+    round = ParentalKey('SealedRound', related_name='forms')
+
+
+class RFPForm(AbstractRelatedForm):
+    rfp = ParentalKey('RequestForPartners', related_name='forms')
+
+
 class AbstractRelatedReviewForm(Orderable):
     form = models.ForeignKey('review.ReviewForm', on_delete=models.PROTECT)
 
@@ -271,18 +305,14 @@ class AbstractRelatedReviewForm(Orderable):
         return self.form.name
 
 
-class FundForm(AbstractRelatedForm):
-    fund = ParentalKey('FundType', related_name='forms')
-
-
-class RoundForm(AbstractRelatedForm):
-    round = ParentalKey('Round', related_name='forms')
-
-
 class FundReviewForm(AbstractRelatedReviewForm):
     fund = ParentalKey('FundType', related_name='review_forms')
 
 
+class RFPReviewForm(AbstractRelatedReviewForm):
+    rfp = ParentalKey('RequestForPartners', related_name='review_forms')
+
+
 class ApplicationForm(models.Model):
     name = models.CharField(max_length=255)
     form_fields = StreamField(ApplicationCustomFormFieldsBlock())
@@ -296,19 +326,22 @@ class ApplicationForm(models.Model):
         return self.name
 
 
-class Round(WorkflowStreamForm, SubmittableStreamForm):  # type: ignore
-    parent_page_types = ['funds.FundType']
+class AbstractRound(WorkflowStreamForm, SubmittableStreamForm):  # type: ignore
+    class Meta:
+        abstract = True
+
+    parent_page_types = ['funds.FundType', 'funds.RequestForPartners']
     subpage_types = []  # type: ignore
 
     lead = models.ForeignKey(
         settings.AUTH_USER_MODEL,
         limit_choices_to=LIMIT_TO_STAFF,
-        related_name='round_lead',
+        related_name='%(class)s_lead',
         on_delete=models.PROTECT,
     )
     reviewers = ParentalManyToManyField(
         settings.AUTH_USER_MODEL,
-        related_name='rounds_reviewer',
+        related_name='%(class)s_reviewer',
         limit_choices_to=LIMIT_TO_REVIEWERS,
         blank=True,
     )
@@ -319,6 +352,7 @@ class Round(WorkflowStreamForm, SubmittableStreamForm):  # type: ignore
         default=date.today,
         help_text='When no end date is provided the round will remain open indefinitely.'
     )
+    sealed = models.BooleanField(default=False)
 
     content_panels = SubmittableStreamForm.content_panels + [
         FieldPanel('lead'),
@@ -426,6 +460,18 @@ class Round(WorkflowStreamForm, SubmittableStreamForm):  # type: ignore
         raise Http404()
 
 
+class Round(AbstractRound):
+    parent_page_types = ['funds.FundType', 'funds.RequestForPartners']
+
+
+class SealedRound(AbstractRound):
+    parent_page_types = ['funds.RequestForPartners']
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.sealed = True
+
+
 class LabType(EmailForm, WorkflowStreamForm, SubmittableStreamForm):  # type: ignore
     class Meta:
         verbose_name = _("Lab")
diff --git a/opentech/apply/home/models.py b/opentech/apply/home/models.py
index 41c079730..cc25885c0 100644
--- a/opentech/apply/home/models.py
+++ b/opentech/apply/home/models.py
@@ -8,7 +8,7 @@ from django.db import models
 class ApplyHomePage(Page):
     # Only allow creating HomePages at the root level
     parent_page_types = ['wagtailcore.Page']
-    subpage_types = ['funds.FundType', 'funds.LabType']
+    subpage_types = ['funds.FundType', 'funds.LabType', 'funds.RequestForPartners']
 
     strapline = models.CharField(blank=True, max_length=255)
 
-- 
GitLab