# 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 . 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.timezone import localtime, make_aware, now 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 . 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', ] 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_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 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') ) 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 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')), } ] return { 'dates': dates, 'events': events, 'users': users, 'extra_data': extra_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' 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 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() 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', ] 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__'