diff --git a/SciPost_v1/settings/local_jorran.py b/SciPost_v1/settings/local_jorran.py index 6db72e8b3e4cc6d3d5f0393f45fc99ab32049d97..26921f7d6fab6dc46732e502e5218d24acb7ec5c 100644 --- a/SciPost_v1/settings/local_jorran.py +++ b/SciPost_v1/settings/local_jorran.py @@ -3,13 +3,6 @@ from .base import * # THE MAIN THING HERE DEBUG = True -# Debug toolbar settings -INSTALLED_APPS += ( - 'debug_toolbar', -) -MIDDLEWARE += ( - 'debug_toolbar.middleware.DebugToolbarMiddleware', -) INTERNAL_IPS = ['127.0.0.1', '::1'] # Static and media diff --git a/common/forms.py b/common/forms.py index aa4067ee60c49532c465477021b8849255bc8372..5ec561a7a8139cf9cc633834c463769b852f3f57 100644 --- a/common/forms.py +++ b/common/forms.py @@ -106,14 +106,12 @@ class MonthYearWidget(Widget): if not self.required: index += 1 y = self.year_choices[index][0] - if y and m: # Days are used for filtering, but not communicated to the user if self.round_to_end: d = calendar.monthrange(int(y), int(m))[1] else: - d = 1 - + d = '1' return '%s-%s-%s' % (y, m, d) return data.get(name, None) diff --git a/finances/forms.py b/finances/forms.py index 520ba430c3e8aed355adf16e50bffba61b8c700b..4320a7d73800a1d5455a83bbf689a23b5c77cf5d 100644 --- a/finances/forms.py +++ b/finances/forms.py @@ -1,6 +1,7 @@ __copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" __license__ = "AGPL v3" +import datetime from django import forms from django.contrib.auth import get_user_model @@ -9,13 +10,13 @@ from django.db.models import Sum from django.utils import timezone from ajax_select.fields import AutoCompleteSelectField +from dateutil.rrule import rrule, MONTHLY +from common.forms import MonthYearWidget from scipost.fields import UserModelChoiceField from .models import Subsidy, WorkLog -today = timezone.now().date() - class SubsidyForm(forms.ModelForm): organization = AutoCompleteSelectField('organization_lookup') @@ -47,48 +48,83 @@ class WorkLogForm(forms.ModelForm): } -class LogsActiveFilter(forms.Form): +class LogsFilter(forms.Form): """ Filter work logs given the requested date range and users. """ employee = UserModelChoiceField( - queryset=get_user_model().objects.filter(work_logs__isnull=False).distinct(), required=False) - month = forms.ChoiceField( - choices=[(None, 9 * '-')] + [(k, v) for k, v in MONTHS.items()], required=False, initial=None) - year = forms.ChoiceField(choices=[(y, y) for y in reversed(range(today.year-6, today.year+1))]) + queryset=get_user_model().objects.filter(work_logs__isnull=False).distinct(), + required=False, empty_label='Show all') + start = forms.DateField(widget=MonthYearWidget(required=True), required=True) # Month + end = forms.DateField(widget=MonthYearWidget(required=True, end=True), required=True) # Month def __init__(self, *args, **kwargs): - if not kwargs.get('data', False) and not args[0]: - args = list(args) - args[0] = {'year': today.year} - args = tuple(args) - kwargs['initial'] = {'year': today.year} super().__init__(*args, **kwargs) + today = timezone.now().date() + self.initial['start'] = today.today() + self.initial['end'] = today.today() + + def clean(self): + if self.is_valid(): + self.cleaned_data['months'] = [ + dt for dt in rrule( + MONTHLY, + dtstart=self.cleaned_data['start'], + until=self.cleaned_data['end'])] + return self.cleaned_data + + def get_months(self): + if self.is_valid(): + return self.cleaned_data.get('months', []) + return [] def filter(self): - """Filter work logs and return in output-convenient format.""" + """Filter work logs and return in user-grouped format.""" + output = [] + if self.is_valid(): + if self.cleaned_data['employee']: + user_qs = get_user_model().objects.filter(id=self.cleaned_data['employee'].id) + else: + user_qs = get_user_model().objects.filter(work_logs__isnull=False) + user_qs = user_qs.filter( + work_logs__work_date__gte=self.cleaned_data['start'], + work_logs__work_date__lte=self.cleaned_data['end']).distinct() + + output = [] + for user in user_qs: + logs = user.work_logs.filter( + work_date__gte=self.cleaned_data['start'], + work_date__lte=self.cleaned_data['end']).distinct() + + output.append({ + 'logs': logs, + 'duration': logs.aggregate(total=Sum('duration')), + 'user': user, + }) + return output + + def filter_per_month(self): + """Filter work logs and return in per-month format.""" output = [] if self.is_valid(): - user_qs = get_user_model().objects.filter( - work_logs__isnull=False, work_logs__work_date__year=self.cleaned_data['year']) if self.cleaned_data['employee']: - # Get as a queryset instead of single instead. - user_qs = user_qs.filter(id=self.cleaned_data['employee'].id) - user_qs = user_qs.distinct() + user_qs = get_user_model().objects.filter(id=self.cleaned_data['employee'].id) + else: + user_qs = get_user_model().objects.filter(work_logs__isnull=False) + user_qs = user_qs.filter( + work_logs__work_date__gte=self.cleaned_data['start'], + work_logs__work_date__lte=self.cleaned_data['end']).distinct() output = [] for user in user_qs: - logs = user.work_logs.filter(work_date__year=self.cleaned_data['year']) - if self.cleaned_data['month']: - logs = logs.filter(work_date__month=self.cleaned_data['month']) - logs = logs.distinct() - - if logs: - # If logs exists for given filters - output.append({ - 'logs': logs, - 'duration': logs.aggregate(total=Sum('duration')), - 'user': user, - }) + # If logs exists for given filters + output.append({ + 'logs': [], + 'user': user, + }) + for dt in self.get_months(): + output[-1]['logs'].append( + user.work_logs.filter( + work_date__year=dt.year, work_date__month=dt.month).aggregate(total=Sum('duration'))['total']) return output diff --git a/finances/templates/finances/timesheets.html b/finances/templates/finances/timesheets.html index 1c616cd18cdba9ca089c299c18ca08455a747955..e48fdf69a8d94a2c0b3093275de6f3b3d3663dca 100644 --- a/finances/templates/finances/timesheets.html +++ b/finances/templates/finances/timesheets.html @@ -1,10 +1,11 @@ -{% extends 'scipost/base.html' %} +{% extends 'finances/base.html' %} {% block breadcrumb_items %} - <span class="breadcrumb-item">Team Timesheets</span> + {{ block.super }} + <span class="breadcrumb-item">Team timesheets</span> {% endblock %} -{% block pagetitle %}: Team Timesheets{% endblock pagetitle %} +{% block pagetitle %}: Team timesheets{% endblock pagetitle %} {% load bootstrap %} {% load scipost_extras %} @@ -13,10 +14,25 @@ <div class="row"> <div class="col-12"> - <h1 class="highlight">Team Timesheets</h1> + <h1 class="highlight">Timesheets</h1> + <a href="{% url 'finances:timesheets_detailed' %}">See detailed timesheets</a> + <br> + <br> + + <form method="get"> + {{form.employee|bootstrap }} + + <label>Date from</label> + <div class="form-row"> + {{ form.start }} + </div> + + <label>Date until</label> + <div class="form-row"> + {{ form.end }} + </div> + <br> - <form method="get" action="{% url 'finances:timesheets' %}"> - {{ form|bootstrap }} <input type="submit" class="btn btn-primary" value="Filter"> </form> </div> @@ -24,38 +40,34 @@ <div class="row"> <div class="col-12"> - <h2 class="mb-2 mt-4">Team Timesheets</h2> - {% for user_log in user_logs %} + {% if form.is_bound and form.is_valid %} + <h2 class="mb-2 mt-4">Team timesheets</h2> <h4 class="mb-1">{{ user_log.user.first_name }} {{ user_log.user.last_name }}</h4> - <table class="table"> + <table class="table table-hover"> <thead class="thead-default"> <tr> - <th>Date</th> - <th>Related to object</th> - <th>Log type</th> - <th>Comments</th> - <th>Duration</th> + <th>Employee</th> + {% for month in form.get_months %} + <th>{{ month|date:'N Y' }}</th> + {% endfor %} </tr> </thead> <tbody> - {% for log in user_log.logs %} + {% for user_log in form.filter_per_month %} <tr> - <td>{{ log.work_date }}</td> - <td>{{ log.content }}</td> - <td>{{ log.log_type }}</td> - <td>{{ log.comments }}</td> - <td>{{ log.duration|duration }}</td> + <td>{{ user_log.user.last_name }}, {{ user_log.user.first_name }}</td> + {% for log in user_log.logs %} + <td>{{ log|duration }}</td> + {% endfor %} </tr> + {% empty %} + <tr><td colspan="5">No logs found.</td></tr> {% endfor %} - <tr> - <td colspan="4" class="text-right">Total:</td> - <td><strong>{{ user_log.duration.total|duration }}</strong></td> - </tr> </tbody> </table> - {% empty %} - <p>No logs found.</p> - {% endfor %} + {% else %} + <p class="text-danger">First submit the filter form to retrieve results.</p> + {% endif %} </div> </div> {% endblock %} diff --git a/finances/templates/finances/timesheets_detailed.html b/finances/templates/finances/timesheets_detailed.html new file mode 100644 index 0000000000000000000000000000000000000000..eaa0530386e633b0958af933be45b135b4f24214 --- /dev/null +++ b/finances/templates/finances/timesheets_detailed.html @@ -0,0 +1,80 @@ +{% extends 'finances/base.html' %} + +{% block breadcrumb_items %} + {{ block.super }} + <a href="{% url 'finances:timesheets' %}" class="breadcrumb-item">Team timesheets</a> + <span class="breadcrumb-item">Detailed timesheets</span> +{% endblock %} + +{% block pagetitle %}: Team timesheets{% endblock pagetitle %} + +{% load bootstrap %} +{% load scipost_extras %} + +{% block content %} + +<div class="row"> + <div class="col-12"> + <h1 class="highlight">Detailed timesheets</h1> + <br> + + <form method="get"> + {{form.employee|bootstrap }} + + <label>Date from</label> + <div class="form-row"> + {{ form.start }} + </div> + + <label>Date until</label> + <div class="form-row"> + {{ form.end }} + </div> + <br> + + <input type="submit" class="btn btn-primary" value="Filter"> + </form> + </div> +</div> + +<div class="row"> + <div class="col-12"> + {% if form.is_bound and form.is_valid %} + <h2 class="mb-2 mt-4">Team timesheets</h2> + {% for user_log in form.filter %} + <h4 class="mb-1">{{ user_log.user.first_name }} {{ user_log.user.last_name }}</h4> + <table class="table table-hover"> + <thead class="thead-default"> + <tr> + <th>Date</th> + <th>Related to object</th> + <th>Log type</th> + <th>Comments</th> + <th>Duration</th> + </tr> + </thead> + <tbody> + {% for log in user_log.logs %} + <tr> + <td>{{ log.work_date }}</td> + <td>{{ log.content }}</td> + <td>{{ log.log_type }}</td> + <td>{{ log.comments }}</td> + <td>{{ log.duration|duration }}</td> + </tr> + {% endfor %} + <tr> + <td colspan="4" class="text-right">Total:</td> + <td><strong>{{ user_log.duration.total|duration }}</strong></td> + </tr> + </tbody> + </table> + {% empty %} + <p>No logs found.</p> + {% endfor %} + {% else %} + <p class="text-danger">First submit the filter form to retrieve results.</p> + {% endif %} + </div> +</div> +{% endblock %} diff --git a/finances/urls.py b/finances/urls.py index 218eca065cf5a310f52e731918db79b685cb8f51..31bd7da08fed250e82b94718d8467d4b6fc4dabc 100644 --- a/finances/urls.py +++ b/finances/urls.py @@ -8,48 +8,19 @@ from django.views.generic import TemplateView from . import views urlpatterns = [ - url( - r'^$', - TemplateView.as_view(template_name='finances/finances.html'), - name='finances' - ), + url(r'^$', TemplateView.as_view(template_name='finances/finances.html'), name='finances'), # Subsidies - url( - r'^subsidies/$', - views.SubsidyListView.as_view(), - name='subsidies' - ), - url( - r'^subsidies/add/$', - views.SubsidyCreateView.as_view(), - name='subsidy_create' - ), - url( - r'^subsidies/(?P<pk>[0-9]+)/update/$', - views.SubsidyUpdateView.as_view(), - name='subsidy_update' - ), - url( - r'^subsidies/(?P<pk>[0-9]+)/delete/$', - views.SubsidyDeleteView.as_view(), - name='subsidy_delete' - ), - url( - r'^subsidies/(?P<pk>[0-9]+)/$', - views.SubsidyDetailView.as_view(), - name='subsidy_details' - ), + url(r'^subsidies/$', views.SubsidyListView.as_view(), name='subsidies'), + url(r'^subsidies/add/$', views.SubsidyCreateView.as_view(), name='subsidy_create'), + url(r'^subsidies/(?P<pk>[0-9]+)/update/$', views.SubsidyUpdateView.as_view(), + name='subsidy_update'), + url(r'^subsidies/(?P<pk>[0-9]+)/delete/$', views.SubsidyDeleteView.as_view(), + name='subsidy_delete'), + url(r'^subsidies/(?P<pk>[0-9]+)/$', views.SubsidyDetailView.as_view(), name='subsidy_details'), # Timesheets - url( - r'^timesheets$', - views.timesheets, - name='timesheets' - ), - url( - r'^logs/(?P<slug>\d+)/delete$', - views.LogDeleteView.as_view(), - name='log_delete' - ), + url(r'^timesheets$', views.timesheets, name='timesheets'), + url(r'^timesheets/detailed$', views.timesheets_detailed, name='timesheets_detailed'), + url(r'^logs/(?P<slug>\d+)/delete$', views.LogDeleteView.as_view(), name='log_delete'), ] diff --git a/finances/views.py b/finances/views.py index 25945357a5dc41e4a00871d0f6aa4ac4ae9ab6ae..90f6c21037c653f2b74f6b61327f860de5d5a297 100644 --- a/finances/views.py +++ b/finances/views.py @@ -12,7 +12,7 @@ from django.views.generic.detail import DetailView from django.views.generic.edit import CreateView, UpdateView, DeleteView from django.views.generic.list import ListView -from .forms import SubsidyForm, LogsActiveFilter +from .forms import SubsidyForm, LogsFilter from .models import Subsidy, WorkLog from .utils import slug_to_id @@ -85,17 +85,19 @@ class SubsidyDetailView(DetailView): @permission_required('scipost.can_view_timesheets', raise_exception=True) def timesheets(request): """ - See an overview per month of all timesheets. + Overview of all timesheets including comments and related objects. """ - form = LogsActiveFilter(request.GET or None) - context = { - 'form': form, - } + form = LogsFilter(request.GET or None) + context = {'form': form} + return render(request, 'finances/timesheets.html', context) - # if form.is_valid(): - context['user_logs'] = form.filter() - return render(request, 'finances/timesheets.html', context) +@permission_required('scipost.can_view_timesheets', raise_exception=True) +def timesheets_detailed(request): + """Overview of all timesheets. """ + form = LogsFilter(request.GET or None) + context = {'form': form} + return render(request, 'finances/timesheets_detailed.html', context) class LogDeleteView(LoginRequiredMixin, DeleteView): diff --git a/requirements.txt b/requirements.txt index cdb5a611d3fe707162026da11c31e0d846fe9d57..124e5a6e560b27f980127a1bcb9d60da312079a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -44,7 +44,7 @@ Whoosh==2.7.4 # Directly related to Haystack. # Python Utils ithenticate-api-python==0.8 mailchimp3==2.0.15 -python-dateutil==2.6.0 # Doesn't Django have this functionality built-in? -- JdW +python-dateutil==2.6.0 Pillow==3.4.2 # Latest version is v4.2.1; need to know about usage before upgrade. -- JdW html2text