chrono/chrono/manager/forms.py

1648 lines
59 KiB
Python

# chrono - agendas system
# Copyright (C) 2016 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import copy
import csv
import datetime
from collections import defaultdict
from io import StringIO
from operator import itemgetter
import django_filters
from dateutil.relativedelta import relativedelta
from django import forms
from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.humanize.templatetags.humanize import ordinal
from django.core.exceptions import FieldDoesNotExist
from django.core.validators import URLValidator
from django.db import transaction
from django.db.models import DurationField, ExpressionWrapper, F
from django.forms import ValidationError, formset_factory
from django.template import Context, Template, TemplateSyntaxError, VariableDoesNotExist
from django.utils.encoding import force_str
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 chrono.agendas.models import (
WEEK_CHOICES,
WEEKDAY_CHOICES,
WEEKDAYS_LIST,
Agenda,
AgendaNotificationsSettings,
AgendaReminderSettings,
Booking,
Desk,
Event,
EventsType,
MeetingType,
Person,
Resource,
SharedCustodyHolidayRule,
SharedCustodyPeriod,
SharedCustodyRule,
SharedCustodySettings,
Subscription,
TimePeriod,
TimePeriodException,
TimePeriodExceptionGroup,
TimePeriodExceptionSource,
UnavailabilityCalendar,
VirtualMember,
generate_slug,
)
from chrono.utils.lingo import get_agenda_check_types
from chrono.utils.timezone import localtime, make_aware, now
from . import widgets
from .widgets import SplitDateTimeField, WeekdaysWidget
class AgendaAddForm(forms.ModelForm):
edit_role = forms.ModelChoiceField(
label=_('Edit Role'), required=False, queryset=Group.objects.all().order_by('name')
)
view_role = forms.ModelChoiceField(
label=_('View Role'), required=False, queryset=Group.objects.all().order_by('name')
)
class Meta:
model = Agenda
fields = ['label', 'kind', 'category', 'edit_role', 'view_role']
def save(self, *args, **kwargs):
create = self.instance.pk is None
super().save()
if create and self.instance.kind == 'meetings':
default_desk = self.instance.desk_set.create(label=_('Desk 1'))
default_desk.import_timeperiod_exceptions_from_settings(enable=True)
self.instance.desk_simple_management = True
self.instance.save()
return self.instance
class AgendaEditForm(forms.ModelForm):
class Meta:
model = Agenda
fields = [
'label',
'slug',
'category',
'anonymize_delay',
'default_view',
'booking_form_url',
'events_type',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if kwargs['instance'].kind != 'events':
del self.fields['booking_form_url']
del self.fields['events_type']
self.fields['default_view'].choices = [
(k, v) for k, v in self.fields['default_view'].choices if k != 'open_events'
]
else:
if not EventsType.objects.exists():
del self.fields['events_type']
class AgendaBookingDelaysForm(forms.ModelForm):
class Meta:
model = Agenda
fields = [
'minimal_booking_delay',
'minimal_booking_delay_in_working_days',
'maximal_booking_delay',
'minimal_booking_time',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if kwargs['instance'].kind != 'virtual':
self.fields['minimal_booking_delay'].required = True
self.fields['maximal_booking_delay'].required = True
if kwargs['instance'].kind != 'events' or settings.WORKING_DAY_CALENDAR is None:
del self.fields['minimal_booking_delay_in_working_days']
class AgendaRolesForm(AgendaAddForm):
class Meta:
model = Agenda
fields = [
'edit_role',
'view_role',
]
class UnavailabilityCalendarAddForm(forms.ModelForm):
class Meta:
model = UnavailabilityCalendar
fields = ['label', 'edit_role', 'view_role']
edit_role = forms.ModelChoiceField(
label=_('Edit Role'), required=False, queryset=Group.objects.all().order_by('name')
)
view_role = forms.ModelChoiceField(
label=_('View Role'), required=False, queryset=Group.objects.all().order_by('name')
)
class UnavailabilityCalendarEditForm(UnavailabilityCalendarAddForm):
class Meta:
model = UnavailabilityCalendar
fields = ['label', 'slug', 'edit_role', 'view_role']
class NewEventForm(forms.ModelForm):
frequency = forms.ChoiceField(
label=_('Event frequency'),
widget=forms.RadioSelect,
choices=(
('unique', _('Unique')),
('recurring', _('Recurring')),
),
initial='unique',
help_text=_('This field will not be editable once event has bookings.'),
)
recurrence_days = forms.TypedMultipleChoiceField(
choices=WEEKDAY_CHOICES,
coerce=int,
required=False,
widget=WeekdaysWidget,
label=_('Recurrence days'),
)
class Meta:
model = Event
fields = [
'label',
'start_datetime',
'frequency',
'recurrence_days',
'recurrence_week_interval',
'recurrence_end_date',
'duration',
'places',
]
field_classes = {
'start_datetime': SplitDateTimeField,
}
widgets = {
'recurrence_end_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
}
def clean(self):
super().clean()
if self.cleaned_data.get('frequency') == 'unique':
self.cleaned_data['recurrence_days'] = None
self.cleaned_data['recurrence_end_date'] = None
def clean_start_datetime(self):
start_datetime = self.cleaned_data['start_datetime']
if start_datetime.year < 2000:
raise ValidationError(_('Year must be after 2000.'))
return start_datetime
def clean_recurrence_days(self):
recurrence_days = self.cleaned_data['recurrence_days']
if recurrence_days == []:
return None
return recurrence_days
def clean_recurrence_end_date(self):
recurrence_end_date = self.cleaned_data['recurrence_end_date']
if recurrence_end_date and recurrence_end_date > now().date() + datetime.timedelta(days=3 * 365):
raise ValidationError(
_(
'Recurrence end date cannot be more than 3 years from now. '
'If the end date is not known, this field can simply be left blank.'
)
)
return recurrence_end_date
def save(self, *args, **kwargs):
with transaction.atomic():
event = super().save(*args, **kwargs)
if event.recurrence_days:
event.create_all_recurrences()
return event
class EventForm(NewEventForm):
protected_fields = (
'slug',
'start_datetime',
'frequency',
'recurrence_days',
'recurrence_week_interval',
)
class Meta:
model = Event
widgets = {
'recurrence_end_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
}
fields = [
'label',
'slug',
'start_datetime',
'frequency',
'recurrence_days',
'recurrence_week_interval',
'recurrence_end_date',
'duration',
'publication_datetime',
'places',
'waiting_list_places',
'description',
'pricing',
'url',
]
field_classes = {
'start_datetime': SplitDateTimeField,
'publication_datetime': SplitDateTimeField,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['frequency'].initial = 'recurring' if self.instance.recurrence_days else 'unique'
if not self.instance.recurrence_days and self.instance.booking_set.exists():
self.fields['frequency'].disabled = True
self.fields['frequency'].help_text = ''
self.fields['frequency'].widget.attrs['title'] = _(
'This field cannot be modified because event has bookings.'
)
if self.instance.recurrence_days and self.instance.has_recurrences_booked():
for field in self.protected_fields:
self.fields[field].disabled = True
self.fields[field].help_text = _(
'This field cannot be modified because some recurrences have bookings attached to them.'
)
if self.instance.agenda.events_type and not self.instance.primary_event:
field_classes = {
'text': forms.CharField,
'textarea': forms.CharField,
'bool': forms.NullBooleanField,
}
widget_classes = {
'textarea': forms.widgets.Textarea,
}
for custom_field in self.instance.agenda.events_type.get_custom_fields():
field_class = field_classes[custom_field['field_type']]
field_name = 'custom_field_%s' % custom_field['varname']
self.fields[field_name] = field_class(
label=custom_field['label'],
required=False,
initial=self.instance.custom_fields.get(custom_field['varname']),
widget=widget_classes.get(custom_field['field_type']),
)
if self.instance.primary_event:
for field in (
'slug',
'recurrence_end_date',
'publication_datetime',
'frequency',
'recurrence_days',
'recurrence_week_interval',
):
del self.fields[field]
def clean_slug(self):
slug = self.cleaned_data['slug']
if self.instance.agenda.event_set.filter(slug=slug).exclude(pk=self.instance.pk).exists():
raise ValidationError(_('Another event exists with the same identifier.'))
return slug
def clean(self):
super().clean()
if (
'recurrence_end_date' in self.changed_data
and 'recurrence_end_date' in self.cleaned_data
and self.instance.has_recurrences_booked(after=self.cleaned_data['recurrence_end_date'])
):
raise ValidationError(_('Bookings exist after this date.'))
if self.instance.agenda.events_type and self.instance.primary_event is None:
custom_fields = {}
for custom_field in self.instance.agenda.events_type.get_custom_fields():
field_name = 'custom_field_%s' % custom_field['varname']
custom_fields[custom_field['varname']] = self.cleaned_data.get(field_name)
if field_name in self.cleaned_data:
del self.cleaned_data[field_name]
self.cleaned_data['custom_fields'] = custom_fields
def save(self, *args, **kwargs):
with self.instance.update_recurrences(
self.changed_data,
self.cleaned_data,
self.protected_fields,
list(self.protected_fields) + ['recurrence_end_date', 'frequency'],
):
super(NewEventForm, self).save(commit=False, *args, **kwargs)
if 'custom_fields' in self.cleaned_data:
self.instance.custom_fields = self.cleaned_data['custom_fields']
self.instance.save()
return self.instance
class EventDuplicateForm(forms.ModelForm):
class Meta:
model = Event
fields = [
'label',
'start_datetime',
]
field_classes = {
'start_datetime': SplitDateTimeField,
}
def save(self, *args, **kwargs):
with transaction.atomic():
self.instance = self.instance.duplicate(
label=self.cleaned_data["label"], start_datetime=self.cleaned_data["start_datetime"]
)
if self.instance.recurrence_days:
self.instance.create_all_recurrences()
return self.instance
class EventsTypeForm(forms.ModelForm):
class Meta:
model = EventsType
fields = ['label', 'slug']
class CustomFieldForm(forms.Form):
varname = forms.SlugField(label=_('Field slug'), required=False)
label = forms.CharField(label=_('Field label'), required=False)
field_type = forms.ChoiceField(
label=_('Field type'),
choices=[
('', '-------'),
('text', _('Text')),
('textarea', _('Textarea')),
('bool', _('Boolean')),
],
required=False,
)
def clean(self):
cleaned_data = super().clean()
if cleaned_data.get('varname') and not cleaned_data.get('label'):
self.add_error('label', _('This field is required.'))
if cleaned_data.get('varname') and not cleaned_data.get('field_type'):
self.add_error('field_type', _('This field is required.'))
return cleaned_data
CustomFieldFormSet = formset_factory(CustomFieldForm)
class BookingCheckFilterSet(django_filters.FilterSet):
class Meta:
model = Booking
fields = []
def __init__(self, *args, **kwargs):
self.agenda = kwargs.pop('agenda')
filters = kwargs.pop('filters')
super().__init__(*args, **kwargs)
# add filters on extra_data to filterset
for key, values in filters.items():
self.filters['extra-data-%s' % key] = django_filters.ChoiceFilter(
label=_('Filter by %s') % key,
field_name='extra_data__%s' % key,
lookup_expr='iexact',
choices=[(v, v) for v in values],
empty_label=_('all'),
widget=forms.RadioSelect,
)
self.filters['sort'] = django_filters.ChoiceFilter(
label=_('Sort by'),
choices=[
('lastname,firstname', _('Last name, first name')),
('firstname,lastname', _('First name, last name')),
],
empty_label=None,
required=True,
initial='lastname,firstname',
widget=forms.RadioSelect,
method='do_nothing',
)
self.filters['sort'].parent = self
# add filters on booking status to filterset
status_choices = [
('booked', _('With booking')),
('not-booked', _('Without booking')),
('cancelled', _('Cancelled')),
('not-checked', _('Not checked')),
('presence', _('Presence')),
]
check_types = get_agenda_check_types(self.agenda)
absence_check_types = [ct for ct in check_types if ct.kind == 'absence']
presence_check_types = [ct for ct in check_types if ct.kind == 'presence']
status_choices += [
('presence::%s' % ct.slug, _('Presence (%s)') % ct.label) for ct in presence_check_types
]
status_choices += [('absence', _('Absence'))]
status_choices += [
('absence::%s' % ct.slug, _('Absence (%s)') % ct.label) for ct in absence_check_types
]
self.filters['booking-status'] = django_filters.ChoiceFilter(
label=_('Filter by status'),
choices=status_choices,
empty_label=_('all'),
widget=forms.RadioSelect,
method='filter_booking_status',
)
self.filters['booking-status'].parent = self
def filter_booking_status(self, queryset, name, value):
if value == 'not-booked':
return queryset.none()
if value == 'cancelled':
return queryset.filter(cancellation_datetime__isnull=False)
queryset = queryset.filter(cancellation_datetime__isnull=True)
if value == 'booked':
return queryset
if value == 'not-checked':
return queryset.filter(user_was_present__isnull=True)
if value == 'presence':
return queryset.filter(user_was_present=True)
if value == 'absence':
return queryset.filter(user_was_present=False)
if value.startswith('absence::'):
return queryset.filter(user_was_present=False, user_check_type_slug=value.split('::')[1])
if value.startswith('presence::'):
return queryset.filter(user_was_present=True, user_check_type_slug=value.split('::')[1])
return queryset
def do_nothing(self, queryset, name, value):
return queryset
class SubscriptionCheckFilterSet(BookingCheckFilterSet):
class Meta:
model = Subscription
fields = []
def filter_booking_status(self, queryset, name, value):
if value != 'not-booked':
return queryset.none()
return queryset
class BookingCheckAbsenceForm(forms.Form):
check_type = forms.ChoiceField(required=False)
def __init__(self, *args, **kwargs):
agenda = kwargs.pop('agenda')
super().__init__(*args, **kwargs)
check_types = get_agenda_check_types(agenda)
self.absence_check_types = [ct for ct in check_types if ct.kind == 'absence']
self.fields['check_type'].choices = [('', '---------')] + [
(ct.slug, ct.label) for ct in self.absence_check_types
]
class BookingCheckPresenceForm(forms.Form):
check_type = forms.ChoiceField(required=False)
def __init__(self, *args, **kwargs):
agenda = kwargs.pop('agenda')
super().__init__(*args, **kwargs)
check_types = get_agenda_check_types(agenda)
self.presence_check_types = [ct for ct in check_types if ct.kind == 'presence']
self.fields['check_type'].choices = [('', '---------')] + [
(ct.slug, ct.label) for ct in self.presence_check_types
]
class EventsTimesheetForm(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'),
)
extra_data = forms.CharField(
label=_('Extra datas'),
max_length=250,
required=False,
help_text=_('Comma separated list of keys defined in extra_data.'),
)
group_by = forms.CharField(
label=_('Group by'),
max_length=50,
required=False,
help_text=_('Key defined in extra_data.'),
)
with_page_break = forms.BooleanField(
label=_('Add a page break before each grouper'),
required=False,
)
sort = forms.ChoiceField(
label=_('Sort by'),
choices=[
('lastname,firstname', _('Last name, first name')),
('firstname,lastname', _('First name, last name')),
],
initial='lastname,firstname',
)
date_display = forms.ChoiceField(
label=_('Date display'),
choices=[
('all', _('All on the same page')),
('month', _('1 month per page')),
('week', _('1 week per page')),
('custom', _('Custom')),
],
initial='all',
)
custom_nb_dates_per_page = forms.IntegerField(
label=_('Number of dates per page'),
required=False,
)
activity_display = forms.ChoiceField(
label=_('Activity display'),
choices=[
('row', _('In line')),
('col', _('In column')),
],
initial='row',
)
orientation = forms.ChoiceField(
label=_('PDF orientation'),
choices=[
('portrait', _('Portrait')),
('landscape', _('Landscape')),
],
initial='portrait',
)
def __init__(self, *args, **kwargs):
self.agenda = kwargs.pop('agenda')
self.event = kwargs.pop('event', None)
super().__init__(*args, **kwargs)
if self.event is not None:
del self.fields['date_start']
del self.fields['date_end']
del self.fields['date_display']
del self.fields['custom_nb_dates_per_page']
del self.fields['activity_display']
def get_slots(self):
extra_data = self.cleaned_data['extra_data'].split(',')
extra_data = [d.strip() for d in extra_data if d.strip()]
group_by = self.cleaned_data['group_by'].strip()
all_extra_data = extra_data[:]
if group_by:
all_extra_data += [group_by]
if self.event is not None:
all_events = [self.event]
min_start = self.event.start_datetime
max_start = min_start + datetime.timedelta(days=1)
else:
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)
# fetch all events in this range
all_events = (
self.agenda.event_set.filter(
recurrence_days__isnull=True,
start_datetime__gte=min_start,
start_datetime__lt=max_start,
cancelled=False,
)
.select_related('primary_event')
.order_by('start_datetime', 'label')
)
dates = defaultdict(list)
events = []
dates_per_event_id = defaultdict(list)
for event in all_events:
date = localtime(event.start_datetime).date()
real_event = event.primary_event or event
dates[date].append(real_event)
if real_event not in events:
events.append(real_event)
dates_per_event_id[real_event.pk].append(date)
dates = sorted(dates.items(), key=lambda a: a[0])
date_display = self.cleaned_data.get('date_display') or 'all'
if date_display in ['month', 'week']:
grouper = defaultdict(list)
for date, event in dates:
if date_display == 'month':
attr = date.month
else:
attr = date.isocalendar().week
grouper[(date.year, attr)].append((date, event))
dates = [grouper[g] for g in sorted(grouper.keys())]
elif date_display == 'custom':
n = self.cleaned_data['custom_nb_dates_per_page']
dates = [dates[i : i + n] for i in range(0, len(dates), n)]
else:
dates = [dates]
event_slots = []
for event in events:
event_slots.append(
{'event': event, 'dates': {date: False for date in dates_per_event_id[event.pk]}}
)
users = {}
subscriptions = self.agenda.subscriptions.filter(date_start__lt=max_start, date_end__gt=min_start)
for subscription in subscriptions:
if subscription.user_external_id in users:
continue
users[subscription.user_external_id] = {
'user_id': subscription.user_external_id,
'user_first_name': subscription.user_first_name,
'user_last_name': subscription.user_last_name,
'extra_data': {k: (subscription.extra_data or {}).get(k) or '' for k in all_extra_data},
'events': copy.deepcopy(event_slots),
}
booking_qs_kwargs = {}
if not self.agenda.subscriptions.exists():
booking_qs_kwargs = {'cancellation_datetime__isnull': True}
booked_qs = (
Booking.objects.filter(
event__in=all_events,
in_waiting_list=False,
primary_booking__isnull=True,
**booking_qs_kwargs,
)
.exclude(user_external_id='')
.select_related('event')
.order_by('event__start_datetime')
)
participants = 0
for booking in booked_qs:
user_id = booking.user_external_id
if user_id not in users:
users[user_id] = {
'user_id': user_id,
'user_first_name': booking.user_first_name,
'user_last_name': booking.user_last_name,
'extra_data': {k: (booking.extra_data or {}).get(k) or '' for k in all_extra_data},
'events': copy.deepcopy(event_slots),
}
if booking.cancellation_datetime is not None:
continue
# mark the slot as booked
date = localtime(booking.event.start_datetime).date()
for event in users[user_id]['events']:
if event['event'].pk != (booking.event.primary_event_id or booking.event_id):
continue
if date in event['dates']:
event['dates'][date] = True
participants += 1
break
if self.cleaned_data['sort'] == 'lastname,firstname':
sort_fields = ['user_last_name', 'user_first_name']
else:
sort_fields = ['user_first_name', 'user_last_name']
if group_by:
groupers = defaultdict(list)
for user in users.values():
groupers[user['extra_data'].get(group_by) or ''].append(user)
users = [
{
'grouper': g,
'users': sorted(u, key=itemgetter(*sort_fields, 'user_id')),
}
for g, u in groupers.items()
]
users = sorted(users, key=itemgetter('grouper'))
if users and users[0]['grouper'] == '':
users = users[1:] + users[:1]
else:
users = [
{
'grouper': '',
'users': sorted(users.values(), key=itemgetter(*sort_fields, 'user_id')),
}
]
data = {
'dates': dates,
'events': events,
'users': users,
'extra_data': extra_data,
}
if self.event:
data['participants'] = participants
return data
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.'))
if cleaned_data.get('date_display') == 'custom':
if not cleaned_data.get('custom_nb_dates_per_page'):
self.add_error('custom_nb_dates_per_page', _('This field is required.'))
return cleaned_data
class AgendaResourceForm(forms.Form):
resource = forms.ModelChoiceField(label=_('Resource'), queryset=Resource.objects.none())
def __init__(self, *args, **kwargs):
agenda = kwargs.pop('agenda')
super().__init__(*args, **kwargs)
self.fields['resource'].queryset = Resource.objects.exclude(agenda=agenda)
class NewMeetingTypeForm(forms.ModelForm):
class Meta:
model = MeetingType
exclude = ['agenda', 'slug', 'deleted']
def clean(self):
super().clean()
agenda = self.instance.agenda
for virtual_agenda in agenda.virtual_agendas.all():
for real_agenda in virtual_agenda.real_agendas.all():
if real_agenda != agenda:
raise ValidationError(
_("Can't add a meetingtype to an agenda that is included in a virtual agenda.")
)
class MeetingTypeForm(forms.ModelForm):
class Meta:
model = MeetingType
exclude = ['agenda', 'deleted']
def clean_slug(self):
slug = self.cleaned_data['slug']
if self.instance.agenda.meetingtype_set.filter(slug=slug).exclude(pk=self.instance.pk).exists():
raise ValidationError(_('Another meeting type exists with the same identifier.'))
return slug
def clean(self):
super().clean()
for virtual_agenda in self.instance.agenda.virtual_agendas.all():
if virtual_agenda.real_agendas.count() == 1:
continue
for mt in virtual_agenda.iter_meetingtypes():
if (
mt.label == self.instance.label
and mt.slug == self.instance.slug
and mt.duration == self.instance.duration
):
raise ValidationError(
_('This meetingtype is used by a virtual agenda: %s' % virtual_agenda)
)
class TimePeriodFormBase(forms.Form):
repeat = forms.ChoiceField(
label=_('Repeat'),
widget=forms.RadioSelect,
choices=(
('every-week', _('Every week')),
('custom', _('Custom')),
),
initial='every-week',
)
weekday_indexes = forms.TypedMultipleChoiceField(
choices=WEEK_CHOICES,
coerce=int,
required=False,
label='',
widget=forms.CheckboxSelectMultiple(),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'date' in self.fields:
del self.fields['repeat']
del self.fields['weekday_indexes']
def clean(self):
cleaned_data = super().clean()
if cleaned_data['end_time'] <= cleaned_data['start_time']:
raise ValidationError(_('End time must come after start time.'))
if cleaned_data.get('repeat') == 'every-week':
cleaned_data['weekday_indexes'] = None
return cleaned_data
class TimePeriodAddForm(TimePeriodFormBase):
field_order = ['weekdays', 'start_time', 'end_time', 'repeat', 'weekday_indexes']
weekdays = forms.MultipleChoiceField(
label=_('Days'), widget=forms.CheckboxSelectMultiple(), choices=WEEKDAYS_LIST
)
start_time = forms.TimeField(label=_('Start Time'), widget=widgets.TimeWidget())
end_time = forms.TimeField(label=_('End Time'), widget=widgets.TimeWidget())
class TimePeriodForm(TimePeriodFormBase, forms.ModelForm):
class Meta:
model = TimePeriod
widgets = {
'date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
'start_time': widgets.TimeWidget(),
'end_time': widgets.TimeWidget(),
}
fields = ['weekday', 'start_time', 'end_time', 'repeat', 'weekday_indexes']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.old_weekday = self.instance.weekday
self.old_start_time = self.instance.start_time
self.old_end_time = self.instance.end_time
self.old_date = self.instance.date
if self.instance.weekday_indexes:
self.fields['repeat'].initial = 'custom'
if self.instance.agenda: # virtual agenda exclusion period
del self.fields['repeat']
del self.fields['weekday_indexes']
def clean_date(self):
data = self.cleaned_data['date']
if data.year < 2000:
raise ValidationError(_('Year must be after 2000.'))
return data
def save(self):
super().save()
if not self.instance.desk:
return self.instance
if not self.instance.desk.agenda.desk_simple_management:
return self.instance
for desk in self.instance.desk.agenda.desk_set.exclude(pk=self.instance.desk.pk):
timeperiod = desk.timeperiod_set.filter(
weekday=self.old_weekday,
start_time=self.old_start_time,
end_time=self.old_end_time,
date=self.old_date,
).first()
if timeperiod is not None:
timeperiod.weekday = self.instance.weekday
timeperiod.start_time = self.instance.start_time
timeperiod.end_time = self.instance.end_time
timeperiod.date = self.instance.date
timeperiod.save()
return self.instance
class DateTimePeriodForm(TimePeriodForm):
class Meta(TimePeriodForm.Meta):
fields = ['date', 'start_time', 'end_time']
class ExcludedPeriodAddForm(TimePeriodAddForm):
repeat = None
weekday_indexes = None
class NewDeskForm(forms.ModelForm):
copy_from = forms.ModelChoiceField(
label=_('Copy settings of desk'),
required=False,
queryset=Desk.objects.none(),
)
class Meta:
model = Desk
exclude = ['agenda', 'slug']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.agenda.desk_simple_management:
del self.fields['copy_from']
else:
self.fields['copy_from'].queryset = Desk.objects.filter(agenda=self.instance.agenda)
def save(self):
if self.instance.agenda.desk_simple_management:
desk = self.instance.agenda.desk_set.first()
if desk is not None:
return desk.duplicate(label=self.cleaned_data['label'])
elif self.cleaned_data['copy_from']:
return self.cleaned_data['copy_from'].duplicate(label=self.cleaned_data['label'])
super().save()
self.instance.import_timeperiod_exceptions_from_settings(enable=True)
return self.instance
class DeskForm(forms.ModelForm):
class Meta:
model = Desk
exclude = ['agenda']
def clean_slug(self):
slug = self.cleaned_data['slug']
if self.instance.agenda.desk_set.filter(slug=slug).exclude(pk=self.instance.pk).exists():
raise ValidationError(_('Another desk exists with the same identifier.'))
return slug
class TimePeriodExceptionForm(forms.ModelForm):
class Meta:
model = TimePeriodException
fields = ['start_datetime', 'end_datetime', 'label']
field_classes = {
'start_datetime': SplitDateTimeField,
'end_datetime': SplitDateTimeField,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.old_label = self.instance.label
self.old_start_datetime = self.instance.start_datetime
self.old_end_datetime = self.instance.end_datetime
def clean(self):
cleaned_data = super().clean()
for date_field in ('start_datetime', 'end_datetime'):
if date_field in cleaned_data and cleaned_data[date_field].year < 2000:
self.add_error(date_field, _('Year must be after 2000.'))
if 'start_datetime' in cleaned_data and 'end_datetime' in cleaned_data:
if cleaned_data['end_datetime'] <= cleaned_data['start_datetime']:
self.add_error('end_datetime', _('End datetime must be greater than start datetime.'))
return cleaned_data
def save(self):
super().save()
self.exceptions = [self.instance]
desk_simple_management = False
if self.instance.desk_id:
desk_simple_management = self.instance.desk.agenda.desk_simple_management
if desk_simple_management:
for desk in self.instance.desk.agenda.desk_set.exclude(pk=self.instance.desk_id):
exception = desk.timeperiodexception_set.filter(
source__isnull=True,
label=self.old_label,
start_datetime=self.old_start_datetime,
end_datetime=self.old_end_datetime,
).first()
if exception is not None:
exception.label = self.instance.label
exception.start_datetime = self.instance.start_datetime
exception.end_datetime = self.instance.end_datetime
exception.save()
self.exceptions.append(exception)
return self.instance
class NewTimePeriodExceptionForm(TimePeriodExceptionForm):
all_desks = forms.BooleanField(label=_('Apply exception on all desks of the agenda'), required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.unavailability_calendar:
del self.fields['all_desks']
elif self.instance.desk_id and self.instance.desk.agenda.desk_set.count() == 1:
del self.fields['all_desks']
elif self.instance.desk_id and self.instance.desk.agenda.desk_simple_management:
del self.fields['all_desks']
def save(self):
super().save()
self.exceptions = [self.instance]
all_desks = self.cleaned_data.get('all_desks')
desk_simple_management = False
if self.instance.desk_id:
desk_simple_management = self.instance.desk.agenda.desk_simple_management
if all_desks or desk_simple_management:
for desk in self.instance.desk.agenda.desk_set.exclude(pk=self.instance.desk_id):
self.exceptions.append(
TimePeriodException.objects.create(
desk=desk,
label=self.instance.label,
start_datetime=self.instance.start_datetime,
end_datetime=self.instance.end_datetime,
)
)
return self.instance
class VirtualMemberForm(forms.ModelForm):
class Meta:
model = VirtualMember
fields = ['real_agenda']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['real_agenda'].queryset = Agenda.objects.filter(kind='meetings').exclude(
virtual_agendas=self.instance.virtual_agenda
)
class ImportEventsForm(forms.Form):
events_csv_file = forms.FileField(
label=_('Events File'),
required=True,
help_text=_(
'CSV file with date, time, number of places, '
'number of places in waiting list, label, and '
'optionally, identifier, description, pricing, '
'URL, and publication date as columns.'
),
)
events = None
def __init__(self, agenda_pk, **kwargs):
self.agenda_pk = agenda_pk
super().__init__(**kwargs)
def clean_events_csv_file(self):
class ValidationErrorWithOrdinal(ValidationError):
def __init__(self, message, event_no):
super().__init__(message)
self.message = format_html(message, event_no=mark_safe(ordinal(event_no + 1)))
content = self.cleaned_data['events_csv_file'].read()
if b'\0' in content:
raise ValidationError(_('Invalid file format.'))
for charset in ('utf-8-sig', 'iso-8859-15'):
try:
content = content.decode(charset)
break
except UnicodeDecodeError:
continue
# all byte-sequences are ok for iso-8859-15 so we will always reach
# this line with content being a unicode string.
try:
dialect = csv.Sniffer().sniff(content)
dialect.doublequote = True
except csv.Error:
dialect = None
events = []
warnings = {}
events_by_slug = {x.slug: x for x in Event.objects.filter(agenda=self.agenda_pk)}
event_ids_with_bookings = set(
Booking.objects.filter(
event__agenda=self.agenda_pk, cancellation_datetime__isnull=True
).values_list('event_id', flat=True)
)
seen_slugs = set(events_by_slug.keys())
for i, csvline in enumerate(csv.reader(StringIO(content), dialect=dialect)):
if not csvline:
continue
if len(csvline) < 3:
raise ValidationErrorWithOrdinal(_('Invalid file format. ({event_no} event)'), i)
if i == 0 and csvline[0].strip('#') in ('date', 'Date', _('date'), _('Date')):
continue
# label needed to generate a slug
label = None
if len(csvline) >= 5:
label = force_str(csvline[4])
# get or create event
event = None
slug = None
if len(csvline) >= 6:
slug = force_str(csvline[5]) if csvline[5] else None
# get existing event if relevant
if slug and slug in seen_slugs:
event = events_by_slug[slug]
# update label
event.label = label
if event is None:
# new event
event = Event(agenda_id=self.agenda_pk, label=label)
# generate a slug if not provided
event.slug = slug or generate_slug(event, seen_slugs=seen_slugs, agenda=self.agenda_pk)
# maintain caches
seen_slugs.add(event.slug)
events_by_slug[event.slug] = event
for datetime_fmt in (
'%Y-%m-%d %H:%M',
'%d/%m/%Y %H:%M',
'%d/%m/%Y %Hh%M',
'%Y-%m-%d %H:%M:%S',
'%d/%m/%Y %H:%M:%S',
):
try:
event_datetime = make_aware(
datetime.datetime.strptime('%s %s' % tuple(csvline[:2]), datetime_fmt)
)
except ValueError:
continue
if (
event.pk is not None
and event.start_datetime != event_datetime
and event.start_datetime > now()
and event.pk in event_ids_with_bookings
and event.pk not in warnings
):
# event start datetime has changed, event is not past and has not cancelled bookings
# => warn the user
warnings[event.pk] = event
event.start_datetime = event_datetime
break
else:
raise ValidationErrorWithOrdinal(
_('Invalid file format. (date/time format, {event_no} event)'), i
)
try:
event.places = int(csvline[2])
except ValueError:
raise ValidationError(_('Invalid file format. (number of places, {event_no} event)'), i)
if len(csvline) >= 4:
try:
event.waiting_list_places = int(csvline[3])
except ValueError:
raise ValidationError(
_('Invalid file format. (number of places in waiting list, {event_no} event)'), i
)
column_index = 7
for more_attr in ('description', 'pricing', 'url'):
if len(csvline) >= column_index:
setattr(event, more_attr, csvline[column_index - 1])
column_index += 1
if len(csvline) >= 10 and csvline[9]: # publication date is optional
for datetime_fmt in (
'%Y-%m-%d',
'%d/%m/%Y',
'%Y-%m-%d %H:%M',
'%d/%m/%Y %H:%M',
'%d/%m/%Y %Hh%M',
'%Y-%m-%d %H:%M:%S',
'%d/%m/%Y %H:%M:%S',
):
try:
event.publication_datetime = make_aware(
datetime.datetime.strptime(csvline[9], datetime_fmt)
)
break
except ValueError:
continue
else:
raise ValidationError(_('Invalid file format. (date/time format, {event_no} event)'), i)
if len(csvline) >= 11 and csvline[10]: # duration is optional
try:
event.duration = int(csvline[10])
except ValueError:
raise ValidationError(_('Invalid file format. (duration, {event_no} event)'), i)
try:
event.full_clean(exclude=['desk', 'meeting_type', 'primary_event'])
except ValidationError as e:
errors = [_('Invalid file format:\n')]
for label, field_errors in e.message_dict.items():
label_name = self.get_verbose_name(label)
msg = _('%s: ') % label_name if label_name else ''
msg += _('%(errors)s (line %(line)d)') % {
'errors': ', '.join(field_errors),
'line': i + 1,
}
errors.append(msg)
raise ValidationError(errors)
events.append(event)
self.events = events
self.warnings = warnings
@staticmethod
def get_verbose_name(field_name):
try:
return Event._meta.get_field(field_name).verbose_name
except FieldDoesNotExist:
return ''
class ExceptionsImportForm(forms.ModelForm):
ics_file = forms.FileField(
label=_('ICS File'),
required=False,
help_text=_('ICS file containing events which will be considered as exceptions.'),
)
ics_url = forms.CharField(
label=_('URL'),
max_length=200,
required=False,
help_text=_('URL to remote calendar which will be synchronised hourly.'),
)
def clean(self, *args, **kwargs):
cleaned_data = super().clean(*args, **kwargs)
if not cleaned_data.get('ics_file') and not cleaned_data.get('ics_url'):
raise forms.ValidationError(_('Please provide an ICS File or an URL.'))
def clean_ics_url(self):
url = self.cleaned_data['ics_url']
if not url:
return url
try:
url = Template(url).render(Context(settings.TEMPLATE_VARS))
except (TemplateSyntaxError, VariableDoesNotExist) as e:
raise ValidationError(_('syntax error: %s') % e)
URLValidator()(url)
return self.cleaned_data['ics_url']
class DeskExceptionsImportForm(ExceptionsImportForm):
all_desks = forms.BooleanField(label=_('Apply exceptions on all desks of the agenda'), required=False)
class Meta:
model = Desk
fields = []
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.agenda.desk_set.count() == 1:
del self.fields['all_desks']
elif self.instance.agenda.desk_simple_management:
del self.fields['all_desks']
class UnavailabilityCalendarExceptionsImportForm(ExceptionsImportForm):
class Meta:
model = UnavailabilityCalendar
fields = []
class TimePeriodExceptionSourceReplaceForm(forms.ModelForm):
ics_newfile = forms.FileField(
label=_('ICS File'),
required=False,
help_text=_('ICS file containing events which will be considered as exceptions.'),
)
class Meta:
model = TimePeriodExceptionSource
fields = []
def save(self, *args, **kwargs):
def store_source(source):
if bool(source.ics_file):
source.ics_file.delete()
source.ics_file = self.cleaned_data['ics_newfile']
source.ics_filename = self.cleaned_data['ics_newfile'].name
source.save()
self.cleaned_data['ics_newfile'].seek(0)
old_filename = self.instance.ics_filename
store_source(self.instance)
if self.instance.desk and self.instance.desk.agenda.desk_simple_management:
for desk in self.instance.desk.agenda.desk_set.exclude(pk=self.instance.desk_id):
source = desk.timeperiodexceptionsource_set.filter(ics_filename=old_filename).first()
if source is not None:
store_source(source)
return self.instance
class AgendasImportForm(forms.Form):
agendas_json = forms.FileField(label=_('Export File'))
class AgendaDuplicateForm(forms.Form):
label = forms.CharField(label=_('New label'), max_length=150, required=False)
class BookingCancelForm(forms.ModelForm):
disable_trigger = forms.BooleanField(
label=_('Do not send cancel trigger to form'), initial=False, required=False, widget=forms.HiddenInput
)
def show_trigger_checkbox(self):
self.fields['disable_trigger'].widget = forms.CheckboxInput()
class Meta:
model = Booking
fields = []
class EventCancelForm(forms.ModelForm):
class Meta:
model = Event
fields = []
class AgendaDisplaySettingsForm(forms.ModelForm):
class Meta:
model = Agenda
fields = [
'event_display_template',
'booking_user_block_template',
]
widgets = {'booking_user_block_template': forms.Textarea(attrs={'rows': 3})}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if kwargs['instance'].kind == 'events':
self.fields['booking_user_block_template'].help_text = (
_('Displayed for each booking in event page and check page'),
)
else:
self.fields['booking_user_block_template'].help_text = (
_('Displayed for each booking in agenda view pages'),
)
del self.fields['event_display_template']
class AgendaBookingCheckSettingsForm(forms.ModelForm):
class Meta:
model = Agenda
fields = [
'booking_check_filters',
'mark_event_checked_auto',
'disable_check_update',
'enable_check_for_future_events',
'booking_extra_user_block_template',
]
widgets = {'booking_extra_user_block_template': forms.Textarea(attrs={'rows': 3})}
class AgendaNotificationsForm(forms.ModelForm):
class Meta:
model = AgendaNotificationsSettings
exclude = ['agenda']
@staticmethod
def update_choices(choices, settings):
new_choices = []
for choice in choices:
if choice[0] in (settings.EDIT_ROLE, settings.VIEW_ROLE):
role = settings.get_role_from_choice(choice[0]) or _('undefined')
choice = (choice[0], '%s (%s)' % (choice[1], role))
new_choices.append(choice)
return new_choices
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for email_field in AgendaNotificationsSettings.get_email_field_names():
self.fields[email_field].widget.attrs['size'] = 80
self.fields[email_field].label = ''
self.fields[email_field].help_text = _('Enter a comma separated list of email addresses.')
settings = kwargs['instance']
for role_field in AgendaNotificationsSettings.get_role_field_names():
field = self.fields[role_field]
field.choices = self.update_choices(field.choices, settings)
class AgendaReminderForm(forms.ModelForm):
class Meta:
model = AgendaReminderSettings
exclude = ['agenda']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not settings.SMS_URL:
del self.fields['days_before_sms']
del self.fields['sms_extra_info']
class BookingChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
name = obj.user_name or obj.label or _('Anonymous')
date = date_format(localtime(obj.creation_datetime), 'SHORT_DATETIME_FORMAT')
emails = ', '.join(sorted(obj.emails)) or _('no email')
phone_numbers = ', '.join(sorted(obj.phone_numbers)) or _('no phone number')
if settings.SMS_URL:
return '%s, %s, %s (%s)' % (name, emails, phone_numbers, date)
else:
return '%s, %s (%s)' % (name, emails, date)
class AgendaReminderTestForm(forms.Form):
booking = BookingChoiceField(
label=_('Booking'),
queryset=Booking.objects.none(),
help_text=_('Only the last ten bookings are displayed.'),
)
msg_type = forms.MultipleChoiceField(
label=_('Send via'),
choices=(('email', _('Email')), ('sms', _('SMS'))),
widget=forms.CheckboxSelectMultiple(),
)
email = forms.EmailField(
label=_('Email'),
help_text=_('This will override email specified on booking creation, if any.'),
required=False,
)
phone_number = forms.CharField(
label=_('Phone number'),
max_length=16,
help_text=_('This will override phone number specified on booking creation, if any.'),
required=False,
)
def __init__(self, *args, **kwargs):
agenda = kwargs.pop('agenda')
super().__init__(*args, **kwargs)
self.fields['booking'].queryset = Booking.objects.filter(
pk__in=Booking.objects.filter(event__agenda=agenda).order_by('-creation_datetime')[:10]
).order_by('-creation_datetime')
if not settings.SMS_URL:
self.fields['msg_type'].initial = ['email']
self.fields['msg_type'].widget = forms.MultipleHiddenInput()
del self.fields['phone_number']
class AgendasExportForm(forms.Form):
agendas = forms.BooleanField(label=_('Agendas'), required=False, initial=True)
resources = forms.BooleanField(label=_('Resources'), required=False, initial=True)
unavailability_calendars = forms.BooleanField(
label=_('Unavailability calendars'), required=False, initial=True
)
categories = forms.BooleanField(label=_('Categories'), required=False, initial=True)
events_types = forms.BooleanField(label=_('Events types'), required=False, initial=True)
shared_custody = forms.BooleanField(label=_('Shared custody'), required=False, initial=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not SharedCustodySettings.objects.exists():
self.fields['shared_custody'].initial = False
self.fields['shared_custody'].widget = forms.HiddenInput()
class SharedCustodyRuleForm(forms.ModelForm):
guardian = forms.ModelChoiceField(label=_('Guardian'), queryset=Person.objects.none())
days = forms.TypedMultipleChoiceField(
choices=WEEKDAY_CHOICES,
coerce=int,
required=True,
widget=WeekdaysWidget,
label=_('Days'),
)
class Meta:
model = SharedCustodyRule
fields = ['guardian', 'days', 'weeks']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['guardian'].empty_label = None
self.fields['guardian'].queryset = Person.objects.filter(
pk__in=[self.instance.agenda.first_guardian_id, self.instance.agenda.second_guardian_id]
)
def clean(self):
cleaned_data = super().clean()
if self.instance.agenda.rule_overlaps(
days=cleaned_data.get('days', []), weeks=cleaned_data['weeks'], instance=self.instance
):
raise ValidationError(_('Rule overlaps existing rules.'))
return cleaned_data
class SharedCustodyHolidayRuleForm(forms.ModelForm):
guardian = forms.ModelChoiceField(label=_('Guardian'), queryset=Person.objects.none())
class Meta:
model = SharedCustodyHolidayRule
fields = ['guardian', 'holiday', 'years', 'periodicity']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['guardian'].empty_label = None
self.fields['guardian'].queryset = Person.objects.filter(
pk__in=[self.instance.agenda.first_guardian_id, self.instance.agenda.second_guardian_id]
)
settings = SharedCustodySettings.get_singleton()
self.fields['holiday'].queryset = TimePeriodExceptionGroup.objects.filter(
unavailability_calendar=settings.holidays_calendar_id,
exceptions__isnull=False,
).distinct()
def clean(self):
cleaned_data = super().clean()
holidays = cleaned_data['holiday'].exceptions.annotate(
delta=ExpressionWrapper(F('end_datetime') - F('start_datetime'), output_field=DurationField())
)
is_short_holiday = holidays.filter(delta__lt=datetime.timedelta(days=28)).exists()
if 'quarters' in cleaned_data['periodicity'] and is_short_holiday:
raise ValidationError(_('Short holidays cannot be cut into quarters.'))
if self.instance.agenda.holiday_rule_overlaps(
cleaned_data['holiday'], cleaned_data['years'], cleaned_data['periodicity'], self.instance
):
raise ValidationError(_('Rule overlaps existing rules.'))
return cleaned_data
def save(self, *args, **kwargs):
with transaction.atomic():
super().save()
self.instance.update_or_create_periods()
return self.instance
class SharedCustodyPeriodForm(forms.ModelForm):
guardian = forms.ModelChoiceField(label=_('Guardian'), queryset=Person.objects.none())
class Meta:
model = SharedCustodyPeriod
fields = ['guardian', 'date_start', 'date_end']
widgets = {
'date_start': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
'date_end': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['guardian'].empty_label = None
self.fields['guardian'].queryset = Person.objects.filter(
pk__in=[self.instance.agenda.first_guardian_id, self.instance.agenda.second_guardian_id]
)
def clean(self):
cleaned_data = super().clean()
if self.instance.agenda.period_overlaps(
date_start=cleaned_data['date_start'], date_end=cleaned_data['date_end'], instance=self.instance
):
raise ValidationError(_('Period overlaps existing periods.'))
if cleaned_data['date_end'] <= cleaned_data['date_start']:
self.add_error('date_end', _('End date must be greater than start date.'))
return cleaned_data
class SharedCustodySettingsForm(forms.ModelForm):
management_role = forms.ModelChoiceField(
label=_('Management role'), required=False, queryset=Group.objects.all().order_by('name')
)
class Meta:
model = SharedCustodySettings
fields = '__all__'