From c4089a7613c9a889f3489af38e45509e40a641cd Mon Sep 17 00:00:00 2001
From: Todd Dembrey <todd.dembrey@torchbox.com>
Date: Fri, 1 Nov 2019 16:44:46 +0000
Subject: [PATCH] Feature/gh 1619 report table (#1634)

* Add basic listing on the project page
* Add tests for new current object and updates to factories
---
 .../apply/funds/tests/factories/models.py     | 19 +----
 opentech/apply/home/factories.py              | 20 +++---
 opentech/apply/projects/forms.py              |  8 ++-
 .../migrations/0027_report_current.py         | 19 +++++
 opentech/apply/projects/models.py             | 47 +++++++++++-
 .../includes/reports.html                     | 72 +++++++++++++++++++
 .../application_projects/project_detail.html  | 13 +---
 opentech/apply/projects/tests/factories.py    | 19 ++++-
 opentech/apply/projects/tests/test_models.py  | 15 +++-
 opentech/apply/projects/tests/test_views.py   | 18 +++++
 opentech/apply/projects/views/report.py       |  9 ++-
 .../apply/stream_forms/testing/factories.py   |  6 +-
 12 files changed, 213 insertions(+), 52 deletions(-)
 create mode 100644 opentech/apply/projects/migrations/0027_report_current.py
 create mode 100644 opentech/apply/projects/templates/application_projects/includes/reports.html

diff --git a/opentech/apply/funds/tests/factories/models.py b/opentech/apply/funds/tests/factories/models.py
index 20fd24800..7ded87fb7 100644
--- a/opentech/apply/funds/tests/factories/models.py
+++ b/opentech/apply/funds/tests/factories/models.py
@@ -72,22 +72,12 @@ class AbstractApplicationFactory(wagtail_factories.PageFactory):
         workflow_stages = 1
 
     title = factory.Faker('sentence')
+    parent = factory.SubFactory(ApplyHomePageFactory)
 
     # Will need to update how the stages are identified as Fund Page changes
     workflow_name = factory.LazyAttribute(lambda o: workflow_for_stages(o.workflow_stages))
     approval_form = factory.SubFactory('opentech.apply.projects.tests.factories.ProjectApprovalFormFactory')
 
-    @factory.post_generation
-    def parent(self, create, extracted_parent, **parent_kwargs):
-        # THIS MUST BE THE FIRST POST GENERATION METHOD OR THE OBJECT WILL BE UNSAVED
-        if create:
-            if extracted_parent and parent_kwargs:
-                raise ValueError('Cant pass a parent instance and attributes')
-
-            parent = extracted_parent or ApplyHomePageFactory(**parent_kwargs)
-
-            parent.add_child(instance=self)
-
     @factory.post_generation
     def forms(self, create, extracted, **kwargs):
         if create:
@@ -152,12 +142,7 @@ class RoundFactory(wagtail_factories.PageFactory):
     start_date = factory.Sequence(lambda n: datetime.date.today() + datetime.timedelta(days=7 * n + 1))
     end_date = factory.Sequence(lambda n: datetime.date.today() + datetime.timedelta(days=7 * (n + 1)))
     lead = factory.SubFactory(StaffFactory)
-
-    @factory.post_generation
-    def parent(self, create, extracted_parent, **parent_kwargs):
-        if create:
-            parent = extracted_parent or FundTypeFactory(**parent_kwargs)
-            parent.add_child(instance=self)
+    parent = factory.SubFactory(FundTypeFactory)
 
     @factory.post_generation
     def forms(self, create, extracted, **kwargs):
diff --git a/opentech/apply/home/factories.py b/opentech/apply/home/factories.py
index 89628412d..bd18111c0 100644
--- a/opentech/apply/home/factories.py
+++ b/opentech/apply/home/factories.py
@@ -1,10 +1,17 @@
-import factory
 import wagtail_factories
 
 from .models import ApplyHomePage
 
 
+class ApplySiteFactory(wagtail_factories.SiteFactory):
+    class Meta:
+        django_get_or_create = ('hostname',)
+
+
 class ApplyHomePageFactory(wagtail_factories.PageFactory):
+    title = "Apply Home"
+    slug = 'apply'
+
     class Meta:
         model = ApplyHomePage
 
@@ -15,14 +22,3 @@ class ApplyHomePageFactory(wagtail_factories.PageFactory):
             return model_class.objects.get(slug=kwargs['slug'])
         except model_class.DoesNotExist:
             return super()._create(model_class, *args, **kwargs)
-
-    @factory.post_generation
-    def parent(self, create, extracted_parent, **parent_kwargs):
-        if create and not self.get_parent():
-            root = ApplyHomePage.get_first_root_node()
-            root.add_child(instance=self)
-
-    @factory.post_generation
-    def site(self, create, extracted_site, **site_kwargs):
-        if create:
-            wagtail_factories.SiteFactory(root_page=self, is_default_site=True)
diff --git a/opentech/apply/projects/forms.py b/opentech/apply/projects/forms.py
index e4ae81e4f..3e1f9f4cf 100644
--- a/opentech/apply/projects/forms.py
+++ b/opentech/apply/projects/forms.py
@@ -385,14 +385,16 @@ class ReportEditForm(forms.ModelForm):
         fields = ['public']
 
     def save(self, commit=True):
-        instance = super().save(commit)
-
         version = ReportVersion.objects.create(
-            report=instance,
+            report=self.instance,
             content=self.cleaned_data['content'],
             submitted=timezone.now(),
         )
 
+        self.instance.current = version
+
+        instance = super().save(commit)
+
         files = self.cleaned_data['files']
         if files:
             ReportPrivateFiles.objects.bulk_create(
diff --git a/opentech/apply/projects/migrations/0027_report_current.py b/opentech/apply/projects/migrations/0027_report_current.py
new file mode 100644
index 000000000..5337aa8ba
--- /dev/null
+++ b/opentech/apply/projects/migrations/0027_report_current.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.1.11 on 2019-10-30 14:34
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0026_data_contract_approved_date'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='report',
+            name='current',
+            field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='live_for_report', to='application_projects.ReportVersion'),
+        ),
+    ]
diff --git a/opentech/apply/projects/models.py b/opentech/apply/projects/models.py
index eb812096d..f391daa19 100644
--- a/opentech/apply/projects/models.py
+++ b/opentech/apply/projects/models.py
@@ -11,12 +11,13 @@ from django.contrib.postgres.fields import JSONField
 from django.core.exceptions import ValidationError
 from django.core.validators import MinValueValidator
 from django.db import models
-from django.db.models import F, Max, Sum, Value as V
+from django.db.models import F, Max, Q, Sum, Value as V
 from django.db.models.functions import Coalesce
 from django.db.models.signals import post_delete
 from django.dispatch.dispatcher import receiver
 from django.urls import reverse
 from django.utils import timezone
+from django.utils.functional import cached_property
 from django.utils.translation import ugettext as _
 from wagtail.contrib.settings.models import BaseSetting, register_setting
 from wagtail.admin.edit_handlers import (
@@ -554,6 +555,12 @@ class ReportConfig(models.Model):
     occurrence = models.PositiveSmallIntegerField(default=1)
     frequency = models.CharField(choices=FREQUENCY_CHOICES, default=MONTH, max_length=5)
 
+    def past_due_reports(self):
+        return self.project.reports.filter(
+            current__isnull=True,
+            end_date__lt=timezone.now().date(),
+        ).order_by('end_date')
+
     def current_due_report(self):
         # Project not started - no reporting required
         if not self.project.start_date:
@@ -562,7 +569,7 @@ class ReportConfig(models.Model):
         today = timezone.now().date()
 
         last_report = self.project.reports.filter(
-            end_date__lt=today,
+            Q(end_date__lt=today) | Q(current__isnull=False)
         ).first()
 
         schedule_date = self.schedule_start or self.project.start_date
@@ -601,10 +608,23 @@ class ReportConfig(models.Model):
         return next_date
 
 
+class ReportQueryset(models.QuerySet):
+    def done(self):
+        return self.filter(current__isnull=False)
+
+
 class Report(models.Model):
     public = models.BooleanField(default=True)
     end_date = models.DateField()
     project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="reports")
+    current = models.OneToOneField(
+        "ReportVersion",
+        on_delete=models.CASCADE,
+        related_name='live_for_report',
+        null=True,
+    )
+
+    objects = ReportQueryset.as_manager()
 
     class Meta:
         ordering = ('-end_date',)
@@ -613,6 +633,29 @@ class Report(models.Model):
     def past_due(self):
         return timezone.now().date() > self.end_date
 
+    @property
+    def is_very_late(self):
+        more_than_one_report_late = self.project.reports.filter(end_date__gt=self.end_date).count() >= 2
+        not_submitted = not self.current
+        return not_submitted and more_than_one_report_late
+
+    @property
+    def can_submit(self):
+        return self.start_date <= timezone.now().date()
+
+    @property
+    def submitted_date(self):
+        if self.current:
+            return self.current.submitted.date()
+
+    @cached_property
+    def start_date(self):
+        last_report = self.project.reports.order_by('current__submitted').filter(end_date__lt=self.end_date).first()
+        if last_report:
+            return last_report.end_date + relativedelta(days=1)
+
+        return self.project.start_date
+
 
 class ReportVersion(models.Model):
     report = models.ForeignKey("Report", on_delete=models.CASCADE, related_name="versions")
diff --git a/opentech/apply/projects/templates/application_projects/includes/reports.html b/opentech/apply/projects/templates/application_projects/includes/reports.html
new file mode 100644
index 000000000..8a082167e
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/includes/reports.html
@@ -0,0 +1,72 @@
+<div id="payment-requests" class="payment-block">
+    <div class="payment-block__header">
+        <p class="payment-block__title">Reporting</p>
+    </div>
+    <div>
+    {% for report in object.report_config.past_due_reports %}
+        <p>
+            A report is due for the period {{ report.start_date }} to {{ report.end_date }}
+            {% if report.is_very_late %}
+                ( ! )
+            {% endif %}
+            <a
+                class="payment-block__button button button--primary"
+                href="{% url "apply:projects:reports:edit" pk=report.pk %}"
+            >
+                Add Report
+            </a>
+        </p>
+    {% endfor %}
+    {% with next_report=object.report_config.current_due_report %}
+        <p>
+            The
+            {% if next_report.can_submit %}
+            current
+            {% else %}
+            next
+            {% endif %}
+            reporting period is {{ next_report.start_date }} to {{ next_report.end_date }}
+            {% if next_report.can_submit %}
+            <a
+                class="payment-block__button button button--primary"
+                href="{% url "apply:projects:reports:edit" pk=next_report.pk %}"
+            >
+                Add Report
+            </a>
+            {% endif %}
+        </p>
+    {% endwith %}
+
+    </div>
+    <table class="payment-block__table">
+        <thead>
+            <tr>
+                <th class="payment-block__table-date">Period End</th>
+                <th class="payment-block__table-date">Submitted</th>
+                <th class="payment-block__table-date">Privacy</th>
+                <th class="payment-block__table-update"></th>
+            </tr>
+        </thead>
+        <tbody>
+            {% for report in object.reports.done %}
+            <tr>
+                <td>
+                    <span class="payment-block__mobile-label">Period End: </span>{{ report.end_date }}
+                </td>
+                <td>
+                    <span class="payment-block__mobile-label">Submitted: </span>{{ report.submitted_date|default:"-" }}
+                </td>
+                <td>
+                    <span class="payment-block__mobile-label">Privacy: </span>{% if report.public %}Public{% else %}Private{% endif %}
+                </td>
+                <td>
+                </td>
+            </tr>
+            {% empty %}
+            <tr>
+                <td colspan="4">No reports submitted</td>
+            </tr>
+            {% endfor %}
+        </tbody>
+    </table>
+</div>
diff --git a/opentech/apply/projects/templates/application_projects/project_detail.html b/opentech/apply/projects/templates/application_projects/project_detail.html
index 62f722cd4..df19d07d1 100644
--- a/opentech/apply/projects/templates/application_projects/project_detail.html
+++ b/opentech/apply/projects/templates/application_projects/project_detail.html
@@ -132,16 +132,9 @@
                 {% endif %}
 
                 {% if object.is_in_progress %}
-                    {% with next_report=object.report_config.current_due_report %}
-                    {% if next_report %}
-                    <a
-                        class="button button--primary"
-                        href="{% url "apply:projects:reports:edit" pk=next_report.pk %}"
-                    >
-                        Add Report
-                    </a>
-                    {% endif %}
-                    {% endwith %}
+                    <div class="wrapper wrapper--outer-space-large">
+                        {% include "application_projects/includes/reports.html" %}
+                    </div>
                 {% endif %}
 
                 {% if not object.is_in_progress %}
diff --git a/opentech/apply/projects/tests/factories.py b/opentech/apply/projects/tests/factories.py
index f2c96b46a..0ed9ba224 100644
--- a/opentech/apply/projects/tests/factories.py
+++ b/opentech/apply/projects/tests/factories.py
@@ -17,6 +17,7 @@ from opentech.apply.projects.models import (
     ProjectApprovalForm,
     Report,
     ReportConfig,
+    ReportVersion,
 )
 from opentech.apply.stream_forms.testing.factories import FormDataFactory, FormFieldsBlockFactory
 from opentech.apply.users.tests.factories import StaffFactory, UserFactory
@@ -145,13 +146,25 @@ class PaymentReceiptFactory(factory.DjangoModelFactory):
 
 
 class ReportConfigFactory(factory.DjangoModelFactory):
-    project = factory.SubFactory("opentech.apply.projects.tests.factories.ApprovedProjectFactory")
+    project = factory.SubFactory(
+        "opentech.apply.projects.tests.factories.ApprovedProjectFactory",
+        report_config=None,
+    )
 
     class Meta:
         model = ReportConfig
         django_get_or_create = ('project',)
 
 
+class ReportVersionFactory(factory.DjangoModelFactory):
+    report = factory.SubFactory("opentech.apply.projects.tests.factories.ReportFactory")
+    submitted = factory.LazyFunction(timezone.now)
+    content = factory.Faker('paragraph')
+
+    class Meta:
+        model = ReportVersion
+
+
 class ReportFactory(factory.DjangoModelFactory):
     project = factory.SubFactory("opentech.apply.projects.tests.factories.ApprovedProjectFactory")
     end_date = factory.LazyFunction(timezone.now)
@@ -163,7 +176,11 @@ class ReportFactory(factory.DjangoModelFactory):
         past_due = factory.Trait(
             end_date=factory.LazyFunction(lambda: timezone.now() - relativedelta(days=1))
         )
+        submitted = factory.Trait(
+            current=factory.RelatedFactory(ReportVersionFactory, 'report')
+        )
 
 
 class ApprovedProjectFactory(ProjectFactory):
     contract = factory.RelatedFactory(ContractFactory, 'project')
+    report_config = factory.RelatedFactory(ReportConfigFactory, 'project')
diff --git a/opentech/apply/projects/tests/test_models.py b/opentech/apply/projects/tests/test_models.py
index 4de065a1f..91e2540fb 100644
--- a/opentech/apply/projects/tests/test_models.py
+++ b/opentech/apply/projects/tests/test_models.py
@@ -213,8 +213,13 @@ class TestReportConfigCalculations(TestCase):
     def test_no_report_creates_report(self):
         config = ReportConfigFactory()
         report = config.current_due_report()
+        # Separate day from month for case where start date + 1 month would exceed next month
+        # length (31st Oct to 30th Nov)
+        # combined => 31th + 1 month = 30th - 1 day = 29th (wrong)
+        # separate => 31th - 1 day = 30th + 1 month = 30th (correct)
+        next_due = report.project.start_date - relativedelta(days=1) + relativedelta(months=1)
         self.assertEqual(Report.objects.count(), 1)
-        self.assertEqual(report.end_date, self.today + relativedelta(months=1, days=-1))
+        self.assertEqual(report.end_date, next_due)
 
     def test_no_report_creates_report_not_in_past(self):
         config = ReportConfigFactory(schedule_start=self.today - relativedelta(months=3))
@@ -232,9 +237,15 @@ class TestReportConfigCalculations(TestCase):
         config = ReportConfigFactory(schedule_start=self.today - relativedelta(days=2))
         ReportFactory(project=config.project, end_date=self.today - relativedelta(days=1))
 
+        # Separate day from month for case where start date + 1 month would exceed next month
+        # length (31st Oct to 30th Nov)
+        # combined => 31th + 1 month = 30th - 1 day = 29th (wrong)
+        # separate => 31th - 1 day = 30th + 1 month = 30th (correct)
+        next_due = self.today - relativedelta(days=1) + relativedelta(months=1)
+
         report = config.current_due_report()
         self.assertEqual(Report.objects.count(), 2)
-        self.assertEqual(report.end_date, self.today + relativedelta(months=1, days=-1))
+        self.assertEqual(report.end_date, next_due)
 
     def test_past_due_report_future_schedule_creates_report(self):
         config = ReportConfigFactory(schedule_start=self.today + relativedelta(days=3))
diff --git a/opentech/apply/projects/tests/test_views.py b/opentech/apply/projects/tests/test_views.py
index a572392f3..5f2bdd7d6 100644
--- a/opentech/apply/projects/tests/test_views.py
+++ b/opentech/apply/projects/tests/test_views.py
@@ -1,10 +1,12 @@
 from decimal import Decimal
 from io import BytesIO
+from dateutil.relativedelta import relativedelta
 
 from django.contrib.auth.models import AnonymousUser
 from django.core.exceptions import PermissionDenied
 from django.test import RequestFactory, TestCase, override_settings
 from django.urls import reverse
+from django.utils import timezone
 
 from opentech.apply.funds.tests.factories import LabSubmissionFactory
 from opentech.apply.users.tests.factories import (
@@ -1255,8 +1257,22 @@ class TestStaffSubmitReport(BaseViewTestCase):
     def test_submit_report(self):
         report = ReportFactory()
         response = self.post_page(report, {'content': 'Some text', 'public': True})
+        report.refresh_from_db()
         self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url()))
         self.assertEqual(report.versions.first().content, 'Some text')
+        self.assertEqual(report.versions.first(), report.current)
+
+    def test_can_submit_future_report(self):
+        submitted_report = ReportFactory(
+            end_date=timezone.now() + relativedelta(days=1),
+            submitted=True,
+        )
+        future_report = ReportFactory(
+            end_date=timezone.now() + relativedelta(days=3),
+            project=submitted_report.project,
+        )
+        response = self.post_page(future_report, {'content': 'Some text', 'public': True})
+        self.assertEqual(response.status_code, 404)
 
     def test_change_privacy(self):
         report = ReportFactory()
@@ -1289,8 +1305,10 @@ class TestApplicantSubmitReport(BaseViewTestCase):
     def test_submit_own_report(self):
         report = ReportFactory(project__user=self.user)
         response = self.post_page(report, {'content': 'Some text', 'public': True})
+        report.refresh_from_db()
         self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url()))
         self.assertEqual(report.versions.first().content, 'Some text')
+        self.assertEqual(report.versions.first(), report.current)
 
     def test_change_privacy_own(self):
         report = ReportFactory(project__user=self.user)
diff --git a/opentech/apply/projects/views/report.py b/opentech/apply/projects/views/report.py
index 28115a366..a919f22f1 100644
--- a/opentech/apply/projects/views/report.py
+++ b/opentech/apply/projects/views/report.py
@@ -1,5 +1,6 @@
 from django.contrib.auth.decorators import login_required
 from django.contrib.auth.mixins import UserPassesTestMixin
+from django.http import Http404
 from django.utils.decorators import method_decorator
 from django.core.exceptions import PermissionDenied
 from django.views.generic import (
@@ -24,8 +25,6 @@ class ReportingMixin:
 
 
 class ReportAccessMixin:
-    model = Report
-
     def dispatch(self, request, *args, **kwargs):
         is_admin = request.user.is_apply_staff
         is_owner = request.user == self.get_object().project.user
@@ -38,6 +37,12 @@ class ReportAccessMixin:
 @method_decorator(login_required, name='dispatch')
 class ReportUpdateView(ReportAccessMixin, UpdateView):
     form_class = ReportEditForm
+    model = Report
+
+    def dispatch(self, *args, **kwargs):
+        if not self.get_object().can_submit:
+            raise Http404
+        return super().dispatch(*args, **kwargs)
 
     def get_success_url(self):
         return self.object.project.get_absolute_url()
diff --git a/opentech/apply/stream_forms/testing/factories.py b/opentech/apply/stream_forms/testing/factories.py
index 045438cef..0cf89a20b 100644
--- a/opentech/apply/stream_forms/testing/factories.py
+++ b/opentech/apply/stream_forms/testing/factories.py
@@ -212,7 +212,7 @@ class DropdownFieldBlockFactory(FormFieldBlockFactory):
 
 
 class UploadableMediaFactory(FormFieldBlockFactory):
-    default_value = factory.django.FileField
+    default_value = factory.django.FileField()
 
     @classmethod
     def make_answer(cls, params=None):
@@ -220,12 +220,12 @@ class UploadableMediaFactory(FormFieldBlockFactory):
         params.setdefault('data', b'this is some content')
         if params.get('filename') is None:
             params['filename'] = 'example.pdf'
-        file_name, file = cls.default_value()._make_content(params)
+        file_name, file = cls.default_value._make_content(params)
         return SimpleUploadedFile(file_name, file.read())
 
 
 class ImageFieldBlockFactory(UploadableMediaFactory):
-    default_value = factory.django.ImageField
+    default_value = factory.django.ImageField()
 
     class Meta:
         model = stream_blocks.ImageFieldBlock
-- 
GitLab