chrono/chrono/manager/forms.py

784 lines
28 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/>.
from __future__ import unicode_literals
import csv
import datetime
from django import forms
from django.conf import settings
from django.contrib.auth.models import Group
from django.core.exceptions import FieldDoesNotExist
from django.db import transaction
from django.forms import ValidationError
from django.utils.encoding import force_text
from django.utils.six import StringIO
from django.utils.timezone import make_aware, make_naive, now
from django.utils.translation import ugettext_lazy as _
from chrono.agendas.models import (
WEEKDAYS_LIST,
AbsenceReason,
Agenda,
AgendaNotificationsSettings,
AgendaReminderSettings,
Booking,
Desk,
Event,
MeetingType,
Resource,
TimePeriod,
TimePeriodException,
TimePeriodExceptionSource,
UnavailabilityCalendar,
VirtualMember,
generate_slug,
)
from . import widgets
from .widgets import SplitDateTimeField
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',
]
def __init__(self, *args, **kwargs):
super(AgendaEditForm, self).__init__(*args, **kwargs)
if kwargs['instance'].kind != 'events':
del self.fields['booking_form_url']
self.fields['default_view'].choices = [
(k, v) for k, v in self.fields['default_view'].choices if k != 'open_events'
]
class AgendaBookingDelaysForm(forms.ModelForm):
class Meta:
model = Agenda
fields = [
'minimal_booking_delay',
'maximal_booking_delay',
]
def __init__(self, *args, **kwargs):
super(AgendaBookingDelaysForm, self).__init__(*args, **kwargs)
if kwargs['instance'].kind != 'virtual':
self.fields['minimal_booking_delay'].required = True
self.fields['maximal_booking_delay'].required = True
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):
class Meta:
model = Event
fields = [
'label',
'start_datetime',
'repeat',
'duration',
'places',
]
field_classes = {
'start_datetime': SplitDateTimeField,
}
class EventForm(forms.ModelForm):
protected_fields = ('repeat', 'slug', 'start_datetime')
class Meta:
model = Event
widgets = {
'publication_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
'recurrence_end_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
}
fields = [
'label',
'slug',
'start_datetime',
'repeat',
'recurrence_end_date',
'duration',
'publication_date',
'places',
'waiting_list_places',
'description',
'pricing',
'url',
]
field_classes = {
'start_datetime': SplitDateTimeField,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.recurrence_rule 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.primary_event:
for field in ('slug', 'repeat', 'recurrence_end_date', 'publication_date'):
del self.fields[field]
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.cleaned_data.get('recurrence_end_date') and not self.cleaned_data.get('repeat'):
raise ValidationError(_('Recurrence end date makes no sense without repetition.'))
def save(self, *args, **kwargs):
with transaction.atomic():
if any(field for field in self.changed_data if field in self.protected_fields):
self.instance.recurrences.all().delete()
elif self.instance.recurrence_rule:
update_fields = {
field: value
for field, value in self.cleaned_data.items()
if field != 'recurrence_end_date' and field not in self.protected_fields
}
self.instance.recurrences.update(**update_fields)
super().save(*args, **kwargs)
if self.instance.recurrence_end_date:
self.instance.recurrences.filter(
start_datetime__gt=self.instance.recurrence_end_date
).delete()
excluded_datetimes = [event.datetime_slug for event in self.instance.recurrences.all()]
self.instance.create_all_recurrences(excluded_datetimes)
return self.instance
class BookingAbsenceReasonForm(forms.Form):
reason = forms.ChoiceField(required=False)
def __init__(self, *args, **kwargs):
agenda = kwargs.pop('agenda')
super().__init__(*args, **kwargs)
if agenda.absence_reasons_group:
self.fields['reason'].choices = [('', '---------')] + [
(r.label, r.label) for r in agenda.absence_reasons_group.absence_reasons.all()
]
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 TimePeriodAddForm(forms.Form):
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())
def clean_end_time(self):
if self.cleaned_data['end_time'] <= self.cleaned_data['start_time']:
raise ValidationError(_('End time must come after start time.'))
return self.cleaned_data['end_time']
class TimePeriodForm(forms.ModelForm):
class Meta:
model = TimePeriod
widgets = {
'start_time': widgets.TimeWidget(),
'end_time': widgets.TimeWidget(),
}
exclude = ['agenda', 'desk']
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
def clean_end_time(self):
if self.cleaned_data['end_time'] <= self.cleaned_data['start_time']:
raise ValidationError(_('End time must come after start time.'))
return self.cleaned_data['end_time']
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
).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.save()
return self.instance
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(VirtualMemberForm, self).__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(ImportEventsForm, self).__init__(**kwargs)
def clean_events_csv_file(self):
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)
except csv.Error:
dialect = None
events = []
warnings = {}
events_by_slug = {e.slug: e for e 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 ValidationError(_('Invalid file format. (line %d)') % (i + 1))
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_text(csvline[4])
# get or create event
event = None
slug = None
if len(csvline) >= 6:
slug = force_text(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 ValidationError(_('Invalid file format. (date/time format, line %d)') % (i + 1))
try:
event.places = int(csvline[2])
except ValueError:
raise ValidationError(_('Invalid file format. (number of places, line %d)') % (i + 1))
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, line %d)') % (i + 1)
)
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 date_fmt in ('%Y-%m-%d', '%d/%m/%Y'):
try:
event.publication_date = datetime.datetime.strptime(csvline[9], date_fmt).date()
break
except ValueError:
continue
else:
raise ValidationError(_('Invalid file format. (date format, line %d)') % (i + 1))
if len(csvline) >= 11 and csvline[10]: # duration is optional
try:
event.duration = int(csvline[10])
except ValueError:
raise ValidationError(_('Invalid file format. (duration, line %d)') % (i + 1))
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.URLField(
label=_('URL'),
required=False,
help_text=_('URL to remote calendar which will be synchronised hourly.'),
)
class Meta:
model = Desk
fields = []
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.'))
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.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 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['send_sms']
del self.fields['sms_extra_info']
def clean(self):
cleaned_data = super().clean()
if cleaned_data['days'] and not (cleaned_data['send_email'] or cleaned_data.get('send_sms')):
raise ValidationError(_('Select at least one notification medium.'))
return cleaned_data
class AgendasExportForm(forms.Form):
agendas = forms.BooleanField(label=_('Agendas'), required=False, initial=True)
unavailability_calendars = forms.BooleanField(
label=_('Unavailability calendars'), required=False, initial=True
)