# -*- coding: utf-8 -*- # ------------------------------------------------------------------------------ import types from appy import Object from appy.shared.utils import splitList, IterSub from appy.gen import Field from appy.px import Px from DateTime import DateTime from BTrees.IOBTree import IOBTree from persistent.list import PersistentList # ------------------------------------------------------------------------------ class Other: '''Identifies a Calendar field that must be shown within another Calendar (see parameter "others" in class Calendar).''' def __init__(self, obj, name, color='grey'): # The object on which this calendar is defined self.obj = obj # The other calendar instance self.field = obj.getField(name) # The color into which events from this calendar must be shown (in the # month rendering) in the calendar integrating this one. self.color = color def getEventsAt(self, res, calendar, date, eventNames, inTimeline, colors): '''Gets the events defined at p_date in this calendar and append them in p_res.''' events = self.field.getEventsAt(self.obj.o, date) if not events: return eventType = events[0].eventType # Gathered info will be an Object instance info = Object(color=self.color) if inTimeline: # Get the background color for this cell if it has been # defined, or (a) nothing if showUncolored is False, (b) a # tooltipped dot else. if eventType in colors: info.bgColor = colors[eventType] info.symbol = None else: info.bgColor = None if calendar.showUncolored: info.symbol = '' % \ eventNames[eventType] else: info.symbol = None else: # Get the event name info.name = eventNames[eventType] res.append(info) # ------------------------------------------------------------------------------ class Calendar(Field): '''This field allows to produce an agenda (monthly view) and view/edit events on it.''' jsFiles = {'view': ('calendar.js',)} DateTime = DateTime Other = Other # Access to the Other class via the Calendar class IterSub = IterSub timelineBgColors = {'Fri': '#a6a6a6', 'Sat': '#c0c0c0', 'Sun': '#c0c0c0'} # For timeline rendering, the row displaying month names pxTimeLineMonths = Px(''' ::mInfo.month''') # For timeline rendering, the row displaying day letters pxTimelineDayLetters = Px(''' :namesOfDays[date.aDay()].name[0] ''') # For timeline rendering, the row displaying day numbers pxTimelineDayNumbers = Px(''' :str(date.day()).zfill(2) ''') # Legend for a timeline calendar pxTimelineLegend = Px('''
 
:allEventNames[eventType]
''') # Timeline view for a calendar pxViewTimeline = Px(''' :field.pxTimeLineMonths:field.pxTimelineDayLetters:field.pxTimelineDayNumbers :field.pxTimelineDayNumbers:field.pxTimelineDayLetters:field.pxTimeLineMonths
::tlName ::field.getTimelineCell(events) ::tlName
:field.pxTimelineLegend''') # Month view for a calendar pxViewMonth = Px('''
:namesOfDays[dayId].short
:day :_('month_%s_short' % date.aMonth())
:allEventNames[eventType]
:event.name
::info
''') pxView = pxCell = Px('''
:_('month_%s' % monthDayOne.aMonth()) :month.split('/')[0]
:getattr(field, 'pxView%s' % render.capitalize())
''') pxEdit = pxSearch = '' def __init__(self, eventTypes, eventNameMethod=None, validator=None, default=None, show=('view', 'xml'), page='main', group=None, layouts=None, move=0, specificReadPermission=False, specificWritePermission=False, width=None, height=300, colspan=1, master=None, masterValue=None, focus=False, mapping=None, label=None, maxEventLength=50, render='month', others=None, timelineName=None, additionalInfo=None, startDate=None, endDate=None, defaultDate=None, colors=None, showUncolored=False, preCompute=None, applicableEvents=None, view=None, xml=None, delete=True): Field.__init__(self, validator, (0,1), default, show, page, group, layouts, move, False, True, False, specificReadPermission, specificWritePermission, width, height, None, colspan, master, masterValue, focus, False, mapping, label, None, None, None, None, True, view, xml) # eventTypes can be a "static" list or tuple of strings that identify # the types of events that are supported by this calendar. It can also # be a method that computes such a "dynamic" list or tuple. When # specifying a static list, an i18n label will be generated for every # event type of the list. When specifying a dynamic list, you must also # give, in p_eventNameMethod, a method that will accept a single arg # (=one of the event types from your dynamic list) and return the "name" # of this event as it must be shown to the user. self.eventTypes = eventTypes self.eventNameMethod = eventNameMethod if callable(eventTypes) and not eventNameMethod: raise Exception("When param 'eventTypes' is a method, you must " \ "give another method in param 'eventNameMethod'.") # It is not possible to create events that span more days than # maxEventLength. self.maxEventLength = maxEventLength # Various render modes exist. Default is the classical "month" view. # It can also be "timeline": in this case, on the x axis, we have one # column per day, and on the y axis, we have one row per calendar (this # one and others as specified in "others", see below). self.render = render # When displaying a given month for this agenda, one may want to # pre-compute, once for the whole month, some information that will then # be given as arg for other methods specified in subsequent parameters. # This mechanism exists for performance reasons, to avoid recomputing # this global information several times. If you specify a method in # p_preCompute, it will be called every time a given month is shown, and # will receive 2 args: the first day of the currently shown month (as a # DateTime instance) and the grid of all shown dates (as a list of lists # of DateTime instances, one sub-list by row in the month view). This # grid may hold a little more than dates of the current month. # Subsequently, the return of your method will be given as arg to other # methods that you may specify as args of other parameters of this # Calendar class (see comments below). self.preCompute = preCompute # If a method is specified in parameter "others" below, it must accept a # single arg (the result of self.preCompute) and must return a list of # calendars whose events must be shown within this agenda. More # precisely, the method can return: # - a single Other instance (see at the top of this file); # - a list of Other instances; # - a list of lists of Other instances, when it has sense to group other # calendars (the timeline rendering exploits this). self.others = others # When displaying a timeline calendar, a name is shown for every other # calendar. If "timelineName" is None (the default), this name will be # the title of the object where the other calendar is defined. Else, it # will be the result of the method specified in "timelineName". This # method must return a string and accepts an Other instance as single # arg. self.timelineName = timelineName # One may want to add, day by day, custom information in the calendar. # When a method is given in p_additionalInfo, for every cell of the # month view, this method will be called with 2 args: the cell's date # and the result of self.preCompute. The method's result (a string that # can hold text or a chunk of XHTML) will be inserted in the cell. self.additionalInfo = additionalInfo # One may limit event encoding and viewing to some period of time, # via p_startDate and p_endDate. Those parameters, if given, must hold # methods accepting no arg and returning a Zope DateTime instance. The # startDate and endDate will be converted to UTC at 00.00. self.startDate = startDate self.endDate = endDate # If a default date is specified, it must be a method accepting no arg # and returning a DateTime instance. As soon as the calendar is shown, # the month where this date is included will be shown. If not default # date is specified, it will be 'now' at the moment the calendar is # shown. self.defaultDate = defaultDate # "colors" must be or return a dict ~{s_eventType: s_color}~ giving a # color to every event type defined in this calendar or in any calendar # from "others". In a timeline, cells are too small to display # translated names for event types, so colors are used instead. self.colors = colors or {} # For event types that are not present in self.colors hereabove, must we # still show them? If yes, they will be represented by a dot with a # tooltip containing the event name. self.showUncolored = showUncolored # For a specific day, all event types may not be applicable. If this is # the case, one may specify here a method that defines, for a given day, # a sub-set of all event types. This method must accept 3 args: the day # in question (as a DateTime instance), the list of all event types, # which is a copy of the (possibly computed) self.eventTypes) and # the result of calling self.preCompute. The method must modify # the 2nd arg and remove from it potentially not applicable events. # This method can also return a message, that will be shown to the user # for explaining him why he can, for this day, only create events of a # sub-set of the possible event types (or even no event at all). self.applicableEvents = applicableEvents # May the user delete events in this calendar? If "delete" is a method, # it must accept an event type as single arg. self.delete = delete def getPreComputedInfo(self, obj, monthDayOne, grid): '''Returns the result of calling self.preComputed, or None if no such method exists.''' if self.preCompute: return self.preCompute(obj.appy(), monthDayOne, grid) def getSiblingMonth(self, month, prevNext): '''Gets the next or previous month (depending of p_prevNext) relative to p_month.''' dayOne = DateTime('%s/01 UTC' % month) if prevNext == 'previous': refDate = dayOne - 1 elif prevNext == 'next': refDate = dayOne + 33 return refDate.strftime('%Y/%m') weekDays = ('Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun') def getNamesOfDays(self, _): '''Returns the translated names of all week days, short and long versions.''' res = {} for day in self.weekDays: name = _('day_%s' % day) short = _('day_%s_short' % day) res[day] = Object(name=name, short=short) return res def getGrid(self, month, render): '''Creates a list of DateTime objects representing the calendar grid to render for a given p_month. If p_render is "month", it is a list of lists (one sub-list for every week; indeed, every week is rendered as a row). If p_render is "timeline", the result is a linear list of DateTime instances.''' # Month is a string "YYYY/mm" currentDay = DateTime('%s/01 UTC' % month) currentMonth = currentDay.month() isLinear = render == 'timeline' if isLinear: res = [] else: res = [[]] dayOneNb = currentDay.dow() or 7 # This way, Sunday is 7 and not 0 if dayOneNb != 1: previousDate = DateTime(currentDay) # If the 1st day of the month is not a Monday, integrate the last # days of the previous month. for i in range(1, dayOneNb): previousDate = previousDate - 1 if isLinear: target = res else: target = res[0] target.insert(0, previousDate) finished = False while not finished: # Insert currentDay in the result if isLinear: res.append(currentDay) else: if len(res[-1]) == 7: # Create a new row res.append([currentDay]) else: res[-1].append(currentDay) currentDay += 1 if currentDay.month() != currentMonth: finished = True # Complete, if needed, the last row with the first days of the next # month. Indeed, we must have a complete week, ending with a Sunday. if isLinear: target = res else: target = res[-1] while target[-1].dow() != 0: target.append(currentDay) currentDay += 1 return res def getOthers(self, obj, preComputed): '''Returns the list of other calendars whose events must also be shown on this calendar.''' res = None if self.others: res = self.others(obj.appy(), preComputed) if res: # Ensure we have a list of lists if isinstance(res, Other): res = [res] if isinstance(res[0], Other): res = [res] if res != None: return res return [[]] def getTimelineName(self, other): '''Returns the name of some p_other calendar as must be shown in a timeline.''' if not self.timelineName: return '%s' % (other.obj.url, other.obj.title) return self.timelineName(self, other) def getTimelineCell(self, events): '''Gets the content of a cell in a timeline calendar.''' # Currently a single event is allowed if not events or not events[0].symbol: return '' return events[0].symbol def getAdditionalInfoAt(self, obj, date, preComputed): '''If the user has specified a method in self.additionalInfo, we call it for displaying this additional info in the calendar, at some p_date.''' if not self.additionalInfo: return return self.additionalInfo(obj.appy(), date, preComputed) def getEventTypes(self, obj): '''Returns the (dynamic or static) event types as defined in self.eventTypes.''' if callable(self.eventTypes): return self.eventTypes(obj.appy()) return self.eventTypes def getColors(self, obj): '''Gets the colors for event types managed by this calendar and others (from self.colors).''' if callable(self.colors): return self.colors(obj) return self.colors def dateInRange(self, date, startDate, endDate): '''Is p_date within the range (possibly) defined for this calendar by p_startDate and p_endDate ?''' tooEarly = startDate and (date < startDate) tooLate = endDate and not tooEarly and (date > endDate) return not tooEarly and not tooLate def getApplicableEventsTypesAt(self, obj, date, eventTypes, preComputed, forBrowser=False): '''Returns the event types that are applicable at a given p_date. More precisely, it returns an object with 2 attributes: * "events" is the list of applicable event types; * "message", not empty if some event types are not applicable, contains a message explaining those event types are not applicable. ''' if not eventTypes: return # There may be no event type at all if not self.applicableEvents: # Keep p_eventTypes as is message = None else: eventTypes = eventTypes[:] message = self.applicableEvents(obj.appy(), date, eventTypes, preComputed) res = Object(eventTypes=eventTypes, message=message) if forBrowser: res.eventTypes = ','.join(res.eventTypes) if not res.message: res.message = '' return res def getEventsAt(self, obj, date): '''Returns the list of events that exist at some p_date (=day).''' obj = obj.o # Ensure p_obj is not a wrapper. if not hasattr(obj.aq_base, self.name): return years = getattr(obj, self.name) year = date.year() if year not in years: return months = years[year] month = date.month() if month not in months: return days = months[month] day = date.day() if day not in days: return return days[day] def getEventTypeAt(self, obj, date): '''Returns the event type of the first event defined at p_day, or None if unspecified.''' events = self.getEventsAt(obj, date) if not events: return return events[0].eventType def getEventsByType(self, obj, eventType, minDate=None, maxDate=None, sorted=True, groupSpanned=False): '''Returns all the events of a given p_eventType. If p_eventType is None, it returns events of all types. The return value is a list of 2-tuples whose 1st elem is a DateTime instance and whose 2nd elem is the event. If p_sorted is True, the list is sorted in chronological order. Else, the order is random, but the result is computed faster. If p_minDate and/or p_maxDate is/are specified, it restricts the search interval accordingly. If p_groupSpanned is True, events spanned on several days are grouped into a single event. In this case, tuples in the result are 3-tuples: (DateTime_startDate, DateTime_endDate, event). ''' # Prevent wrong combinations of parameters if groupSpanned and not sorted: raise Exception('Events must be sorted if you want to get ' \ 'spanned events to be grouped.') obj = obj.o # Ensure p_obj is not a wrapper. res = [] if not hasattr(obj, self.name): return res # Compute "min" and "max" tuples if minDate: minYear = minDate.year() minMonth = (minYear, minDate.month()) minDay = (minYear, minDate.month(), minDate.day()) if maxDate: maxYear = maxDate.year() maxMonth = (maxYear, maxDate.month()) maxDay = (maxYear, maxDate.month(), maxDate.day()) # Browse years years = getattr(obj, self.name) for year in years.keys(): # Don't take this year into account if outside interval if minDate and (year < minYear): continue if maxDate and (year > maxYear): continue months = years[year] # Browse this year's months for month in months.keys(): # Don't take this month into account if outside interval thisMonth = (year, month) if minDate and (thisMonth < minMonth): continue if maxDate and (thisMonth > maxMonth): continue days = months[month] # Browse this month's days for day in days.keys(): # Don't take this day into account if outside interval thisDay = (year, month, day) if minDate and (thisDay < minDay): continue if maxDate and (thisDay > maxDay): continue events = days[day] # Browse this day's events for event in events: # Filter unwanted events if eventType and (event.eventType != eventType): continue # We have found a event. date = DateTime('%d/%d/%d UTC' % (year, month, day)) if groupSpanned: singleRes = [date, None, event] else: singleRes = (date, event) res.append(singleRes) # Sort the result if required if sorted: res.sort(key=lambda x: x[0]) # Group events spanned on several days if required if groupSpanned: # Browse events in reverse order and merge them when appropriate i = len(res) - 1 while i > 0: currentDate = res[i][0] lastDate = res[i][1] previousDate = res[i-1][0] currentType = res[i][2].eventType previousType = res[i-1][2].eventType if (previousDate == (currentDate-1)) and \ (previousType == currentType): # A merge is needed del res[i] res[i-1][1] = lastDate or currentDate i -= 1 return res def hasEventsAt(self, obj, date, otherEvents): '''Returns True if, at p_date, an event is found of the same type as p_otherEvents.''' if not otherEvents: return False events = self.getEventsAt(obj, date) if not events: return False return events[0].eventType == otherEvents[0].eventType def getOtherEventsAt(self, obj, date, others, eventNames, render, colors): '''Gets events that are defined in p_others at some p_date. If p_single is True, p_others does not contain the list of all other calendars, but information about a single calendar.''' res = [] isTimeline = render == 'timeline' if isinstance(others, Other): others.getEventsAt(res, self, date, eventNames, isTimeline, colors) else: for other in IterSub(others): other.getEventsAt(res, self, date, eventNames,isTimeline,colors) return res def getEventName(self, obj, eventType): '''Gets the name of the event corresponding to p_eventType as it must appear to the user.''' if self.eventNameMethod: return self.eventNameMethod(obj.appy(), eventType) else: return obj.translate('%s_event_%s' % (self.labelId, eventType)) def getAllEvents(self, obj, eventTypes, others): '''Computes: * the list of all event types (from this calendar and p_others); * a dict of event names, keyed by event types, for all events in this calendar and p_others).''' res = [[], {}] if eventTypes: for et in eventTypes: res[0].append(et) res[1][et] = self.getEventName(obj, et) if not others: return res for other in IterSub(others): eventTypes = other.field.getEventTypes(other.obj) if eventTypes: for et in eventTypes: if et not in res[1]: res[0].append(et) res[1][et] = other.field.getEventName(other.obj, et) return res def getStartDate(self, obj): '''Get the start date for this calendar if defined''' if self.startDate: d = self.startDate(obj.appy()) # Return the start date without hour, in UTC return DateTime('%d/%d/%d UTC' % (d.year(), d.month(), d.day())) def getEndDate(self, obj): '''Get the end date for this calendar if defined''' if self.endDate: d = self.endDate(obj.appy()) # Return the end date without hour, in UTC. return DateTime('%d/%d/%d UTC' % (d.year(), d.month(), d.day())) def getDefaultDate(self, obj): '''Get the default date that must appear as soon as the calendar is shown.''' if self.defaultDate: return self.defaultDate(obj.appy()) else: return DateTime() # Now def createEvent(self, obj, date, eventType=None, eventSpan=None, handleEventSpan=True): '''Create a new event in the calendar, at some p_date (day). If p_eventType is given, it is used; else, rq['eventType'] is used. If p_handleEventSpan is True, we will use p_eventSpan (or rq["eventSpan"] if p_eventSpan is not given) and also create the same event for successive days.''' obj = obj.o # Ensure p_obj is not a wrapper. rq = obj.REQUEST # Get values from parameters if not eventType: eventType = rq['eventType'] if handleEventSpan and not eventSpan: eventSpan = rq.get('eventSpan', None) # Split the p_date into separate parts year, month, day = date.year(), date.month(), date.day() # Check that the "preferences" dict exists or not if not hasattr(obj.aq_base, self.name): # 1st level: create a IOBTree whose keys are years setattr(obj, self.name, IOBTree()) yearsDict = getattr(obj, self.name) # Get the sub-dict storing months for a given year if year in yearsDict: monthsDict = yearsDict[year] else: yearsDict[year] = monthsDict = IOBTree() # Get the sub-dict storing days of a given month if month in monthsDict: daysDict = monthsDict[month] else: monthsDict[month] = daysDict = IOBTree() # Get the list of events for a given day if day in daysDict: events = daysDict[day] else: daysDict[day] = events = PersistentList() # Create and store the event, excepted if an event already exists if not events: event = Object(eventType=eventType) events.append(event) # Span the event on the successive days if required if handleEventSpan and eventSpan: nbOfDays = min(int(eventSpan), self.maxEventLength) for i in range(nbOfDays): date = date + 1 self.createEvent(obj, date, handleEventSpan=False) def mayDelete(self, obj, events): '''May the user delete p_events?''' if not self.delete: return if callable(self.delete): return self.delete(obj, events[0].eventType) return True def deleteEvent(self, obj, date, handleEventSpan=True): '''Deletes an event. It actually deletes all events at p_date. If p_handleEventSpan is True, we will use rq["deleteNext"] to delete successive events, too.''' obj = obj.o # Ensure p_obj is not a wrapper. if not self.getEventsAt(obj, date): return daysDict = getattr(obj, self.name)[date.year()][date.month()] # Remember events, in case we must delete similar ones for next days. events = self.getEventsAt(obj, date) del daysDict[date.day()] rq = obj.REQUEST if handleEventSpan and rq.has_key('deleteNext') and \ (rq['deleteNext'] == 'True'): while True: date = date + 1 if self.hasEventsAt(obj, date, events): self.deleteEvent(obj, date, handleEventSpan=False) else: break def process(self, obj): '''Processes an action coming from the calendar widget, ie, the creation or deletion of a calendar event.''' rq = obj.REQUEST action = rq['actionType'] # Security check obj.mayEdit(self.writePermission, raiseError=True) # Get the date for this action if action == 'createEvent': return self.createEvent(obj, DateTime(rq['day'])) elif action == 'deleteEvent': return self.deleteEvent(obj, DateTime(rq['day'])) def getColumnStyle(self, obj, date, render, today): '''What style(s) must apply to the table column representing p_date in the calendar? For timelines only.''' if render != 'timeline': return '' # Cells representing specific days must have a specific background color res = '' day = date.aDay() if day in Calendar.timelineBgColors: res = 'background-color: %s' % Calendar.timelineBgColors[day] return res def getCellStyle(self, obj, date, render, events): '''Gets the cell style to apply to the cell corresponding to p_date.''' if render != 'timeline': return '' # Currently, for timelines only if not events: return '' # Currently, a single event is allowed event = events[0] return event.bgColor and ('background-color: %s' % event.bgColor) or '' def getCellClass(self, obj, date, render, today): '''What CSS class(es) must apply to the table cell representing p_date in the calendar?''' if render != 'month': return '' # Currently, for month rendering only res = [] # We must distinguish between past and future dates if date < today: res.append('even') else: res.append('odd') # Week-end days must have a specific style if date.aDay() in ('Sat', 'Sun'): res.append('cellDashed') return ' '.join(res) def getTimelineMonths(self, grid, obj): '''Given the p_grid of dates, this method returns the list of corresponding months.''' res = [] for date in grid: if not res: # Get the month correspoding to the first day in the grid m = Object(month=date.aMonth(), colspan=1, year=date.year()) res.append(m) else: # Augment current month' colspan or create a new one current = res[-1] if date.aMonth() == current.month: current.colspan += 1 else: m = Object(month=date.aMonth(), colspan=1, year=date.year()) res.append(m) # Replace month short names by translated names whose format may vary # according to colspan (a higher colspan allow us to produce a longer # month name). for m in res: text = '%s %d' % (obj.translate('month_%s' % m.month), m.year) if m.colspan < 6: # Short version: a single letter with an acronym m.month = '%s' % (text, text[0]) else: m.month = text return res def splitList(self, l, sub): return splitList(l, sub) # ------------------------------------------------------------------------------