diff --git a/opentech/apply/projects/filters.py b/opentech/apply/projects/filters.py index 260cc0ec0cc8909f4af22a1f6db53b8ad4556cb2..8f793c80c529814f32a28177548c422543122ed4 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 f047e1da7af457eca46f47f979bb184438d0658f..4d0b0034b3ed7a9f14742ec86560abed390588b9 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 1aaa9d67b08e956983f158be9d74fb0923e47e8a..4064f5dae932f353aa2ef5bb1449b7a35c1d1680 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 0000000000000000000000000000000000000000..fe469bc85d1baa68f7a63b57c468088f710c1218 --- /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 6adc7c31c7fa1a93580e53243aba9a11d2c182d5..e3159e8497eae29124e8a3b0b55e656e3d69955f 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 0000000000000000000000000000000000000000..8a3579808112bc05373568dccddef1b544e2566a --- /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 d5f3798810b84b5a45750e97c9899bb5206de6e8..41d28497532b41fe68ee4e7d142d6ebc1ffb30a1 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 0f823130b64f50ad849b25be0adca07a3f86afa8..76e4e727ddb7542fc05d43dbb551d1d38aa148e8 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 2e804f7fee1796aa51b7654ed1081594bc2425de..b76779ef6c9b90295926a1325840ef6800f5e756 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'