Event: avoir une page pour lister les events d'une liste d'agendas non pointés/non facturés sur une période (#75417) #61

Open
lguerin wants to merge 2 commits from wip/75417-event-reporting into main
8 changed files with 483 additions and 2 deletions

View File

@ -89,6 +89,7 @@ from chrono.apps.snapshot.models import (
from chrono.utils.date import get_weekday_index
from chrono.utils.db import ArraySubquery, SumCardinality
from chrono.utils.interval import Interval, IntervalSet
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
@ -371,9 +372,9 @@ class Agenda(WithSnapshotMixin, WithApplicationMixin, models.Model):
return self.get_kind_display()
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)

View File

@ -38,6 +38,7 @@ from django.utils.formats import date_format
from django.utils.html import format_html, mark_safe
from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext
from gadjo.forms.widgets import MultiSelectWidget
from chrono.agendas.models import (
WEEK_CHOICES,
@ -978,6 +979,74 @@ 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(),
widget=MultiSelectWidget,
)
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())

View File

@ -0,0 +1,68 @@
{% extends "chrono/manager_home.html" %}
{% load static i18n %}
{% block css %}
{{ block.super }}
<link rel="stylesheet" type="text/css" media="all" href="{% static "css/gadjo.multiselectwidget.css" %}" />
{% endblock %}
{% block extrascripts %}
{{ block.super }}
<script src="{% static "js/gadjo.multiselectwidget.js" %}"></script>
{% endblock %}
{% 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 %}

View File

@ -71,6 +71,9 @@
<a class="button button-paragraph" href="{% url 'chrono-manager-ants-hub' %}">{% trans 'ANTS Hub' %}</a>
{% endif %}
{% endif %}
{% if lingo_enabled %}
<a class="button button-paragraph" href="{% url 'chrono-manager-events-report' %}">{% trans "Events report" %}</a>
{% endif %}
{% endif %}
{% include 'chrono/includes/application_list_fragment.html' with title_no_application=_('Agendas outside applications') %}

View File

@ -444,6 +444,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',
),
path(
'agendas/<int:pk>/events.csv',
views.agenda_import_events_sample_csv,

View File

@ -93,6 +93,7 @@ from chrono.agendas.models import (
)
from chrono.apps.export_import.models import Application
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 (
@ -121,6 +122,7 @@ from .forms import (
EventCancelForm,
EventDuplicateForm,
EventForm,
EventsReportForm,
EventsTimesheetForm,
ExcludedPeriodAddForm,
ImportEventsForm,
@ -214,6 +216,7 @@ class HomepageView(WithApplicationsMixin, ListView):
context['shared_custody_enabled'] = settings.SHARED_CUSTODY_ENABLED
context['ants_hub_enabled'] = bool(settings.CHRONO_ANTS_HUB_URL)
context['with_sidebar'] = True
context['lingo_enabled'] = bool(get_lingo_service())
return self.with_applications_context_data(context)
def get(self, request, *args, **kwargs):
@ -4357,6 +4360,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

View File

@ -76,6 +76,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/')

View File

@ -1,4 +1,5 @@
import codecs
import copy
import datetime
import json
from unittest import mock
@ -3417,3 +3418,254 @@ def test_event_detail_lease_display(app, admin_user):
assert 'Bookings (1/10)' in resp.text
assert 'Waiting List (1/2): 1 remaining place' in resp.text
assert resp.text.count('Currently being booked...') == 2
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=[1, 2],
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()
def add_agenda(resp):
select = copy.copy(resp.form.fields['agendas'][0])
select.id = 'id_agendas_1'
resp.form.fields['agendas'].append(select)
resp.form.field_order.append(('agendas', select))
login(app)
resp = app.get('/manage/agendas/events/report/')
add_agenda(resp)
resp.form['date_start'] = '2022-02-01'
resp.form['date_end'] = '2022-02-28'
resp.form.get('agendas', 0).value = agenda1.pk
resp.form.get('agendas', 1).value = agenda2.pk
resp.form['status'] = ['not-checked', 'not-invoiced']
resp = resp.form.submit()
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/')
add_agenda(resp)
resp.form['date_start'] = '2022-02-01'
resp.form['date_end'] = '2022-02-28'
resp.form.get('agendas', 0).value = agenda1.pk
resp.form.get('agendas', 1).value = 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/')
add_agenda(resp)
resp.form['date_start'] = '2022-02-01'
resp.form['date_end'] = '2022-02-28'
resp.form.get('agendas', 0).value = agenda1.pk
resp.form.get('agendas', 1).value = 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.get('agendas', 0).value = 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.get('agendas', 0).value = 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=[2],
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.']