Source code for plone.app.event.base

from Acquisition import aq_inner
from Acquisition import aq_parent
from DateTime import DateTime
from Products.CMFCore.utils import getToolByName
from Products.CMFPlone.i18nl10n import ulocalized_time as orig_ulocalized_time
from Products.CMFPlone.interfaces.siteroot import IPloneSiteRoot
from Products.CMFPlone.utils import safe_callable
from calendar import monthrange
from datetime import date
from datetime import datetime
from datetime import timedelta
from persistent.dict import PersistentDict
from plone.app.event.interfaces import IEventSettings
from plone.app.event.interfaces import ISO_DATE_FORMAT
from plone.app.event.vocabularies import replacement_zones
from plone.app.layout.navigation.root import getNavigationRootObject
from plone.event.interfaces import IEvent
from plone.event.interfaces import IEventAccessor
from plone.event.interfaces import IEventRecurrence
from plone.event.interfaces import IRecurrenceSupport
from plone.event.utils import default_timezone as fallback_default_timezone
from plone.event.utils import is_date
from plone.event.utils import is_datetime
from plone.event.utils import is_same_day
from plone.event.utils import is_same_time
from plone.event.utils import pydt
from plone.event.utils import validated_timezone
from plone.registry.interfaces import IRegistry
from zope.annotation.interfaces import IAnnotations
from zope.component import getUtility
from zope.component import queryUtility
from zope.component.hooks import getSite
from zope.component.interfaces import ISite


import pytz


DEFAULT_END_DELTA = 1  # hours
FALLBACK_TIMEZONE = 'UTC'


# RETRIEVE EVENTS

[docs]def get_events(context, start=None, end=None, limit=None, ret_mode=1, expand=False, sort='start', sort_reverse=False, **kw): """Return all events as catalog brains, possibly within a given timeframe. :param context: [required] A context object. :type context: Content object :param start: Date, from which on events should be searched. :type start: Python datetime. :param end: Date, until which events should be searched. :type end: Python datetime :param limit: Number of items to be returned. :type limit: integer :param ret_mode: Return type of search results. These options are available: * 1 (brains): Return results as catalog brains. * 2 (objects): Return results as IEvent and/or IOccurrence objects. * 3 (accessors): Return results as IEventAccessor wrapper objects. :type ret_mode: integer [1|2|3] :param expand: Expand the results to all occurrences (within a timeframe, if given). With this option set to True, the resultset also includes the event's recurrence occurrences and is sorted by the start date. Only available in ret_mode 2 (objects) and 3 (accessors). :type expand: boolean :param sort: Catalog index id to sort on. Not available with expand=True. :type sort: string :param sort_reverse: Change the order of the sorting. :type sort_reverse: boolean :returns: Portal events, matching the search criteria. :rtype: catalog brains """ start, end = _prepare_range(context, start, end) query = {} query['object_provides'] = IEvent.__identifier__ if 'path' not in kw: # limit to the current navigation root, usually (not always) site portal = getSite() navroot = getNavigationRootObject(context, portal) query['path'] = '/'.join(navroot.getPhysicalPath()) else: query['path'] = kw['path'] if start: # All events from start date ongoing: # The minimum end date of events is the date from which we search. query['end'] = {'query': start, 'range': 'min'} if end: # All events until end date: # The maximum start date must be the date until we search. query['start'] = {'query': end, 'range': 'max'} # Sorting # In expand mode we sort after calculation of recurrences again. But we # need to leave this sorting here in place, since no sort definition could # lead to arbitrary results when limiting with sort_limit. query['sort_on'] = sort if sort_reverse: query['sort_order'] = 'reverse' # No sort_limit here! See below. query.update(kw) cat = getToolByName(context, 'portal_catalog') result = cat(**query) # Helper functions def _obj_or_acc(obj, ret_mode): if ret_mode == 2: return obj elif ret_mode == 3: return IEventAccessor(obj) def _get_compare_attr(obj, attr): val = getattr(obj, attr, None) if safe_callable(val): val = val() if isinstance(val, DateTime): val = pydt(val) return val if ret_mode in (2, 3) and expand is False: result = [_obj_or_acc(it.getObject(), ret_mode) for it in result] elif ret_mode in (2, 3) and expand is True: exp_result = [] for it in result: obj = it.getObject() if IEventRecurrence.providedBy(obj): occurrences = [_obj_or_acc(occ, ret_mode) for occ in IRecurrenceSupport(obj).occurrences(start, end)] else: occurrences = [_obj_or_acc(obj, ret_mode)] exp_result += occurrences if sort: # support AT and DX without wrapped by IEventAccessor (mainly for # sorting after "start" or "end"). exp_result.sort(key=lambda x: _get_compare_attr(x, sort)) if sort_reverse: exp_result.reverse() result = exp_result if limit: # Limiting the result set can only happen here, after possibly exanding # the result set with it's occurrences. # Otherwise we might get wrong results - see : # p.a.event.tests.test_base_module.TestGetEventsDX.test_get_event_limit result = result[:limit] return result
[docs]def construct_calendar(events, start=None, end=None): """Return a dictionary with dates in a given timeframe as keys and the actual occurrences for that date for building calendars. Long lasting events will occur on every day until their end. :param events: List of IEvent and/or IOccurrence objects, to construct a calendar data structure from. :type events: list :param start: An optional start range date. :type start: Python datetime or date :param end: An optional start range date. :type end: Python datetime or date :returns: Dictionary with isoformat date strings as keys and event occurrences as values. :rtype: dict """ if start: if is_datetime(start): start = start.date() assert is_date(start) if end: if is_datetime(end): end = end.date() assert is_date(end) cal = {} def _add_to_cal(cal_data, event, date): date_str = date.isoformat() if date_str not in cal_data: cal_data[date_str] = [event] else: cal_data[date_str].append(event) return cal_data for event in events: acc = IEventAccessor(event) start_date = acc.start.date() end_date = acc.end.date() # day span between start and end + 1 for the initial date range_days = (end_date - start_date).days + 1 for add_day in range(range_days): next_start_date = start_date + timedelta(add_day) # initial = 0 # avoid long loops if start and end_date < start: break # if the date is completly outside the range if start and next_start_date <= start: continue # if start is outside but end reaches into range if end and next_start_date > end: break # if date is outside range _add_to_cal(cal, event, next_start_date) return cal
def _prepare_range(context, start, end): """Prepare a date-range to contain timezone info and set end to next day, if end is a date. :param context: [required] Context object. :type context: Content object :param start: [required] Range start. :type start: Python date or datetime :param end: [required] Range end. :type end: Python date or datetime :returns: Localized start and end datetime. :rtype: tuple """ tz = default_timezone(context, as_tzinfo=True) start = pydt(start, missing_zone=tz) if is_date(end): # set range_end to the next day, time will be 0:00 # so the whole previous day is also used for search end = end + timedelta(days=1) end = pydt(end, missing_zone=tz) return start, end # TIMEZONE HANDLING
[docs]def default_timezone(context=None, as_tzinfo=False): """Return the timezone from the portal or user. :param context: Optional context. If not given, the current Site is used. :type context: Content object :param as_tzinfo: Return the default timezone as tzinfo object. :type as_tzinfo: boolean :returns: Timezone identifier or tzinfo object. :rtype: string or tzinfo object """ # TODO: test member timezone if not context: context = getSite() membership = getToolByName(context, 'portal_membership', None) if membership and not membership.isAnonymousUser(): # user not logged in member = membership.getAuthenticatedMember() member_timezone = member.getProperty('timezone', None) if member_timezone: info = pytz.timezone(member_timezone) return info if as_tzinfo else info.zone portal_timezone = None reg = queryUtility(IRegistry, context=context, default=None) if reg: portal_timezone = reg.forInterface( IEventSettings, prefix="plone.app.event", check=False # Don't fail, if portal_timezone isn't set. ).portal_timezone # fallback to what plone.event is doing if not portal_timezone: portal_timezone = fallback_default_timezone() # Change any ambiguous timezone abbreviations to their most common # non-ambigious timezone name. if portal_timezone in replacement_zones.keys(): portal_timezone = replacement_zones[portal_timezone] portal_timezone = validated_timezone(portal_timezone, FALLBACK_TIMEZONE) if as_tzinfo: return pytz.timezone(portal_timezone) return portal_timezone
[docs]def localized_now(context=None): """Return the current datetime localized to the default timezone. :param context: Context object. :type context: Content object :returns: Localized current datetime. :rtype: Python datetime """ if not context: context = getSite() tzinfo = default_timezone(context=context, as_tzinfo=True) return datetime.now(tzinfo).replace(microsecond=0)
[docs]def localized_today(context=None): """Return the current date localized to the default timezone. :param context: Context object. :type context: Content object :returns: Localized current date. :rtype: Python date """ now = localized_now(context) return date(now.year, now.month, now.day) # DATETIME HELPERS
[docs]def first_weekday(): """Returns the number of the first Weekday in a Week, as defined in the registry. 0 is Monday, 6 is Sunday, as expected by Python's datetime. PLEASE NOTE: strftime %w interprets 0 as Sunday unlike the calendar module! :returns: Index of first weekday [0(Monday)..6(Sunday)] :rtype: integer """ controlpanel = getUtility(IRegistry).forInterface(IEventSettings, prefix="plone.app.event") first_wd = controlpanel.first_weekday if not first_wd: return 0 else: return int(first_wd)
[docs]def wkday_to_mon0(day): """Converts an integer weekday number to a representation where Monday is 0 and Sunday is 6 (the datetime default), from a representation where Sunday is 0, Monday is 1 and Saturday is 6 (the strftime behavior). :param day: The weekday number [0(Sunday)..6] :type day: integer :returns: The weekday number [0(Monday)..6] :rtype: integer """ if day == 0: return 6 else: return day - 1
[docs]def wkday_to_mon1(day): """Converts an integer weekday number to a representation where Monday is 1, Saturday is 6 and Sunday is 0 (the strftime behavior), from a representation where Monday is 0 and Sunday is 6 (the datetime default). :param day: The weekday number [0(Monday)..6] :type day: integer :returns: The weekday number [0(Sunday)..6] :rtype: integer """ if day == 6: return 0 else: return day + 1
[docs]def DT(dt, exact=False): """Return a Zope DateTime instance from a Python datetime instance. :param dt: Python datetime, Python date, Zope DateTime instance or string. :param exact: If True, the resolution goes down to microseconds. If False, the resolution are seconds. Defaul is False. :type exact: Boolean :returns: Zope DateTime :rtype: Zope DateTime """ def _adjust_DT(DT, exact): if exact: ret = DT else: ret = DateTime( DT.year(), DT.month(), DT.day(), DT.hour(), DT.minute(), int(DT.second()), DT.timezone() ) return ret tz = default_timezone(getSite()) ret = None if is_datetime(dt): zone_id = getattr(dt.tzinfo, 'zone', tz) tz = validated_timezone(zone_id, tz) second = dt.second if exact: second += dt.microsecond / 1000000.0 ret = DateTime( dt.year, dt.month, dt.day, dt.hour, dt.minute, second, tz ) elif is_date(dt): ret = DateTime(dt.year, dt.month, dt.day, 0, 0, 0, tz) elif isinstance(dt, DateTime): # No timezone validation. DateTime knows how to handle it's zones. ret = _adjust_DT(dt, exact=exact) else: # Try to convert by DateTime itself ret = _adjust_DT(DateTime(dt), exact=exact) return ret
[docs]def guess_date_from(datestr, context=None): """Returns a timezone aware date object if an arbitrary ASCII string is formatted in an ISO date format, otherwise None is returned. Used for traversing and Occurence ids. :param datestr: Date string in an ISO format. :type datestr: string :param context: Context object (for retrieving the timezone). :type context: Content object :returns: Localized date object. :rtype: Python date """ try: dateobj = datetime.strptime(datestr, ISO_DATE_FORMAT) except ValueError: return ret = pytz.timezone(default_timezone(context)).localize(dateobj) return ret
[docs]def dt_start_of_day(dt): """Returns a Python datetime instance set to the start time of the given day (00:00:00). :param dt: datetime to set to the start time of the day. :type dt: Python datetime :returns: datetime set to the start time of the day (00:00:00). :rtype: Python datetime """ if not isinstance(dt, datetime): # is a date dt = datetime.fromordinal(dt.toordinal()) return dt.replace(hour=0, minute=0, second=0, microsecond=0)
[docs]def dt_end_of_day(dt): """Returns a Python datetime instance set to the end time of the given day (23:59:59). :param dt: datetime to set to the end time of the day. :type dt: Python datetime :returns: datetime set to the end time of the day (23:59:59). :rtype: Python datetime """ if not isinstance(dt, datetime): # is a date dt = datetime.fromordinal(dt.toordinal()) return dt.replace(hour=23, minute=59, second=59, microsecond=0)
[docs]def start_end_from_mode(mode, dt=None, context=None): """Return a start and end date from a given mode string, like "today", "past" or "future". This can be used in event retrieval functions. :param mode: One of the following modes: 'all' Show all events. 'past': Show only past events with descending sorting. 'future': Show only future events (default). 'today': Show todays events. 'now': Show todays upcoming events. '7days': Show events until 7 days in future. 'day': Return all events on the given day (dt parameter required) 'week': Show a weeks events, optionally from a given date. These settings override the start and end parameters. Not implemented yet: 'month': Show this month's events. :type mode: string :param dt: Optional datetime for day mode. :type dt: Python datetime """ if not context: context = getSite() now = localized_now(context) start = end = None if mode == 'all': start = None end = None elif mode == 'past': start = None end = now elif mode == 'future': start = now end = None elif mode == 'now': start = now end = dt_end_of_day(now) elif mode == '7days': start = now end = dt_end_of_day(now + timedelta(days=6)) elif mode == 'day' or mode == 'today': if not dt: dt = now # show today start = dt_start_of_day(dt) end = dt_end_of_day(dt) elif mode == 'week': if not dt: dt = now # show this week wkd = dt.weekday() first = first_weekday() if first <= wkd: delta = wkd - first # >= 0 if first > wkd: delta = wkd + 7 - first # > 0 start = dt_start_of_day(dt - timedelta(days=delta)) end = dt_end_of_day(start + timedelta(days=6)) elif mode == 'month': if not dt: dt = now # show this month year = dt.year month = dt.month last_day = monthrange(year, month)[1] # (wkday, days) start = dt_start_of_day(datetime(year, month, 1)) end = dt_end_of_day(datetime(year, month, last_day)) return start, end # DISPLAY HELPERS
[docs]def dates_for_display(occurrence): """ Return a dictionary containing pre-calculated information for building <start>-<end> date strings. Keys are: 'start_date' - date string of the start date 'start_time' - time string of the start date 'end_date' - date string of the end date 'end_time' - time string of the end date 'start_iso' - start date in iso format 'end_iso' - end date in iso format 'same_day' - event ends on the same day 'same_time' - event ends at same time 'whole_day' - whole day events 'open_end' - events without end time The behavior os ulocalized_time() with time_only is odd. Setting time_only=False should return the date part only and *not* the time >>> from DateTime import DateTime >>> start = DateTime(2010,3,16,14,40) >>> from zope.componen.hooks import getSite >>> site = getSite() >>> ulocalized_time(start, False, time_only=True, context=site) u'14:40' >>> ulocalized_time(start, False, time_only=False, context=site) u'14:40' >>> ulocalized_time(start, False, time_only=None, context=site) u'16.03.2010' """ acc = IEventAccessor(occurrence) # this needs to separate date and time as ulocalized_time does DT_start = DT(acc.start) DT_end = DT(acc.end) start_date = ulocalized_time( DT_start, long_format=False, time_only=None, context=occurrence ) start_time = ulocalized_time( DT_start, long_format=False, time_only=True, context=occurrence ) end_date = ulocalized_time( DT_end, long_format=False, time_only=None, context=occurrence ) end_time = ulocalized_time( DT_end, long_format=False, time_only=True, context=occurrence ) same_day = is_same_day(acc.start, acc.end) same_time = is_same_time(acc.start, acc.end) # set time fields to None for whole day events if acc.whole_day: start_time = end_time = None if acc.open_end: end_time = None start_iso = acc.whole_day and acc.start.date().isoformat()\ or acc.start.isoformat() end_iso = acc.whole_day and acc.end.date().isoformat()\ or acc.end.isoformat() return dict( # Start start_date=start_date, start_time=start_time, start_iso=start_iso, # End end_date=end_date, end_time=end_time, end_iso=end_iso, # Meta same_day=same_day, same_time=same_time, whole_day=acc.whole_day, open_end=acc.open_end, )
[docs]def date_speller(context, dt): """Return a dictionary with localized and readably formatted date parts. """ dt = DT(dt) util = getToolByName(context, 'translation_service') dom = 'plonelocales' def zero_pad(num): return '%02d' % num date_dict = dict( year=dt.year(), month=util.translate( util.month_msgid(dt.month()), domain=dom, context=context ), month_abbr=util.translate( util.month_msgid(dt.month(), 'a'), domain=dom, context=context ), wkday=util.translate( util.day_msgid(dt.dow()), domain=dom, context=context ), wkday_abbr=util.translate( util.day_msgid(dt.dow(), 'a'), domain=dom, context=context ), day=dt.day(), day2=zero_pad(dt.day()), hour=dt.hour(), hour2=zero_pad(dt.hour()), minute=dt.minute(), minute2=zero_pad(dt.minute()), second=dt.second(), second2=zero_pad(dt.second()) ) return date_dict
[docs]def default_start(context=None): """Return the default start as python datetime for prefilling forms. :returns: Default start datetime. :rtype: Python datetime """ now = localized_now(context=context) return now.replace(minute=0, second=0, microsecond=0)
[docs]def default_end(context=None): """Return the default end as python datetime for prefilling forms. :returns: Default end datetime. :rtype: Python datetime """ return default_start(context=context) + timedelta(hours=DEFAULT_END_DELTA) # General utils # TODO: Better fits to CMFPlone. (Taken from CMFPlone's new syndication tool)
[docs]class AnnotationAdapter(object): """Abstract Base Class for an annotation storage. If the annotation wasn't set, it won't be created until the first attempt to set a property on this adapter. So, the context doesn't get polluted with annotations by accident. """ ANNOTATION_KEY = None def __init__(self, context): self.context = context annotations = IAnnotations(context) self._data = annotations.get(self.ANNOTATION_KEY, None) def __setattr__(self, name, value): if name in ('context', '_data', 'ANNOTATION_KEY'): self.__dict__[name] = value else: if self._data is None: self._data = PersistentDict() annotations = IAnnotations(self.context) annotations[self.ANNOTATION_KEY] = self._data self._data[name] = value def __getattr__(self, name): return self._data and self._data.get(name, None) or None
[docs]def find_context(context, viewname=None, iface=None, as_url=False, append_view=True): """Find the next context with a given view name or interface, up in the content tree, starting from the given context. This might not be the IPloneSiteRoot, but another subsite. :param context: The context to start the search from. :param viewname: (optional) The name of a view which a context should have configured as defaultView. :param iface: (optional) The interface, the context to search for should implement. :param as_url: (optional) Return the URL of the context found. :param append_view: (optional) In case of a given viewname and called with as_url, append the viewname to the url, if the context hasn't configured it as defaultView. Otherwise ignore this parameter. :returns: A context with the given view name, inteface or ISite root. """ context = aq_inner(context) ret = None if viewname and context.defaultView() == viewname\ or iface and iface.providedBy(context)\ or IPloneSiteRoot.providedBy(context): # Search for viewname or interface but stop at IPloneSiteRoot ret = context else: ret = find_context(aq_parent(context), viewname=viewname, iface=iface, as_url=False, append_view=False) if as_url: url = ret.absolute_url() if viewname and append_view and ret.defaultView() != viewname: url = '%s/%s' % (url, viewname) return url return ret
def find_site(context, as_url=False): return find_context(context, iface=ISite, as_url=as_url) def find_ploneroot(context, as_url=False): return find_context(context, iface=IPloneSiteRoot, as_url=as_url) def find_event_listing(context, as_url=False): return find_context(context, viewname='event_listing', iface=ISite, as_url=as_url, append_view=True) # Workaround for buggy strftime with timezone handling in DateTime. # See: https://github.com/plone/plone.app.event/pull/47 # TODO: should land in CMFPlone or fixed in DateTime. _strftime = lambda v, fmt: pydt(v).strftime(fmt) class PatchedDateTime(DateTime): def strftime(self, fmt): return _strftime(self, fmt)
[docs]def ulocalized_time(time, *args, **kwargs): """Corrects for DateTime bugs doing wrong thing with timezones""" wrapped_time = PatchedDateTime(time) return orig_ulocalized_time(wrapped_time, *args, **kwargs)