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
,
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):
57 class RepeatFrequency(int, IndicoEnum
):
64 class RepeatMapping(object):
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')
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]
80 @unimplemented(exceptions
=(KeyError,), message
=_('Unimplemented repetition pair'))
81 def get_short_name(cls
, repeat_frequency
, repeat_interval
):
83 return cls
.mapping
[(repeat_frequency
, repeat_interval
)][2]
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():
93 raise KeyError('Undefined old repeat: {}'.format(repeat
))
96 class Reservation(Serializer
, db
.Model
):
97 __tablename__
= 'reservations'
99 __calendar_public__
= [
100 'id', ('booked_for_name', 'bookedForName'), ('booking_reason', 'reason'), ('details_url', 'bookingUrl')
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'
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'})
122 created_dt
= db
.Column(
127 start_dt
= db
.Column(
137 repeat_frequency
= db
.Column(
138 PyIntEnum(RepeatFrequency
),
140 default
=RepeatFrequency
.NEVER
141 ) # week, month, year, etc.
142 repeat_interval
= db
.Column(
147 booked_for_id
= db
.Column(
149 db
.ForeignKey('users.users.id'),
152 # Must be nullable for legacy data :(
154 booked_for_name
= db
.Column(
158 created_by_id
= db
.Column(
160 db
.ForeignKey('users.users.id'),
163 # Must be nullable for legacy data :(
167 db
.ForeignKey('roombooking.rooms.id'),
171 contact_email
= db
.Column(
176 contact_phone
= db
.Column(
181 is_accepted
= db
.Column(
185 is_cancelled
= db
.Column(
190 is_rejected
= db
.Column(
195 booking_reason
= db
.Column(
199 rejection_reason
= db
.Column(
207 needs_vc_assistance
= db
.Column(
212 needs_assistance
= db
.Column(
217 event_id
= db
.Column(
222 edit_logs
= db
.relationship(
223 'ReservationEditLog',
224 backref
='reservation',
225 cascade
='all, delete-orphan',
228 occurrences
= db
.relationship(
229 'ReservationOccurrence',
230 backref
='reservation',
231 cascade
='all, delete-orphan',
234 used_equipment
= db
.relationship(
236 secondary
=ReservationEquipmentAssociation
,
237 backref
='reservations',
240 #: The user this booking was made for.
241 #: Assigning a user here also updates `booked_for_name`.
242 booked_for_user
= db
.relationship(
245 foreign_keys
=[booked_for_id
],
247 'reservations_booked_for',
251 #: The user who created this booking.
252 created_by_user
= db
.relationship(
255 foreign_keys
=[created_by_id
],
263 def is_archived(self
):
264 return self
.end_dt
< datetime
.now()
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
)
275 def is_repeating(self
):
276 return self
.repeat_frequency
!= RepeatFrequency
.NEVER
280 return self
.is_accepted
and not (self
.is_rejected
or self
.is_cancelled
)
284 return self
.is_accepted
& ~
(self
.is_rejected | self
.is_cancelled
)
287 def booked_for_user_email(self
):
288 return self
.booked_for_user
.email
if self
.booked_for_user
else None
291 def contact_emails(self
):
292 return set(filter(None, map(unicode.strip
, self
.contact_email
.split(u
','))))
295 def details_url(self
):
296 return url_for('rooms.roomBooking-bookingDetails', self
, _external
=True)
300 from MaKaC
.conference
import ConferenceHolder
301 return ConferenceHolder().getById(str(self
.event_id
))
304 def event(self
, event
):
305 self
.event_id
= int(event
.getId()) if event
else None
308 def location_name(self
):
309 return self
.room
.location_name
312 def repetition(self
):
313 return self
.repeat_frequency
, self
.repeat_interval
316 def status_string(self
):
319 parts
.append(_(u
"Valid"))
321 if self
.is_cancelled
:
322 parts
.append(_(u
"Cancelled"))
324 parts
.append(_(u
"Rejected"))
325 if not self
.is_accepted
:
326 parts
.append(_(u
"Not confirmed"))
328 parts
.append(_(u
"Archived"))
330 parts
.append(_(u
"Live"))
331 return u
', '.join(map(unicode, parts
))
335 return u
'<Reservation({0}, {1}, {2}, {3}, {4})>'.format(
338 self
.booked_for_name
,
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')
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
)
370 for field
in populate_fields
:
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
)
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')
392 raise ValueError('Unexpected kwargs: {}'.format(kwargs
))
394 query
= Reservation
.query
.options(joinedload(Reservation
.room
))
396 query
= query
.filter(*filters
)
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
:
410 query
= query
.limit(limit
)
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
) \
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()
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
)
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
:
463 occurrence
.reject(user
, u
'Rejected due to collision with a confirmed reservation')
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')
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
]))
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')
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
)
492 def can_be_accepted(self
, user
):
495 return rb_is_admin(user
) or self
.room
.is_owned_by(user
)
498 def can_be_cancelled(self
, user
):
501 return self
.is_owned_by(user
) or rb_is_admin(user
) or self
.is_booked_for(user
)
504 def can_be_deleted(self
, user
):
507 return rb_is_admin(user
)
510 def can_be_modified(self
, user
):
513 if self
.is_rejected
or self
.is_cancelled
:
515 if rb_is_admin(user
):
517 return self
.created_by_user
== user
or self
.is_booked_for(user
) or self
.room
.is_owned_by(user
)
520 def can_be_rejected(self
, user
):
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
)
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
:
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)
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
):
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
:
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())
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
):
596 locator
['roomLocation'] = self
.location_name
597 locator
['resvID'] = self
.id
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
)
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') \
617 return self
.used_equipment
.filter(EquipmentType
.parent_id
== vc_equipment
)
619 def is_booked_for(self
, user
):
622 return self
.booked_for_user
== user
or bool(self
.contact_emails
& set(user
.all_emails
))
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
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
)
665 update_occurrences
= False
666 old_repetition
= self
.repetition
668 for field
in populate_fields
:
669 if field
not in data
:
671 old
= getattr(self
, field
)
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
)
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
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
)}
703 changes
[field
] = {'old': old
, 'new': new
, 'converter': converter
}
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'])
716 log
.append(u
"The {} was set to '{}'".format(field_title
, new
))
718 log
.append(u
"The {} was cleared".format(field_title
))
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
)
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
:
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
)
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 ''