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