De-avatarify most of room booking (except room owner)
[cds-indico.git] / indico / modules / rb / models / reservations.py
blob3ba9f9f939fc17d6c909c6794ecf55e0c6a227b1
1 # This file is part of Indico.
2 # Copyright (C) 2002 - 2015 European Organization for Nuclear Research (CERN).
4 # Indico is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License as
6 # published by the Free Software Foundation; either version 3 of the
7 # License, or (at your option) any later version.
9 # Indico is distributed in the hope that it will be useful, but
10 # WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12 # General Public License for more details.
14 # You should have received a copy of the GNU General Public License
15 # along with Indico; if not, see <http://www.gnu.org/licenses/>.
17 from collections import defaultdict, OrderedDict
18 from datetime import datetime, date
20 from sqlalchemy import Date, Time
21 from sqlalchemy.event import listens_for
22 from sqlalchemy.ext.declarative import declared_attr
23 from sqlalchemy.ext.hybrid import hybrid_property
24 from sqlalchemy.orm import joinedload
25 from sqlalchemy.sql import cast
26 from werkzeug.datastructures import OrderedMultiDict
28 from indico.core.db import db
29 from indico.core.db.sqlalchemy.custom import static_array, PyIntEnum
30 from indico.core.db.sqlalchemy.custom.utcdatetime import UTCDateTime
31 from indico.core.db.sqlalchemy.util.queries import limit_groups
32 from indico.core.errors import NoReportError
33 from indico.modules.rb.models.reservation_edit_logs import ReservationEditLog
34 from indico.modules.rb.models.reservation_occurrences import ReservationOccurrence
35 from indico.modules.rb.models.room_nonbookable_periods import NonBookablePeriod
36 from indico.modules.rb.models.equipment import (ReservationEquipmentAssociation, EquipmentType,
37 RoomEquipmentAssociation)
38 from indico.modules.rb.models.utils import unimplemented
39 from indico.modules.rb.notifications.reservations import (notify_confirmation, notify_cancellation,
40 notify_creation, notify_modification,
41 notify_rejection)
42 from indico.modules.rb.utils import rb_is_admin
43 from indico.util.date_time import now_utc, format_date, format_time, get_month_end, round_up_month
44 from indico.util.i18n import _, N_
45 from indico.util.serializer import Serializer
46 from indico.util.string import return_ascii
47 from indico.util.struct.enum import IndicoEnum
48 from indico.util.user import unify_user_args
49 from indico.web.flask.util import url_for
50 from MaKaC.common.Locators import Locator
53 class ConflictingOccurrences(Exception):
54 pass
57 class RepeatFrequency(int, IndicoEnum):
58 NEVER = 0
59 DAY = 1
60 WEEK = 2
61 MONTH = 3
64 class RepeatMapping(object):
65 mapping = {
66 (RepeatFrequency.NEVER, 0): (N_('Single reservation'), None, 'none'),
67 (RepeatFrequency.DAY, 1): (N_('Repeat daily'), 0, 'daily'),
68 (RepeatFrequency.WEEK, 1): (N_('Repeat once a week'), 1, 'weekly'),
69 (RepeatFrequency.WEEK, 2): (N_('Repeat once every two weeks'), 2, 'everyTwoWeeks'),
70 (RepeatFrequency.WEEK, 3): (N_('Repeat once every three weeks'), 3, 'everyThreeWeeks'),
71 (RepeatFrequency.MONTH, 1): (N_('Repeat every month'), 4, 'monthly')
74 @classmethod
75 @unimplemented(exceptions=(KeyError,), message=_('Unimplemented repetition pair'))
76 def get_message(cls, repeat_frequency, repeat_interval):
77 return cls.mapping[(repeat_frequency, repeat_interval)][0]
79 @classmethod
80 @unimplemented(exceptions=(KeyError,), message=_('Unimplemented repetition pair'))
81 def get_short_name(cls, repeat_frequency, repeat_interval):
82 # for the API
83 return cls.mapping[(repeat_frequency, repeat_interval)][2]
85 @classmethod
86 @unimplemented(exceptions=(KeyError,), message=_('Unknown old repeatability'))
87 def convert_legacy_repeatability(cls, repeat):
88 if repeat is None or repeat < 5:
89 for k, (_, v, _) in cls.mapping.iteritems():
90 if v == repeat:
91 return k
92 else:
93 raise KeyError('Undefined old repeat: {}'.format(repeat))
96 class Reservation(Serializer, db.Model):
97 __tablename__ = 'reservations'
98 __public__ = []
99 __calendar_public__ = [
100 'id', ('booked_for_name', 'bookedForName'), ('booking_reason', 'reason'), ('details_url', 'bookingUrl')
102 __api_public__ = [
103 'id', ('start_dt', 'startDT'), ('end_dt', 'endDT'), 'repeat_frequency', 'repeat_interval',
104 ('booked_for_name', 'bookedForName'), ('details_url', 'bookingUrl'), ('booking_reason', 'reason'),
105 ('uses_vc', 'usesAVC'), ('needs_vc_assistance', 'needsAVCSupport'),
106 'needs_assistance', ('is_accepted', 'isConfirmed'), ('is_valid', 'isValid'), 'is_cancelled',
107 'is_rejected', ('location_name', 'location'), 'booked_for_user_email'
110 @declared_attr
111 def __table_args__(cls):
112 return (db.Index('ix_reservations_start_dt_date', cast(cls.start_dt, Date)),
113 db.Index('ix_reservations_end_dt_date', cast(cls.end_dt, Date)),
114 db.Index('ix_reservations_start_dt_time', cast(cls.start_dt, Time)),
115 db.Index('ix_reservations_end_dt_time', cast(cls.end_dt, Time)),
116 {'schema': 'roombooking'})
118 id = db.Column(
119 db.Integer,
120 primary_key=True
122 created_dt = db.Column(
123 UTCDateTime,
124 nullable=False,
125 default=now_utc
127 start_dt = db.Column(
128 db.DateTime,
129 nullable=False,
130 index=True
132 end_dt = db.Column(
133 db.DateTime,
134 nullable=False,
135 index=True
137 repeat_frequency = db.Column(
138 PyIntEnum(RepeatFrequency),
139 nullable=False,
140 default=RepeatFrequency.NEVER
141 ) # week, month, year, etc.
142 repeat_interval = db.Column(
143 db.SmallInteger,
144 nullable=False,
145 default=0
146 ) # 1, 2, 3, etc.
147 booked_for_id = db.Column(
148 db.Integer,
149 db.ForeignKey('users.users.id'),
150 index=True,
151 nullable=True,
152 # Must be nullable for legacy data :(
154 booked_for_name = db.Column(
155 db.String,
156 nullable=False
158 created_by_id = db.Column(
159 db.Integer,
160 db.ForeignKey('users.users.id'),
161 index=True,
162 nullable=True,
163 # Must be nullable for legacy data :(
165 room_id = db.Column(
166 db.Integer,
167 db.ForeignKey('roombooking.rooms.id'),
168 nullable=False,
169 index=True
171 contact_email = db.Column(
172 db.String,
173 nullable=False,
174 default=''
176 contact_phone = db.Column(
177 db.String,
178 nullable=False,
179 default=''
181 is_accepted = db.Column(
182 db.Boolean,
183 nullable=False
185 is_cancelled = db.Column(
186 db.Boolean,
187 nullable=False,
188 default=False
190 is_rejected = db.Column(
191 db.Boolean,
192 nullable=False,
193 default=False
195 booking_reason = db.Column(
196 db.Text,
197 nullable=False
199 rejection_reason = db.Column(
200 db.String
202 uses_vc = db.Column(
203 db.Boolean,
204 nullable=False,
205 default=False
207 needs_vc_assistance = db.Column(
208 db.Boolean,
209 nullable=False,
210 default=False
212 needs_assistance = db.Column(
213 db.Boolean,
214 nullable=False,
215 default=False
217 event_id = db.Column(
218 db.Integer,
219 index=True
222 edit_logs = db.relationship(
223 'ReservationEditLog',
224 backref='reservation',
225 cascade='all, delete-orphan',
226 lazy='dynamic'
228 occurrences = db.relationship(
229 'ReservationOccurrence',
230 backref='reservation',
231 cascade='all, delete-orphan',
232 lazy='dynamic'
234 used_equipment = db.relationship(
235 'EquipmentType',
236 secondary=ReservationEquipmentAssociation,
237 backref='reservations',
238 lazy='dynamic'
240 #: The user this booking was made for.
241 #: Assigning a user here also updates `booked_for_name`.
242 booked_for_user = db.relationship(
243 'User',
244 lazy=False,
245 foreign_keys=[booked_for_id],
246 backref=db.backref(
247 'reservations_booked_for',
248 lazy='dynamic'
251 #: The user who created this booking.
252 created_by_user = db.relationship(
253 'User',
254 lazy=False,
255 foreign_keys=[created_by_id],
256 backref=db.backref(
257 'reservations',
258 lazy='dynamic'
262 @hybrid_property
263 def is_archived(self):
264 return self.end_dt < datetime.now()
266 @hybrid_property
267 def is_pending(self):
268 return not (self.is_accepted or self.is_rejected or self.is_cancelled)
270 @is_pending.expression
271 def is_pending(self):
272 return ~(Reservation.is_accepted | Reservation.is_rejected | Reservation.is_cancelled)
274 @hybrid_property
275 def is_repeating(self):
276 return self.repeat_frequency != RepeatFrequency.NEVER
278 @hybrid_property
279 def is_valid(self):
280 return self.is_accepted and not (self.is_rejected or self.is_cancelled)
282 @is_valid.expression
283 def is_valid(self):
284 return self.is_accepted & ~(self.is_rejected | self.is_cancelled)
286 @property
287 def booked_for_user_email(self):
288 return self.booked_for_user.email if self.booked_for_user else None
290 @property
291 def contact_emails(self):
292 return set(filter(None, map(unicode.strip, self.contact_email.split(u','))))
294 @property
295 def details_url(self):
296 return url_for('rooms.roomBooking-bookingDetails', self, _external=True)
298 @property
299 def event(self):
300 from MaKaC.conference import ConferenceHolder
301 return ConferenceHolder().getById(str(self.event_id))
303 @event.setter
304 def event(self, event):
305 self.event_id = int(event.getId()) if event else None
307 @property
308 def location_name(self):
309 return self.room.location_name
311 @property
312 def repetition(self):
313 return self.repeat_frequency, self.repeat_interval
315 @property
316 def status_string(self):
317 parts = []
318 if self.is_valid:
319 parts.append(_(u"Valid"))
320 else:
321 if self.is_cancelled:
322 parts.append(_(u"Cancelled"))
323 if self.is_rejected:
324 parts.append(_(u"Rejected"))
325 if not self.is_accepted:
326 parts.append(_(u"Not confirmed"))
327 if self.is_archived:
328 parts.append(_(u"Archived"))
329 else:
330 parts.append(_(u"Live"))
331 return u', '.join(map(unicode, parts))
333 @return_ascii
334 def __repr__(self):
335 return u'<Reservation({0}, {1}, {2}, {3}, {4})>'.format(
336 self.id,
337 self.room_id,
338 self.booked_for_name,
339 self.start_dt,
340 self.end_dt
343 @classmethod
344 def create_from_data(cls, room, data, user, prebook=None):
345 """Creates a new reservation.
347 :param room: The Room that's being booked.
348 :param data: A dict containing the booking data, usually from a :class:`NewBookingConfirmForm` instance
349 :param user: The :class:`.User` who creates the booking.
350 :param prebook: Instead of determining the booking type from the user's
351 permissions, always use the given mode.
354 populate_fields = ('start_dt', 'end_dt', 'repeat_frequency', 'repeat_interval', 'room_id', 'booked_for_user',
355 'contact_email', 'contact_phone', 'booking_reason', 'used_equipment',
356 'needs_assistance', 'uses_vc', 'needs_vc_assistance')
358 if data['repeat_frequency'] == RepeatFrequency.NEVER and data['start_dt'].date() != data['end_dt'].date():
359 raise ValueError('end_dt != start_dt for non-repeating booking')
361 if prebook is None:
362 prebook = not room.can_be_booked(user)
363 if prebook and not room.can_be_prebooked(user):
364 raise NoReportError('You cannot book this room')
366 room.check_advance_days(data['end_dt'].date(), user)
367 room.check_bookable_hours(data['start_dt'].time(), data['end_dt'].time(), user)
369 reservation = cls()
370 for field in populate_fields:
371 if field in data:
372 setattr(reservation, field, data[field])
373 reservation.room = room
374 reservation.booked_for_name = reservation.booked_for_user.full_name
375 reservation.is_accepted = not prebook
376 reservation.created_by_user = user
377 reservation.create_occurrences(True)
378 if not any(occ.is_valid for occ in reservation.occurrences):
379 raise NoReportError(_('Reservation has no valid occurrences'))
380 notify_creation(reservation)
381 return reservation
383 @staticmethod
384 def get_with_data(*args, **kwargs):
385 filters = kwargs.pop('filters', None)
386 limit = kwargs.pop('limit', None)
387 offset = kwargs.pop('offset', 0)
388 order = kwargs.pop('order', Reservation.start_dt)
389 limit_per_room = kwargs.pop('limit_per_room', False)
390 occurs_on = kwargs.pop('occurs_on')
391 if kwargs:
392 raise ValueError('Unexpected kwargs: {}'.format(kwargs))
394 query = Reservation.query.options(joinedload(Reservation.room))
395 if filters:
396 query = query.filter(*filters)
397 if occurs_on:
398 query = query.filter(
399 Reservation.id.in_(db.session.query(ReservationOccurrence.reservation_id)
400 .filter(ReservationOccurrence.date.in_(occurs_on),
401 ReservationOccurrence.is_valid))
403 if limit_per_room and (limit or offset):
404 query = limit_groups(query, Reservation, Reservation.room_id, order, limit, offset)
406 query = query.order_by(order, Reservation.created_dt)
408 if not limit_per_room:
409 if limit:
410 query = query.limit(limit)
411 if offset:
412 query = query.offset(offset)
414 result = OrderedDict((r.id, {'reservation': r}) for r in query)
416 if 'vc_equipment' in args:
417 vc_id_subquery = db.session.query(EquipmentType.id) \
418 .correlate(Reservation) \
419 .filter_by(name='Video conference') \
420 .join(RoomEquipmentAssociation) \
421 .filter(RoomEquipmentAssociation.c.room_id == Reservation.room_id) \
422 .as_scalar()
424 # noinspection PyTypeChecker
425 vc_equipment_data = dict(db.session.query(Reservation.id, static_array.array_agg(EquipmentType.name))
426 .join(ReservationEquipmentAssociation, EquipmentType)
427 .filter(Reservation.id.in_(result.iterkeys()))
428 .filter(EquipmentType.parent_id == vc_id_subquery)
429 .group_by(Reservation.id))
431 for id_, data in result.iteritems():
432 data['vc_equipment'] = vc_equipment_data.get(id_, ())
434 if 'occurrences' in args:
435 occurrence_data = OrderedMultiDict(db.session.query(ReservationOccurrence.reservation_id,
436 ReservationOccurrence)
437 .filter(ReservationOccurrence.reservation_id.in_(result.iterkeys()))
438 .order_by(ReservationOccurrence.start_dt))
439 for id_, data in result.iteritems():
440 data['occurrences'] = occurrence_data.getlist(id_)
442 return result.values()
444 @staticmethod
445 def find_overlapping_with(room, occurrences, skip_reservation_id=None):
446 return Reservation.find(Reservation.room == room,
447 Reservation.id != skip_reservation_id,
448 ReservationOccurrence.is_valid,
449 ReservationOccurrence.filter_overlap(occurrences),
450 _join=ReservationOccurrence)
452 @unify_user_args
453 def accept(self, user):
454 self.is_accepted = True
455 self.add_edit_log(ReservationEditLog(user_name=user.full_name, info=['Reservation accepted']))
456 notify_confirmation(self)
458 valid_occurrences = self.occurrences.filter(ReservationOccurrence.is_valid).all()
459 pre_occurrences = ReservationOccurrence.find_overlapping_with(self.room, valid_occurrences, self.id).all()
460 for occurrence in pre_occurrences:
461 if not occurrence.is_valid:
462 continue
463 occurrence.reject(user, u'Rejected due to collision with a confirmed reservation')
465 @unify_user_args
466 def cancel(self, user, reason=None, silent=False):
467 self.is_cancelled = True
468 self.rejection_reason = reason
469 self.occurrences.filter_by(is_valid=True).update({'is_cancelled': True, 'rejection_reason': reason},
470 synchronize_session='fetch')
471 if not silent:
472 notify_cancellation(self)
473 log_msg = u'Reservation cancelled: {}'.format(reason) if reason else 'Reservation cancelled'
474 self.add_edit_log(ReservationEditLog(user_name=user.full_name, info=[log_msg]))
476 @unify_user_args
477 def reject(self, user, reason, silent=False):
478 self.is_rejected = True
479 self.rejection_reason = reason
480 self.occurrences.filter_by(is_valid=True).update({'is_rejected': True, 'rejection_reason': reason},
481 synchronize_session='fetch')
482 if not silent:
483 notify_rejection(self)
484 log_msg = u'Reservation rejected: {}'.format(reason)
485 self.add_edit_log(ReservationEditLog(user_name=user.full_name, info=[log_msg]))
487 def add_edit_log(self, edit_log):
488 self.edit_logs.append(edit_log)
489 db.session.flush()
491 @unify_user_args
492 def can_be_accepted(self, user):
493 if user is None:
494 return False
495 return rb_is_admin(user) or self.room.is_owned_by(user)
497 @unify_user_args
498 def can_be_cancelled(self, user):
499 if user is None:
500 return False
501 return self.is_owned_by(user) or rb_is_admin(user) or self.is_booked_for(user)
503 @unify_user_args
504 def can_be_deleted(self, user):
505 if user is None:
506 return False
507 return rb_is_admin(user)
509 @unify_user_args
510 def can_be_modified(self, user):
511 if user is None:
512 return False
513 if self.is_rejected or self.is_cancelled:
514 return False
515 if rb_is_admin(user):
516 return True
517 return self.created_by_user == user or self.is_booked_for(user) or self.room.is_owned_by(user)
519 @unify_user_args
520 def can_be_rejected(self, user):
521 if user is None:
522 return False
523 return rb_is_admin(user) or self.room.is_owned_by(user)
525 def create_occurrences(self, skip_conflicts, user=None):
526 ReservationOccurrence.create_series_for_reservation(self)
527 db.session.flush()
529 if user is None:
530 user = self.created_by_user
532 # Check for conflicts with nonbookable periods
533 if not rb_is_admin(user) and not self.room.is_owned_by(user):
534 nonbookable_periods = self.room.nonbookable_periods.filter(NonBookablePeriod.end_dt > self.start_dt)
535 for occurrence in self.occurrences:
536 if not occurrence.is_valid:
537 continue
538 for nbd in nonbookable_periods:
539 if nbd.overlaps(occurrence.start_dt, occurrence.end_dt):
540 if not skip_conflicts:
541 raise ConflictingOccurrences()
542 occurrence.cancel(user, u'Skipped due to nonbookable date', silent=True, propagate=False)
543 break
545 # Check for conflicts with blockings
546 blocked_rooms = self.room.get_blocked_rooms(*(occurrence.start_dt for occurrence in self.occurrences))
547 for br in blocked_rooms:
548 blocking = br.blocking
549 if blocking.can_be_overridden(user, self.room):
550 continue
551 for occurrence in self.occurrences:
552 if occurrence.is_valid and blocking.is_active_at(occurrence.start_dt.date()):
553 # Cancel OUR occurrence
554 msg = u'Skipped due to collision with a blocking ({})'
555 occurrence.cancel(user, msg.format(blocking.reason), silent=True, propagate=False)
557 # Check for conflicts with other occurrences
558 conflicting_occurrences = self.get_conflicting_occurrences()
559 for occurrence, conflicts in conflicting_occurrences.iteritems():
560 if not occurrence.is_valid:
561 continue
562 if conflicts['confirmed']:
563 if not skip_conflicts:
564 raise ConflictingOccurrences()
565 # Cancel OUR occurrence
566 msg = u'Skipped due to collision with {} reservation(s)'
567 occurrence.cancel(user, msg.format(len(conflicts['confirmed'])), silent=True, propagate=False)
568 elif conflicts['pending'] and self.is_accepted:
569 # Reject OTHER occurrences
570 for conflict in conflicts['pending']:
571 conflict.reject(user, u'Rejected due to collision with a confirmed reservation')
573 # Mark occurrences created within the notification window as notified
574 for occurrence in self.occurrences:
575 if occurrence.is_valid and occurrence.is_in_notification_window():
576 occurrence.notification_sent = True
578 # Mark occurrences created within the digest window as notified
579 if self.repeat_frequency == RepeatFrequency.WEEK:
580 if self.room.is_in_digest_window():
581 digest_start = round_up_month(date.today())
582 else:
583 digest_start = date.today()
584 digest_end = get_month_end(digest_start)
585 self.occurrences.filter(ReservationOccurrence.start_dt <= digest_end).update({'notification_sent': True})
587 def find_excluded_days(self):
588 return self.occurrences.filter(~ReservationOccurrence.is_valid)
590 def find_overlapping(self):
591 occurrences = self.occurrences.filter(ReservationOccurrence.is_valid).all()
592 return Reservation.find_overlapping_with(self.room, occurrences, self.id)
594 def getLocator(self):
595 locator = Locator()
596 locator['roomLocation'] = self.location_name
597 locator['resvID'] = self.id
598 return locator
600 def get_conflicting_occurrences(self):
601 valid_occurrences = self.occurrences.filter(ReservationOccurrence.is_valid).all()
602 colliding_occurrences = ReservationOccurrence.find_overlapping_with(self.room, valid_occurrences, self.id).all()
603 conflicts = defaultdict(lambda: dict(confirmed=[], pending=[]))
604 for occurrence in valid_occurrences:
605 for colliding in colliding_occurrences:
606 if occurrence.overlaps(colliding):
607 key = 'confirmed' if colliding.reservation.is_accepted else 'pending'
608 conflicts[occurrence][key].append(colliding)
609 return conflicts
611 def get_vc_equipment(self):
612 vc_equipment = self.room.available_equipment \
613 .correlate(ReservationOccurrence) \
614 .with_entities(EquipmentType.id) \
615 .filter_by(name='Video conference') \
616 .as_scalar()
617 return self.used_equipment.filter(EquipmentType.parent_id == vc_equipment)
619 def is_booked_for(self, user):
620 if user is None:
621 return False
622 return self.booked_for_user == user or bool(self.contact_emails & set(user.all_emails))
624 @unify_user_args
625 def is_owned_by(self, user):
626 return self.created_by_user == user
628 def modify(self, data, user):
629 """Modifies an existing reservation.
631 :param data: A dict containing the booking data, usually from a :class:`ModifyBookingForm` instance
632 :param user: The :class:`.User` who modifies the booking.
635 populate_fields = ('start_dt', 'end_dt', 'repeat_frequency', 'repeat_interval', 'booked_for_user',
636 'contact_email', 'contact_phone', 'booking_reason', 'used_equipment',
637 'needs_assistance', 'uses_vc', 'needs_vc_assistance')
638 # fields affecting occurrences
639 occurrence_fields = {'start_dt', 'end_dt', 'repeat_frequency', 'repeat_interval'}
640 # fields where date and time are compared separately
641 date_time_fields = {'start_dt', 'end_dt'}
642 # fields for the repetition
643 repetition_fields = {'repeat_frequency', 'repeat_interval'}
644 # pretty names for logging
645 field_names = {
646 'start_dt/date': "start date",
647 'end_dt/date': "end date",
648 'start_dt/time': "start time",
649 'end_dt/time': "end time",
650 'repetition': "booking type",
651 'booked_for_user': "'Booked for' user",
652 'contact_email': "contact email",
653 'contact_phone': "contact phone number",
654 'booking_reason': "booking reason",
655 'used_equipment': "list of equipment",
656 'needs_assistance': "option 'General Assistance'",
657 'uses_vc': "option 'Uses Videoconference'",
658 'needs_vc_assistance': "option 'Videoconference Setup Assistance'"
661 self.room.check_advance_days(data['end_dt'].date(), user)
662 self.room.check_bookable_hours(data['start_dt'].time(), data['end_dt'].time(), user)
664 changes = {}
665 update_occurrences = False
666 old_repetition = self.repetition
668 for field in populate_fields:
669 if field not in data:
670 continue
671 old = getattr(self, field)
672 new = data[field]
673 converter = unicode
674 if field == 'used_equipment':
675 # Dynamic relationship
676 old = sorted(old.all())
677 converter = lambda x: u', '.join(x.name for x in x)
678 if old != new:
679 # Booked for user updates the (redundant) name
680 if field == 'booked_for_user':
681 old = self.booked_for_name
682 new = self.booked_for_name = data[field].full_name
683 # Apply the change
684 setattr(self, field, data[field])
685 # If any occurrence-related field changed we need to recreate the occurrences
686 if field in occurrence_fields:
687 update_occurrences = True
688 # Record change for history entry
689 if field in date_time_fields:
690 # The date/time fields create separate entries for the date and time parts
691 if old.date() != new.date():
692 changes[field + '/date'] = {'old': old.date(), 'new': new.date(), 'converter': format_date}
693 if old.time() != new.time():
694 changes[field + '/time'] = {'old': old.time(), 'new': new.time(), 'converter': format_time}
695 elif field in repetition_fields:
696 # Repetition needs special handling since it consists of two fields but they are tied together
697 # We simply update it whenever we encounter such a change; after the last change we end up with
698 # the correct change data
699 changes['repetition'] = {'old': old_repetition,
700 'new': self.repetition,
701 'converter': lambda x: RepeatMapping.get_message(*x)}
702 else:
703 changes[field] = {'old': old, 'new': new, 'converter': converter}
705 if not changes:
706 return False
708 # Create a verbose log entry for the modification
709 log = [u'Booking modified']
710 for field, change in changes.iteritems():
711 field_title = field_names.get(field, field)
712 converter = change['converter']
713 old = converter(change['old'])
714 new = converter(change['new'])
715 if not old:
716 log.append(u"The {} was set to '{}'".format(field_title, new))
717 elif not new:
718 log.append(u"The {} was cleared".format(field_title))
719 else:
720 log.append(u"The {} was changed from '{}' to '{}'".format(field_title, old, new))
722 self.edit_logs.append(ReservationEditLog(user_name=user.full_name, info=log))
724 # Recreate all occurrences if necessary
725 if update_occurrences:
726 cols = [col.name for col in ReservationOccurrence.__table__.columns
727 if not col.primary_key and col.name not in {'start_dt', 'end_dt'}]
729 old_occurrences = {occ.date: occ for occ in self.occurrences}
730 self.occurrences.delete(synchronize_session='fetch')
731 self.create_occurrences(True, user)
732 db.session.flush()
733 # Restore rejection data etc. for recreated occurrences
734 for occurrence in self.occurrences:
735 old_occurrence = old_occurrences.get(occurrence.date)
736 # Copy data from old occurrence UNLESS the new one is invalid (e.g. because of collisions)
737 # Otherwise we'd end up with valid occurrences ignoring collisions!
738 if old_occurrence and occurrence.is_valid:
739 for col in cols:
740 setattr(occurrence, col, getattr(old_occurrence, col))
741 # Don't cause new notifications for the entire booking in case of daily repetition
742 if self.repeat_frequency == RepeatFrequency.DAY and all(occ.notification_sent
743 for occ in old_occurrences.itervalues()):
744 for occurrence in self.occurrences:
745 occurrence.notification_sent = True
747 # Sanity check so we don't end up with an "empty" booking
748 if not any(occ.is_valid for occ in self.occurrences):
749 raise NoReportError(_('Reservation has no valid occurrences'))
751 notify_modification(self, changes)
752 return True
755 @listens_for(Reservation.booked_for_user, 'set')
756 def _booked_for_user_set(target, user, *unused):
757 target.booked_for_name = user.full_name if user else ''