manager: report for not checked or not invoiced events (#75417)
parent
59368a540c
commit
431b382f76
|
@ -77,6 +77,7 @@ from django.utils.translation import ngettext, pgettext_lazy
|
|||
from chrono.interval import Interval, IntervalSet
|
||||
from chrono.utils.date import get_weekday_index
|
||||
from chrono.utils.db import ArraySubquery, SumCardinality
|
||||
from chrono.utils.lingo import get_lingo_service
|
||||
from chrono.utils.misc import AgendaImportError, ICSError, clean_import_data, generate_slug
|
||||
from chrono.utils.publik_urls import translate_from_publik_url
|
||||
from chrono.utils.requests_wrapper import requests as requests_wrapper
|
||||
|
@ -308,9 +309,9 @@ class Agenda(models.Model):
|
|||
return reverse('chrono-manager-agenda-settings', kwargs={'pk': self.id})
|
||||
|
||||
def get_lingo_url(self):
|
||||
if not settings.KNOWN_SERVICES.get('lingo'):
|
||||
lingo = get_lingo_service()
|
||||
if not lingo:
|
||||
return
|
||||
lingo = list(settings.KNOWN_SERVICES['lingo'].values())[0]
|
||||
lingo_url = lingo.get('url') or ''
|
||||
return '%smanage/pricing/agenda/%s/' % (lingo_url, self.slug)
|
||||
|
||||
|
|
|
@ -783,6 +783,73 @@ class EventsTimesheetForm(forms.Form):
|
|||
return cleaned_data
|
||||
|
||||
|
||||
class EventsReportForm(forms.Form):
|
||||
date_start = forms.DateField(
|
||||
label=_('Start date'),
|
||||
widget=forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
|
||||
)
|
||||
date_end = forms.DateField(
|
||||
label=_('End date'),
|
||||
widget=forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
|
||||
)
|
||||
agendas = forms.ModelMultipleChoiceField(
|
||||
label=_('Agendas'),
|
||||
queryset=Agenda.objects.none(),
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
label=_('Status'),
|
||||
choices=[
|
||||
('not-checked', _('Not checked')),
|
||||
('not-invoiced', _('Not invoiced')),
|
||||
],
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop('user')
|
||||
super().__init__(*args, **kwargs)
|
||||
agendas = [a for a in Agenda.objects.filter(kind='events') if a.can_be_viewed(user)]
|
||||
self.fields['agendas'].queryset = Agenda.objects.filter(pk__in=[a.pk for a in agendas])
|
||||
|
||||
def get_events(self):
|
||||
min_start = make_aware(
|
||||
datetime.datetime.combine(self.cleaned_data['date_start'], datetime.time(0, 0))
|
||||
)
|
||||
max_start = make_aware(datetime.datetime.combine(self.cleaned_data['date_end'], datetime.time(0, 0)))
|
||||
max_start = max_start + datetime.timedelta(days=1)
|
||||
event_queryset = (
|
||||
Event.objects.filter(
|
||||
recurrence_days__isnull=True,
|
||||
start_datetime__gte=min_start,
|
||||
start_datetime__lt=max_start,
|
||||
cancelled=False,
|
||||
agenda__in=self.cleaned_data['agendas'],
|
||||
)
|
||||
.select_related('agenda')
|
||||
.order_by('start_datetime', 'duration', 'label')
|
||||
)
|
||||
|
||||
result = {}
|
||||
|
||||
if 'not-checked' in self.cleaned_data['status']:
|
||||
result['not_checked'] = event_queryset.filter(checked=False)
|
||||
if 'not-invoiced' in self.cleaned_data['status']:
|
||||
result['not_invoiced'] = event_queryset.filter(invoiced=False)
|
||||
|
||||
return result
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
if 'date_start' in cleaned_data and 'date_end' in cleaned_data:
|
||||
if cleaned_data['date_end'] < cleaned_data['date_start']:
|
||||
self.add_error('date_end', _('End date must be greater than start date.'))
|
||||
elif (cleaned_data['date_start'] + relativedelta(months=3)) < cleaned_data['date_end']:
|
||||
self.add_error('date_end', _('Please select an interval of no more than 3 months.'))
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class AgendaResourceForm(forms.Form):
|
||||
resource = forms.ModelChoiceField(label=_('Resource'), queryset=Resource.objects.none())
|
||||
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
{% extends "chrono/manager_home.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{% url 'chrono-manager-events-report' %}">{% trans "Events report" %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans 'Events report' %}</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="section">
|
||||
<h3>{% trans "Report configuration" %}</h3>
|
||||
<div>
|
||||
<form>
|
||||
{{ form.as_p }}
|
||||
<button class="submit-button">{% trans "See report" %}</button>
|
||||
{% if request.GET and form.is_valid %}
|
||||
<button class="submit-button" name="csv">{% trans "Get CSV file" %}</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{% if request.GET and form.is_valid %}
|
||||
{% with events=form.get_events %}
|
||||
|
||||
{% if 'not-checked' in form.cleaned_data.status %}
|
||||
<h4>{% blocktrans with start=form.cleaned_data.date_start end=form.cleaned_data.date_end %}Report from {{ start }} to {{ end }} - not checked events{% endblocktrans %}</h4>
|
||||
<ul class="objects-list single-links">
|
||||
{% for event in events.not_checked %}
|
||||
<li>
|
||||
<a href="{% url 'chrono-manager-event-view' pk=event.agenda_id event_pk=event.pk %}">
|
||||
{% if event.label %}{{ event.label }} / {% endif %} {{ event.start_datetime }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if 'not-invoiced' in form.cleaned_data.status %}
|
||||
<h4>{% blocktrans with start=form.cleaned_data.date_start end=form.cleaned_data.date_end %}Report from {{ start }} to {{ end }} - not invoiced events{% endblocktrans %}</h4>
|
||||
<ul class="objects-list single-links">
|
||||
{% for event in events.not_invoiced %}
|
||||
<li>
|
||||
<a href="{% url 'chrono-manager-event-view' pk=event.agenda_id event_pk=event.pk %}">
|
||||
{% if event.label %}{{ event.label }} / {% endif %} {{ event.start_datetime }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -21,6 +21,9 @@
|
|||
{% if user.is_staff %}
|
||||
<li><a href="{% url 'chrono-manager-resource-list' %}">{% trans 'Resources' %}</a></li>
|
||||
{% endif %}
|
||||
{% if lingo_enabled %}
|
||||
<li><a href="{% url 'chrono-manager-events-report' %}">{% trans "Events report" %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if user.is_staff %}
|
||||
|
|
|
@ -434,6 +434,11 @@ urlpatterns = [
|
|||
views.subscription_extra_user_block,
|
||||
name='chrono-manager-subscription-extra-user-block',
|
||||
),
|
||||
path(
|
||||
'agendas/events/report/',
|
||||
views.events_report,
|
||||
name='chrono-manager-events-report',
|
||||
),
|
||||
re_path(
|
||||
r'^agendas/events.csv$',
|
||||
views.agenda_import_events_sample_csv,
|
||||
|
|
|
@ -88,6 +88,7 @@ from chrono.agendas.models import (
|
|||
VirtualMember,
|
||||
)
|
||||
from chrono.utils.date import get_weekday_index
|
||||
from chrono.utils.lingo import get_lingo_service
|
||||
from chrono.utils.timezone import localtime, make_aware, make_naive, now
|
||||
|
||||
from .forms import (
|
||||
|
@ -115,6 +116,7 @@ from .forms import (
|
|||
EventCancelForm,
|
||||
EventDuplicateForm,
|
||||
EventForm,
|
||||
EventsReportForm,
|
||||
EventsTimesheetForm,
|
||||
ExcludedPeriodAddForm,
|
||||
ImportEventsForm,
|
||||
|
@ -173,6 +175,7 @@ class HomepageView(ListView):
|
|||
context = super().get_context_data(**kwargs)
|
||||
context['has_access_to_unavailability_calendars'] = self.has_access_to_unavailability_calendars()
|
||||
context['shared_custody_enabled'] = settings.SHARED_CUSTODY_ENABLED
|
||||
context['lingo_enabled'] = bool(get_lingo_service())
|
||||
return context
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
@ -3749,6 +3752,72 @@ class EventCancellationReportListView(ViewableAgendaMixin, ListView):
|
|||
event_cancellation_report_list = EventCancellationReportListView.as_view()
|
||||
|
||||
|
||||
class EventsReportView(View):
|
||||
template_name = 'chrono/manager_event_report.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
form = EventsReportForm(user=self.request.user, data=self.request.GET or None)
|
||||
if self.request.GET:
|
||||
form.is_valid()
|
||||
context = {'form': form}
|
||||
return context
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
context = self.get_context_data()
|
||||
if 'csv' in request.GET and context['form'].is_valid():
|
||||
return self.csv(request, context)
|
||||
return render(request, self.template_name, context)
|
||||
|
||||
def csv(self, request, context):
|
||||
form = context['form']
|
||||
filename = 'events_report_{}_{}'.format(
|
||||
form.cleaned_data['date_start'].strftime('%Y-%m-%d'),
|
||||
form.cleaned_data['date_end'].strftime('%Y-%m-%d'),
|
||||
)
|
||||
response = HttpResponse(content_type='text/csv')
|
||||
response['Content-Disposition'] = 'attachment; filename="%s.csv"' % filename
|
||||
writer = csv.writer(response)
|
||||
events = form.get_events()
|
||||
|
||||
headers = [
|
||||
_('agenda'),
|
||||
_('label'),
|
||||
_('date'),
|
||||
_('time'),
|
||||
]
|
||||
|
||||
if 'not-checked' in form.cleaned_data['status']:
|
||||
writer.writerow([_('Not checked')])
|
||||
writer.writerow(headers)
|
||||
for event in events['not_checked']:
|
||||
writer.writerow(
|
||||
[
|
||||
event.agenda.label,
|
||||
event.label,
|
||||
event.start_datetime.strftime('%Y-%m-%d'),
|
||||
event.start_datetime.strftime('%H:%M'),
|
||||
]
|
||||
)
|
||||
|
||||
if 'not-invoiced' in form.cleaned_data['status']:
|
||||
writer.writerow([_('Not invoiced')])
|
||||
writer.writerow(headers)
|
||||
for event in events['not_invoiced']:
|
||||
writer.writerow(
|
||||
[
|
||||
event.agenda.label,
|
||||
event.label,
|
||||
event.start_datetime.strftime('%Y-%m-%d'),
|
||||
event.start_datetime.strftime('%H:%M'),
|
||||
]
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
events_report = EventsReportView.as_view()
|
||||
|
||||
|
||||
class TimePeriodExceptionSourceToggleView(ManagedTimePeriodExceptionMixin, DetailView):
|
||||
model = TimePeriodExceptionSource
|
||||
|
||||
|
|
|
@ -75,6 +75,20 @@ def test_access(app, admin_user):
|
|||
assert "This site doesn't have any agenda yet." in resp.text
|
||||
|
||||
|
||||
def test_events_report_link(settings, app, admin_user):
|
||||
settings.KNOWN_SERVICES = {}
|
||||
|
||||
app = login(app)
|
||||
resp = app.get('/manage/')
|
||||
assert 'Events report' not in resp
|
||||
assert '/manage/agendas/events/report/' not in resp
|
||||
|
||||
settings.KNOWN_SERVICES['lingo'] = {'default': {'url': 'https://lingo.dev/'}}
|
||||
resp = app.get('/manage/')
|
||||
assert 'Events report' in resp
|
||||
assert '/manage/agendas/events/report/' in resp
|
||||
|
||||
|
||||
def test_logout(app, admin_user):
|
||||
app = login(app)
|
||||
app.get('/logout/')
|
||||
|
|
|
@ -3279,3 +3279,244 @@ def test_duplicate_event_creates_recurrences(app, admin_user):
|
|||
|
||||
event_recurrence = duplicate.recurrences.first()
|
||||
app.get(f'/manage/agendas/{agenda.pk}/events/{event_recurrence.pk}/duplicate', status=404)
|
||||
|
||||
|
||||
def test_events_report_form(app, admin_user):
|
||||
login(app)
|
||||
resp = app.get('/manage/agendas/events/report/')
|
||||
resp.form['date_start'] = '2022-01-01'
|
||||
resp.form['date_end'] = '2021-12-31'
|
||||
resp = resp.form.submit()
|
||||
assert resp.context['form'].errors['date_end'] == ['End date must be greater than start date.']
|
||||
|
||||
resp.form['date_end'] = '2022-04-02'
|
||||
resp = resp.form.submit()
|
||||
assert resp.context['form'].errors['date_end'] == ['Please select an interval of no more than 3 months.']
|
||||
|
||||
resp.form['date_end'] = '2022-04-01'
|
||||
resp = resp.form.submit()
|
||||
assert resp.context['form'].errors == {
|
||||
'agendas': ['This field is required.'],
|
||||
'status': ['This field is required.'],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.freeze_time('2022-02-15')
|
||||
def test_events_report(app, admin_user):
|
||||
start, end = (
|
||||
now() - datetime.timedelta(days=15),
|
||||
now() + datetime.timedelta(days=14),
|
||||
) # 2022-02-31, 2022-03-01
|
||||
agenda1 = Agenda.objects.create(label='Events 1', kind='events')
|
||||
agenda2 = Agenda.objects.create(label='Events 2', kind='events')
|
||||
Event.objects.create(
|
||||
label='event 0',
|
||||
start_datetime=start,
|
||||
places=10,
|
||||
agenda=agenda1,
|
||||
checked=False,
|
||||
invoiced=False,
|
||||
)
|
||||
event1 = Event.objects.create(
|
||||
label='event 1',
|
||||
start_datetime=start + datetime.timedelta(days=1),
|
||||
places=10,
|
||||
agenda=agenda1,
|
||||
checked=False,
|
||||
invoiced=False,
|
||||
)
|
||||
event2 = Event.objects.create(
|
||||
label='event 2',
|
||||
start_datetime=start + datetime.timedelta(days=2),
|
||||
places=10,
|
||||
agenda=agenda1,
|
||||
checked=True,
|
||||
invoiced=False,
|
||||
)
|
||||
event3 = Event.objects.create(
|
||||
label='event 3',
|
||||
start_datetime=now(),
|
||||
places=10,
|
||||
agenda=agenda2,
|
||||
checked=False,
|
||||
invoiced=True,
|
||||
)
|
||||
Event.objects.create(
|
||||
label='event 4',
|
||||
start_datetime=end - datetime.timedelta(days=1),
|
||||
places=10,
|
||||
agenda=agenda2,
|
||||
checked=True,
|
||||
invoiced=True,
|
||||
)
|
||||
Event.objects.create(
|
||||
label='event 5',
|
||||
start_datetime=end,
|
||||
places=10,
|
||||
agenda=agenda2,
|
||||
checked=False,
|
||||
invoiced=False,
|
||||
)
|
||||
Event.objects.create(
|
||||
label='event cancelled',
|
||||
start_datetime=now() + datetime.timedelta(days=4),
|
||||
places=10,
|
||||
agenda=agenda1,
|
||||
cancelled=True,
|
||||
checked=False,
|
||||
invoiced=False,
|
||||
)
|
||||
recurring_event = Event.objects.create(
|
||||
label='recurring',
|
||||
start_datetime=start + datetime.timedelta(days=1),
|
||||
places=10,
|
||||
agenda=agenda2,
|
||||
recurrence_days=[0, 1],
|
||||
recurrence_end_date=now(),
|
||||
checked=False,
|
||||
invoiced=False,
|
||||
)
|
||||
recurring_event.create_all_recurrences()
|
||||
recurrences = recurring_event.recurrences.all()
|
||||
assert recurrences.count() == 4
|
||||
recurrent_event1 = recurrences[0]
|
||||
recurrent_event1.checked = False
|
||||
recurrent_event1.invoiced = False
|
||||
recurrent_event1.save()
|
||||
recurrent_event2 = recurrences[1]
|
||||
recurrent_event2.checked = True
|
||||
recurrent_event2.invoiced = False
|
||||
recurrent_event2.save()
|
||||
recurrent_event3 = recurrences[2]
|
||||
recurrent_event3.checked = False
|
||||
recurrent_event3.invoiced = True
|
||||
recurrent_event3.save()
|
||||
recurrent_event4 = recurrences[3]
|
||||
recurrent_event4.checked = True
|
||||
recurrent_event4.invoiced = True
|
||||
recurrent_event4.save()
|
||||
|
||||
login(app)
|
||||
resp = app.get('/manage/agendas/events/report/')
|
||||
resp.form['date_start'] = '2022-02-01'
|
||||
resp.form['date_end'] = '2022-02-28'
|
||||
resp.form['agendas'] = [agenda1.pk, agenda2.pk]
|
||||
resp.form['status'] = ['not-checked', 'not-invoiced']
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
resp = resp.form.submit()
|
||||
assert len(ctx.captured_queries) == 7
|
||||
events = resp.context['form'].get_events()
|
||||
assert list(events['not_checked']) == [event1, recurrent_event1, recurrent_event3, event3]
|
||||
assert list(events['not_invoiced']) == [event1, recurrent_event1, event2, recurrent_event2]
|
||||
|
||||
resp = app.get('/manage/agendas/events/report/')
|
||||
resp.form['date_start'] = '2022-02-01'
|
||||
resp.form['date_end'] = '2022-02-28'
|
||||
resp.form['agendas'] = [agenda1.pk, agenda2.pk]
|
||||
resp.form['status'] = ['not-checked']
|
||||
resp = resp.form.submit()
|
||||
events = resp.context['form'].get_events()
|
||||
assert list(events['not_checked']) == [event1, recurrent_event1, recurrent_event3, event3]
|
||||
assert 'not_invoiced' not in events
|
||||
|
||||
resp = app.get('/manage/agendas/events/report/')
|
||||
resp.form['date_start'] = '2022-02-01'
|
||||
resp.form['date_end'] = '2022-02-28'
|
||||
resp.form['agendas'] = [agenda1.pk, agenda2.pk]
|
||||
resp.form['status'] = ['not-invoiced']
|
||||
resp = resp.form.submit()
|
||||
events = resp.context['form'].get_events()
|
||||
assert 'not_checked' not in events
|
||||
assert list(events['not_invoiced']) == [event1, recurrent_event1, event2, recurrent_event2]
|
||||
|
||||
resp = app.get('/manage/agendas/events/report/')
|
||||
resp.form['date_start'] = '2022-02-01'
|
||||
resp.form['date_end'] = '2022-02-28'
|
||||
resp.form['agendas'] = [agenda2.pk]
|
||||
resp.form['status'] = ['not-invoiced']
|
||||
resp = resp.form.submit()
|
||||
events = resp.context['form'].get_events()
|
||||
assert 'not_checked' not in events
|
||||
assert list(events['not_invoiced']) == [recurrent_event1, recurrent_event2]
|
||||
|
||||
resp = app.get('/manage/agendas/events/report/')
|
||||
resp.form['date_start'] = '2022-02-01'
|
||||
resp.form['date_end'] = '2022-02-28'
|
||||
resp.form['agendas'] = [agenda2.pk]
|
||||
resp.form['status'] = ['not-checked', 'not-invoiced']
|
||||
resp = resp.form.submit()
|
||||
events = resp.context['form'].get_events()
|
||||
assert list(events['not_checked']) == [recurrent_event1, recurrent_event3, event3]
|
||||
assert list(events['not_invoiced']) == [recurrent_event1, recurrent_event2]
|
||||
|
||||
|
||||
def test_events_report_csv(app, admin_user):
|
||||
agenda = Agenda.objects.create(label='Events', kind='events')
|
||||
Event.objects.create(
|
||||
label='event',
|
||||
start_datetime=make_aware(datetime.datetime(2022, 2, 1, 12, 0)),
|
||||
places=10,
|
||||
agenda=agenda,
|
||||
checked=False,
|
||||
invoiced=False,
|
||||
)
|
||||
recurring_event = Event.objects.create(
|
||||
label='recurring',
|
||||
start_datetime=make_aware(datetime.datetime(2022, 2, 1, 12, 0)),
|
||||
places=10,
|
||||
agenda=agenda,
|
||||
recurrence_days=[1],
|
||||
recurrence_end_date=datetime.date(2022, 2, 2),
|
||||
checked=False,
|
||||
invoiced=False,
|
||||
)
|
||||
recurring_event.create_all_recurrences()
|
||||
|
||||
login(app)
|
||||
resp = app.get(
|
||||
'/manage/agendas/events/report/?csv=&date_start=2022-02-01&date_end=2022-02-28&agendas=%s&status=not-checked&status=not-invoiced'
|
||||
% agenda.pk
|
||||
)
|
||||
assert resp.headers['Content-Type'] == 'text/csv'
|
||||
assert (
|
||||
resp.headers['Content-Disposition']
|
||||
== 'attachment; filename="events_report_2022-02-01_2022-02-28.csv"'
|
||||
)
|
||||
assert resp.text == (
|
||||
'Not checked\r\n'
|
||||
'agenda,label,date,time\r\n'
|
||||
'Events,event,2022-02-01,11:00\r\n'
|
||||
'Events,recurring,2022-02-01,11:00\r\n'
|
||||
'Not invoiced\r\n'
|
||||
'agenda,label,date,time\r\n'
|
||||
'Events,event,2022-02-01,11:00\r\n'
|
||||
'Events,recurring,2022-02-01,11:00\r\n'
|
||||
)
|
||||
|
||||
resp = app.get(
|
||||
'/manage/agendas/events/report/?csv=&date_start=2022-02-01&date_end=2022-02-28&agendas=%s&status=not-checked'
|
||||
% agenda.pk
|
||||
)
|
||||
assert resp.text == (
|
||||
'Not checked\r\n'
|
||||
'agenda,label,date,time\r\n'
|
||||
'Events,event,2022-02-01,11:00\r\n'
|
||||
'Events,recurring,2022-02-01,11:00\r\n'
|
||||
)
|
||||
|
||||
resp = app.get(
|
||||
'/manage/agendas/events/report/?csv=&date_start=2022-02-01&date_end=2022-02-28&agendas=%s&status=not-invoiced'
|
||||
% agenda.pk
|
||||
)
|
||||
assert resp.text == (
|
||||
'Not invoiced\r\n'
|
||||
'agenda,label,date,time\r\n'
|
||||
'Events,event,2022-02-01,11:00\r\n'
|
||||
'Events,recurring,2022-02-01,11:00\r\n'
|
||||
)
|
||||
|
||||
# form invalid
|
||||
resp = app.get(
|
||||
'/manage/agendas/events/report/?csv=&date_start=2022-02-01&date_end=2022-02-28&agendas=%s' % agenda.pk
|
||||
)
|
||||
assert resp.context['form'].errors['status'] == ['This field is required.']
|
||||
|
|
Loading…
Reference in New Issue