class Event(MailSyncBase, HasRevisions, HasPublicID, UpdatedAtMixin, DeletedAtMixin): """Data for events.""" API_OBJECT_NAME = 'event' API_MODIFIABLE_FIELDS = [ 'title', 'description', 'location', 'when', 'participants', 'busy' ] namespace_id = Column(ForeignKey(Namespace.id, ondelete='CASCADE'), nullable=False) namespace = relationship(Namespace, load_on_pending=True) calendar_id = Column(ForeignKey(Calendar.id, ondelete='CASCADE'), nullable=False) # Note that we configure a delete cascade, rather than # passive_deletes=True, in order to ensure that delete revisions are # created for events if their parent calendar is deleted. calendar = relationship(Calendar, backref=backref('events', cascade='delete'), load_on_pending=True) # A server-provided unique ID. uid = Column(String(767, collation='ascii_general_ci'), nullable=False) # DEPRECATED # TODO(emfree): remove provider_name = Column(String(64), nullable=False, default='DEPRECATED') source = Column('source', Enum('local', 'remote'), default='local') raw_data = Column(Text, nullable=False) title = Column(String(TITLE_MAX_LEN), nullable=True) # The database column is named differently for legacy reasons. owner = Column('owner2', String(OWNER_MAX_LEN), nullable=True) description = Column('_description', LONGTEXT, nullable=True) location = Column(String(LOCATION_MAX_LEN), nullable=True) busy = Column(Boolean, nullable=False, default=True) read_only = Column(Boolean, nullable=False) reminders = Column(String(REMINDER_MAX_LEN), nullable=True) recurrence = Column(Text, nullable=True) start = Column(FlexibleDateTime, nullable=False) end = Column(FlexibleDateTime, nullable=True) all_day = Column(Boolean, nullable=False) is_owner = Column(Boolean, nullable=False, default=True) last_modified = Column(FlexibleDateTime, nullable=True) status = Column('status', Enum(*EVENT_STATUSES), server_default='confirmed') # This column is only used for events that are synced from iCalendar # files. message_id = Column(ForeignKey(Message.id, ondelete='CASCADE'), nullable=True) message = relationship(Message, backref=backref('events', order_by='Event.last_modified', cascade='all, delete-orphan')) __table_args__ = (Index('ix_event_ns_uid_calendar_id', 'namespace_id', 'uid', 'calendar_id'), ) participants = Column(MutableList.as_mutable(BigJSON), default=[], nullable=True) # This is only used by the iCalendar invite code. The sequence number # stores the version number of the invite. sequence_number = Column(Integer, nullable=True) discriminator = Column('type', String(30)) __mapper_args__ = { 'polymorphic_on': discriminator, 'polymorphic_identity': 'event' } @validates('reminders', 'recurrence', 'owner', 'location', 'title', 'raw_data') def validate_length(self, key, value): max_len = _LENGTHS[key] return value if value is None else value[:max_len] @property def when(self): if self.all_day: # Dates are stored as DateTimes so transform to dates here. start = arrow.get(self.start).to('utc').date() end = arrow.get(self.end).to('utc').date() return Date(start) if start == end else DateSpan(start, end) else: start = self.start end = self.end return Time(start) if start == end else TimeSpan(start, end) @when.setter def when(self, when): if 'time' in when: self.start = self.end = time_parse(when['time']) self.all_day = False elif 'start_time' in when: self.start = time_parse(when['start_time']) self.end = time_parse(when['end_time']) self.all_day = False elif 'date' in when: self.start = self.end = date_parse(when['date']) self.all_day = True elif 'start_date' in when: self.start = date_parse(when['start_date']) self.end = date_parse(when['end_date']) self.all_day = True def _merge_participant_attributes(self, left, right): """Merge right into left. Right takes precedence unless it's null.""" for attribute in right.keys(): # Special cases: if right[attribute] is None: continue elif right[attribute] == '': continue elif right['status'] == 'noreply': continue else: left[attribute] = right[attribute] return left def _partial_participants_merge(self, event): """Merge the participants from event into self.participants. event always takes precedence over self, except if a participant in self isn't in event. This method is only called by the ical merging code because iCalendar attendance updates are partial: an RSVP reply often only contains the status of the person that RSVPs. It would be very wrong to call this method to merge, say, Google Events participants because they handle the merging themselves. """ # We have to jump through some hoops because a participant may # not have an email or may not have a name, so we build a hash # where we can find both. Also note that we store names in the # hash only if the email is None. self_hash = {} for participant in self.participants: email = participant.get('email') name = participant.get('name') if email is not None: self_hash[email] = participant elif name is not None: # We have a name without an email. self_hash[name] = participant for participant in event.participants: email = participant.get('email') name = participant.get('name') # This is the tricky part --- we only want to store one entry per # participant --- we check if there's an email we already know, if # not we create it. Otherwise we use the name. This sorta works # because we're merging updates to an event and ical updates # always have an email address. # - karim if email is not None: if email in self_hash: self_hash[email] =\ self._merge_participant_attributes(self_hash[email], participant) else: self_hash[email] = participant elif name is not None: if name in self_hash: self_hash[name] =\ self._merge_participant_attributes(self_hash[name], participant) else: self_hash[name] = participant return self_hash.values() def update(self, event): if event.namespace is not None and event.namespace.id is not None: self.namespace_id = event.namespace.id if event.calendar is not None and event.calendar.id is not None: self.calendar_id = event.calendar.id if event.provider_name is not None: self.provider_name = event.provider_name self.uid = event.uid self.raw_data = event.raw_data self.title = event.title self.description = event.description self.location = event.location self.start = event.start self.end = event.end self.all_day = event.all_day self.owner = event.owner self.is_owner = event.is_owner self.read_only = event.read_only self.participants = event.participants self.busy = event.busy self.reminders = event.reminders self.recurrence = event.recurrence self.last_modified = event.last_modified self.message = event.message self.status = event.status if event.sequence_number is not None: self.sequence_number = event.sequence_number @property def recurring(self): if self.recurrence and self.recurrence != '': try: r = ast.literal_eval(self.recurrence) if isinstance(r, str): r = [r] return r except (ValueError, SyntaxError): log.warn('Invalid RRULE entry for event', event_id=self.id, raw_rrule=self.recurrence) return [] return [] @property def organizer_email(self): # For historical reasons, the event organizer field is stored as # "Owner Name <*****@*****.**>". parsed_owner = parseaddr(self.owner) if len(parsed_owner) == 0: return None if parsed_owner[1] == '': return None return parsed_owner[1] @property def organizer_name(self): parsed_owner = parseaddr(self.owner) if len(parsed_owner) == 0: return None if parsed_owner[0] == '': return None return parsed_owner[0] @property def is_recurring(self): return self.recurrence is not None @property def length(self): return self.when.delta @property def cancelled(self): return self.status == 'cancelled' @cancelled.setter def cancelled(self, is_cancelled): if is_cancelled: self.status = 'cancelled' else: self.status = 'confirmed' @classmethod def __new__(cls, *args, **kwargs): # Decide whether or not to instantiate a RecurringEvent/Override # based on the kwargs we get. cls_ = cls recurrence = kwargs.get('recurrence') master_event_uid = kwargs.get('master_event_uid') if recurrence and master_event_uid: raise ValueError("Event can't have both recurrence and master UID") if recurrence and recurrence != '': cls_ = RecurringEvent if master_event_uid: cls_ = RecurringEventOverride return object.__new__(cls_, *args, **kwargs) def __init__(self, **kwargs): # Allow arguments for all subclasses to be passed to main constructor for k in kwargs.keys(): if not hasattr(type(self), k): del kwargs[k] super(Event, self).__init__(**kwargs)
class Event(MailSyncBase, HasRevisions, HasPublicID, UpdatedAtMixin, DeletedAtMixin): """Data for events.""" API_OBJECT_NAME = "event" API_MODIFIABLE_FIELDS = [ "title", "description", "location", "when", "participants", "busy", ] namespace_id = Column(ForeignKey(Namespace.id, ondelete="CASCADE"), nullable=False) namespace = relationship(Namespace, load_on_pending=True) calendar_id = Column(ForeignKey(Calendar.id, ondelete="CASCADE"), nullable=False) # Note that we configure a delete cascade, rather than # passive_deletes=True, in order to ensure that delete revisions are # created for events if their parent calendar is deleted. calendar = relationship(Calendar, backref=backref("events", cascade="delete"), load_on_pending=True) # A server-provided unique ID. uid = Column(String(UID_MAX_LEN, collation="ascii_general_ci"), nullable=False) # DEPRECATED # TODO(emfree): remove provider_name = Column(String(64), nullable=False, default="DEPRECATED") source = Column("source", Enum("local", "remote"), default="local") raw_data = Column(Text, nullable=False) title = Column(String(TITLE_MAX_LEN), nullable=True) # The database column is named differently for legacy reasons. owner = Column("owner2", String(OWNER_MAX_LEN), nullable=True) description = Column("_description", LONGTEXT, nullable=True) location = Column(String(LOCATION_MAX_LEN), nullable=True) busy = Column(Boolean, nullable=False, default=True) read_only = Column(Boolean, nullable=False) reminders = Column(String(REMINDER_MAX_LEN), nullable=True) recurrence = Column(Text, nullable=True) start = Column(FlexibleDateTime, nullable=False) end = Column(FlexibleDateTime, nullable=True) all_day = Column(Boolean, nullable=False) is_owner = Column(Boolean, nullable=False, default=True) last_modified = Column(FlexibleDateTime, nullable=True) status = Column("status", Enum(*EVENT_STATUSES), server_default="confirmed") # This column is only used for events that are synced from iCalendar # files. message_id = Column(ForeignKey(Message.id, ondelete="CASCADE"), nullable=True) message = relationship( Message, backref=backref("events", order_by="Event.last_modified", cascade="all, delete-orphan"), ) __table_args__ = (Index("ix_event_ns_uid_calendar_id", "namespace_id", "uid", "calendar_id"), ) participants = Column(MutableList.as_mutable(BigJSON), default=[], nullable=True) # This is only used by the iCalendar invite code. The sequence number # stores the version number of the invite. sequence_number = Column(Integer, nullable=True) visibility = Column(Enum("private", "public"), nullable=True) discriminator = Column("type", String(30)) __mapper_args__ = { "polymorphic_on": discriminator, "polymorphic_identity": "event" } @validates("reminders", "recurrence", "owner", "location", "title", "uid", "raw_data") def validate_length(self, key, value): if value is None: return None return unicode_safe_truncate(value, MAX_LENS[key]) @property def when(self): if self.all_day: # Dates are stored as DateTimes so transform to dates here. start = arrow.get(self.start).to("utc").date() end = arrow.get(self.end).to("utc").date() return Date(start) if start == end else DateSpan(start, end) else: start = self.start end = self.end return Time(start) if start == end else TimeSpan(start, end) @when.setter def when(self, when): if "time" in when: self.start = self.end = time_parse(when["time"]) self.all_day = False elif "start_time" in when: self.start = time_parse(when["start_time"]) self.end = time_parse(when["end_time"]) self.all_day = False elif "date" in when: self.start = self.end = date_parse(when["date"]) self.all_day = True elif "start_date" in when: self.start = date_parse(when["start_date"]) self.end = date_parse(when["end_date"]) self.all_day = True def _merge_participant_attributes(self, left, right): """Merge right into left. Right takes precedence unless it's null.""" for attribute in right.keys(): # Special cases: if (right[attribute] is None or right[attribute] == "" or right["status"] == "noreply"): continue left[attribute] = right[attribute] return left def _partial_participants_merge(self, event): """Merge the participants from event into self.participants. event always takes precedence over self, except if a participant in self isn't in event. This method is only called by the ical merging code because iCalendar attendance updates are partial: an RSVP reply often only contains the status of the person that RSVPs. It would be very wrong to call this method to merge, say, Google Events participants because they handle the merging themselves. """ # We have to jump through some hoops because a participant may # not have an email or may not have a name, so we build a hash # where we can find both. Also note that we store names in the # hash only if the email is None. self_hash = {} for participant in self.participants: email = participant.get("email") name = participant.get("name") if email is not None: participant["email"] = participant["email"].lower() self_hash[email] = participant elif name is not None: # We have a name without an email. self_hash[name] = participant for participant in event.participants: email = participant.get("email") name = participant.get("name") # This is the tricky part --- we only want to store one entry per # participant --- we check if there's an email we already know, if # not we create it. Otherwise we use the name. This sorta works # because we're merging updates to an event and ical updates # always have an email address. # - karim if email is not None: participant["email"] = participant["email"].lower() if email in self_hash: self_hash[email] = self._merge_participant_attributes( self_hash[email], participant) else: self_hash[email] = participant elif name is not None: if name in self_hash: self_hash[name] = self._merge_participant_attributes( self_hash[name], participant) else: self_hash[name] = participant return list(self_hash.values()) def update(self, event): if event.namespace is not None and event.namespace.id is not None: self.namespace_id = event.namespace.id if event.calendar is not None and event.calendar.id is not None: self.calendar_id = event.calendar.id if event.provider_name is not None: self.provider_name = event.provider_name self.uid = event.uid self.raw_data = event.raw_data self.title = event.title self.description = event.description self.location = event.location self.start = event.start self.end = event.end self.all_day = event.all_day self.owner = event.owner self.is_owner = event.is_owner self.read_only = event.read_only self.participants = event.participants self.busy = event.busy self.reminders = event.reminders self.recurrence = event.recurrence self.last_modified = event.last_modified self.message = event.message self.status = event.status self.visibility = event.visibility if event.sequence_number is not None: self.sequence_number = event.sequence_number @property def recurring(self): if self.recurrence and self.recurrence != "": try: r = ast.literal_eval(self.recurrence) if isinstance(r, str): r = [r] return r except (ValueError, SyntaxError): log.warn( "Invalid RRULE entry for event", event_id=self.id, raw_rrule=self.recurrence, ) return [] return [] @property def organizer_email(self): # For historical reasons, the event organizer field is stored as # "Owner Name <*****@*****.**>". parsed_owner = parseaddr(self.owner) if len(parsed_owner) == 0: return None if parsed_owner[1] == "": return None return parsed_owner[1] @property def organizer_name(self): parsed_owner = parseaddr(self.owner) if len(parsed_owner) == 0: return None if parsed_owner[0] == "": return None return parsed_owner[0] @property def is_recurring(self): return self.recurrence is not None @property def length(self): return self.when.delta @property def cancelled(self): return self.status == "cancelled" @cancelled.setter def cancelled(self, is_cancelled): if is_cancelled: self.status = "cancelled" else: self.status = "confirmed" @property def calendar_event_link(self): try: return json.loads(self.raw_data)["htmlLink"] except (ValueError, KeyError): return @property def emails_from_description(self): if self.description: return extract_emails_from_text(self.description) else: return [] @property def emails_from_title(self): if self.title: return extract_emails_from_text(self.title) else: return [] @classmethod def create(cls, **kwargs): # Decide whether or not to instantiate a RecurringEvent/Override # based on the kwargs we get. cls_ = cls kwargs["__event_created_sanely"] = _EVENT_CREATED_SANELY_SENTINEL recurrence = kwargs.get("recurrence") master_event_uid = kwargs.get("master_event_uid") if recurrence and master_event_uid: raise ValueError("Event can't have both recurrence and master UID") if recurrence and recurrence != "": cls_ = RecurringEvent if master_event_uid: cls_ = RecurringEventOverride return cls_(**kwargs) def __init__(self, **kwargs): if (not kwargs.pop("__event_created_sanely", None) is _EVENT_CREATED_SANELY_SENTINEL): raise AssertionError( "Use Event.create with appropriate keyword args " "instead of constructing Event, RecurringEvent or RecurringEventOverride " "directly") # Allow arguments for all subclasses to be passed to main constructor for k in list(kwargs): if not hasattr(type(self), k): del kwargs[k] super(Event, self).__init__(**kwargs)