From 8d602b12e22dc39550edb82ccd8b3e2ac2a78fd9 Mon Sep 17 00:00:00 2001 From: Todd Dembrey <todd.dembrey@torchbox.com> Date: Wed, 6 Nov 2019 15:30:28 +0000 Subject: [PATCH] Feature/gh 1651 all reports table (#1663) * Add table to filter by submitted reports * Add report table to the project overview page --- opentech/apply/projects/filters.py | 40 +++++++++++- opentech/apply/projects/models.py | 64 ++++++++++++++++++- opentech/apply/projects/tables.py | 28 +++++++- .../widgets/date_range_input_widget.html | 5 ++ .../application_projects/overview.html | 13 ++++ .../application_projects/report_list.html | 37 +++++++++++ opentech/apply/projects/urls.py | 2 + opentech/apply/projects/views/project.py | 16 ++++- opentech/apply/projects/views/report.py | 14 +++- 9 files changed, 212 insertions(+), 7 deletions(-) create mode 100644 opentech/apply/projects/templates/application_projects/filters/widgets/date_range_input_widget.html create mode 100644 opentech/apply/projects/templates/application_projects/report_list.html diff --git a/opentech/apply/projects/filters.py b/opentech/apply/projects/filters.py index 260cc0ec0..8f793c80c 100644 --- a/opentech/apply/projects/filters.py +++ b/opentech/apply/projects/filters.py @@ -12,7 +12,8 @@ from .models import ( PROJECT_STATUS_CHOICES, REQUEST_STATUS_CHOICES, PaymentRequest, - Project + Project, + Report, ) User = get_user_model() @@ -41,3 +42,40 @@ class ProjectListFilter(filters.FilterSet): class Meta: fields = ['status', 'lead', 'fund'] model = Project + + +class DateRangeInputWidget(filters.widgets.SuffixedMultiWidget): + template_name = 'application_projects/filters/widgets/date_range_input_widget.html' + suffixes = ['after', 'before'] + + def __init__(self, attrs=None): + widgets = (forms.DateInput, forms.DateInput) + super().__init__(widgets, attrs) + + def decompress(self, value): + if value: + return [value.start, value.stop] + return [None, None] + + +class ReportListFilter(filters.FilterSet): + reporting_period = filters.DateFromToRangeFilter( + label="Reporting Period", + method="filter_reporting_period", + widget=DateRangeInputWidget, + ) + submitted = filters.DateFromToRangeFilter(widget=DateRangeInputWidget) + + class Meta: + model = Report + fields = ['submitted'] + + def filter_reporting_period(self, queryset, name, value): + after, before = value.start, value.stop + q = {} + if after: + q['start__gte'] = after + if before: + q['end_date__lte'] = before + + return queryset.filter(**q) diff --git a/opentech/apply/projects/models.py b/opentech/apply/projects/models.py index f047e1da7..4d0b0034b 100644 --- a/opentech/apply/projects/models.py +++ b/opentech/apply/projects/models.py @@ -1,4 +1,5 @@ import collections +import datetime import decimal import json import logging @@ -12,8 +13,19 @@ 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, Q, Sum, Value as V -from django.db.models.functions import Coalesce +from django.db.models import ( + Case, + F, + ExpressionWrapper, + Max, + OuterRef, + Q, + Subquery, + Sum, + Value as V, + When, +) +from django.db.models.functions import Cast, Coalesce from django.db.models.signals import post_delete from django.dispatch.dispatcher import receiver from django.urls import reverse @@ -301,6 +313,20 @@ class ProjectQuerySet(models.QuerySet): last_payment_request=Max('payment_requests__requested_at'), ) + def with_start_date(self): + return self.annotate( + start=Cast( + Subquery( + Contract.objects.filter( + project=OuterRef('pk'), + ).approved().order_by( + 'approved_at' + ).values('approved_at')[:1] + ), + models.DateField(), + ) + ) + def for_table(self): return self.with_amount_paid().with_last_payment() @@ -651,6 +677,40 @@ class ReportQueryset(models.QuerySet): Q(current__isnull=False) | Q(skipped=True), ) + def submitted(self): + return self.filter(current__isnull=False) + + def for_table(self): + return self.annotate( + last_end_date=Subquery( + Report.objects.filter( + project=OuterRef('project_id'), + end_date__lt=OuterRef('end_date') + ).values('end_date')[:1] + ), + project_start_date=Subquery( + Project.objects.filter( + pk=OuterRef('project_id'), + ).with_start_date().values('start')[:1] + ), + start=Case( + When( + last_end_date__isnull=False, + # Expression Wrapper doesn't cast the calculated object + # Use cast to get an actual date object + then=Cast( + ExpressionWrapper( + F('last_end_date') + datetime.timedelta(days=1), + output_field=models.DateTimeField(), + ), + models.DateField(), + ), + ), + default=F('project_start_date'), + output_field=models.DateField(), + ) + ) + class Report(models.Model): skipped = models.BooleanField(default=False) diff --git a/opentech/apply/projects/tables.py b/opentech/apply/projects/tables.py index 1aaa9d67b..4064f5dae 100644 --- a/opentech/apply/projects/tables.py +++ b/opentech/apply/projects/tables.py @@ -4,7 +4,7 @@ import django_tables2 as tables from django.db.models import F, Sum from django.contrib.humanize.templatetags.humanize import intcomma -from .models import PaymentRequest, Project +from .models import PaymentRequest, Project, Report class BasePaymentRequestsTable(tables.Table): @@ -119,3 +119,29 @@ class ProjectsListTable(BaseProjectsTable): def order_end_date(self, qs, desc): return qs.by_end_date(desc), True + + +class ReportListTable(tables.Table): + project = tables.LinkColumn( + 'funds:projects:reports:detail', + text=lambda r: textwrap.shorten(r.project.title, width=30, placeholder="..."), + args=[tables.utils.A('pk')], + ) + report_period = tables.Column(accessor='pk') + submitted = tables.DateColumn() + lead = tables.Column(accessor='project.lead') + + class Meta: + fields = [ + 'project', + 'submitted', + ] + sequence = [ + 'project', + 'report_period', + '...' + ] + model = Report + + def render_report_period(self, record): + return f"{record.start} to {record.end_date}" diff --git a/opentech/apply/projects/templates/application_projects/filters/widgets/date_range_input_widget.html b/opentech/apply/projects/templates/application_projects/filters/widgets/date_range_input_widget.html new file mode 100644 index 000000000..fe469bc85 --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/filters/widgets/date_range_input_widget.html @@ -0,0 +1,5 @@ +from: +{% include widget.subwidgets.0.template_name %} +to: +{% include widget.subwidgets.1.template_name %} + diff --git a/opentech/apply/projects/templates/application_projects/overview.html b/opentech/apply/projects/templates/application_projects/overview.html index 6adc7c31c..e3159e849 100644 --- a/opentech/apply/projects/templates/application_projects/overview.html +++ b/opentech/apply/projects/templates/application_projects/overview.html @@ -54,6 +54,19 @@ </div> {% endif %} + {% if reports.table.data %} + <div class="wrapper wrapper--bottom-space"> + + {% include "funds/includes/table_filter_and_search.html" with filter=reports.filterset filter_action=reports.url heading="Reports" %} + + {% render_table reports.table %} + + <div class="all-submissions-table__more"> + <a href="{{ reports.url }}">Show all</a> + </div> + + </div> + {% endif %} </div> {% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/report_list.html b/opentech/apply/projects/templates/application_projects/report_list.html new file mode 100644 index 000000000..8a3579808 --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/report_list.html @@ -0,0 +1,37 @@ +{% extends "base-apply.html" %} + +{% load render_table from django_tables2 %} +{% load static %} + +{% block title %}Reports{% endblock %} + +{% block content %} +<div class="admin-bar"> + <div class="admin-bar__inner wrapper--search"> + {% block page_header %} + <div> + <h1 class="gamma heading heading--no-margin heading--bold">Submitted Reports</h1> + </div> + {% endblock %} + </div> +</div> + +<div class="wrapper wrapper--large wrapper--inner-space-medium"> + {% if table %} + {% include "funds/includes/table_filter_and_search.html" with filter_form=filter_form use_search=False filter_action=filter_action %} + {% render_table table %} + {% else %} + <p>No Reports Available</p> + {% endif %} +</div> + +{% endblock content %} + +{% block extra_css %} + {{ filter.form.media.css }} +{% endblock %} + +{% block extra_js %} + {{ filter.form.media.js }} + <script src="{% static 'js/apply/submission-filters.js' %}"></script> +{% endblock %} diff --git a/opentech/apply/projects/urls.py b/opentech/apply/projects/urls.py index d5f379881..41d284975 100644 --- a/opentech/apply/projects/urls.py +++ b/opentech/apply/projects/urls.py @@ -15,6 +15,7 @@ from .views import ( ProjectOverviewView, ProjectPrivateMediaView, ReportDetailView, + ReportListView, ReportPrivateMedia, ReportSkipView, ReportUpdateView, @@ -44,6 +45,7 @@ urlpatterns = [ ])), ], 'payments'))), path('reports/', include(([ + path('', ReportListView.as_view(), name='all'), path('<int:pk>/', include([ path('', ReportDetailView.as_view(), name='detail'), path('skip/', ReportSkipView.as_view(), name='skip'), diff --git a/opentech/apply/projects/views/project.py b/opentech/apply/projects/views/project.py index 0f823130b..76e4e727d 100644 --- a/opentech/apply/projects/views/project.py +++ b/opentech/apply/projects/views/project.py @@ -38,6 +38,7 @@ from ..files import get_files from ..filters import ( PaymentRequestListFilter, ProjectListFilter, + ReportListFilter, ) from ..forms import ( ApproveContractForm, @@ -61,11 +62,13 @@ from ..models import ( Contract, PacketFile, PaymentRequest, - Project + Project, + Report, ) from ..tables import ( PaymentRequestsListTable, - ProjectsListTable + ProjectsListTable, + ReportListTable, ) from .report import ReportingMixin, ReportFrequencyUpdate @@ -608,9 +611,18 @@ class ProjectOverviewView(TemplateView): context = super().get_context_data(**kwargs) context['projects'] = self.get_projects(self.request) context['payment_requests'] = self.get_payment_requests(self.request) + context['reports'] = self.get_reports(self.request) context['status_counts'] = self.get_status_counts() return context + def get_reports(self, request): + reports = Report.objects.for_table().submitted()[:10] + return { + 'filterset': ReportListFilter(request.GET or None, request=request, queryset=reports), + 'table': ReportListTable(reports, order_by=()), + 'url': reverse('apply:projects:reports:all'), + } + def get_payment_requests(self, request): payment_requests = PaymentRequest.objects.order_by('date_to')[:10] diff --git a/opentech/apply/projects/views/report.py b/opentech/apply/projects/views/report.py index 2e804f7fe..b76779ef6 100644 --- a/opentech/apply/projects/views/report.py +++ b/opentech/apply/projects/views/report.py @@ -10,17 +10,21 @@ from django.views.generic import ( UpdateView, ) from django.views.generic.detail import SingleObjectMixin +from django_filters.views import FilterView +from django_tables2 import SingleTableMixin from opentech.apply.activity.messaging import MESSAGES, messenger from opentech.apply.utils.storage import PrivateMediaView from opentech.apply.utils.views import DelegatedViewMixin from opentech.apply.users.decorators import staff_required -from ..models import Report, ReportConfig, ReportPrivateFiles +from ..filters import ReportListFilter from ..forms import ( ReportEditForm, ReportFrequencyForm, ) +from ..models import Report, ReportConfig, ReportPrivateFiles +from ..tables import ReportListTable class ReportingMixin: @@ -197,3 +201,11 @@ class ReportFrequencyUpdate(DelegatedViewMixin, UpdateView): ) return response + + +@method_decorator(staff_required, name='dispatch') +class ReportListView(SingleTableMixin, FilterView): + queryset = Report.objects.submitted().for_table() + filterset_class = ReportListFilter + table_class = ReportListTable + template_name = 'application_projects/report_list.html' -- GitLab