web/views/calendar.py
author Sylvain Thénault <sylvain.thenault@logilab.fr>
Thu, 20 May 2010 20:47:55 +0200
changeset 5556 9ab2b4c74baf
parent 5424 8ecbcbff9777
child 5713 605f571198eb
permissions -rw-r--r--
[entity] introduce a new 'adapters' registry This changeset introduces the notion in adapters (as in Zope Component Architecture) in a cubicweb way, eg using a specific registry of appobjects. This allows nicer code structure, by avoid clutering entity classes and moving code usually specific to a place of the ui (or something else) together with the code that use the interface. We don't use actual interface anymore, they are implied by adapters (which may be abstract), whose reg id is an interface name. Appobjects that used to 'implements(IFace)' should now be rewritten by: * coding an IFaceAdapter(EntityAdapter) defining (implementing if desired) the interface, usually with __regid__ = 'IFace' * use "adaptable('IFace')" as selector instead Also, the implements_adapter_compat decorator eases backward compatibility with adapter's methods that may still be found on entities implementing the interface. Notice that unlike ZCA, we don't support automatic adapters chain (yagni?). All interfaces defined in cubicweb have been turned into adapters, also some new ones have been introduced to cleanup Entity / AnyEntity classes namespace. At the end, the pluggable mixins mecanism should disappear in favor of adapters as well.

# copyright 2003-2010 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
#
# This file is part of CubicWeb.
#
# CubicWeb is free software: you can redistribute it and/or modify it under the
# terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option)
# any later version.
#
# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
"""html calendar views"""

__docformat__ = "restructuredtext en"
_ = unicode

from datetime import datetime, date, timedelta

from logilab.mtconverter import xml_escape
from logilab.common.date import ONEDAY, strptime, date_range, todate, todatetime

from cubicweb.interfaces import ICalendarable
from cubicweb.selectors import implements, adaptable
from cubicweb.view import EntityView, EntityAdapter, implements_adapter_compat


class ICalendarableAdapter(EntityAdapter):
    __regid__ = 'ICalendarable'
    __select__ = implements(ICalendarable) # XXX for bw compat, should be abstract

    @property
    @implements_adapter_compat('ICalendarable')
    def start(self):
        """return start date"""
        raise NotImplementedError

    @property
    @implements_adapter_compat('ICalendarable')
    def stop(self):
        """return stop state"""
        raise NotImplementedError


# useful constants & functions ################################################

ONEDAY = timedelta(1)

WEEKDAYS = (_("monday"), _("tuesday"), _("wednesday"), _("thursday"),
            _("friday"), _("saturday"), _("sunday"))
MONTHNAMES = ( _('january'), _('february'), _('march'), _('april'), _('may'),
               _('june'), _('july'), _('august'), _('september'), _('october'),
               _('november'), _('december')
               )

# Calendar views ##############################################################

try:
    from vobject import iCalendar

    class iCalView(EntityView):
        """A calendar view that generates a iCalendar file (RFC 2445)

        Does apply to ICalendarable compatible entities
        """
        __select__ = adaptable('ICalendarable')
        paginable = False
        content_type = 'text/calendar'
        title = _('iCalendar')
        templatable = False
        __regid__ = 'ical'

        def call(self):
            ical = iCalendar()
            for i in range(len(self.cw_rset.rows)):
                task = self.cw_rset.complete_entity(i, 0)
                event = ical.add('vevent')
                event.add('summary').value = task.dc_title()
                event.add('description').value = task.dc_description()
                icalendarable = task.cw_adapt_to('ICalendarable')
                if icalendarable.start:
                    event.add('dtstart').value = icalendarable.start
                if icalendarable.stop:
                    event.add('dtend').value = icalendarable.stop

            buff = ical.serialize()
            if not isinstance(buff, unicode):
                buff = unicode(buff, self._cw.encoding)
            self.w(buff)

except ImportError:
    pass

class hCalView(EntityView):
    """A calendar view that generates a hCalendar file

    Does apply to ICalendarable compatible entities
    """
    __regid__ = 'hcal'
    __select__ = adaptable('ICalendarable')
    paginable = False
    title = _('hCalendar')
    #templatable = False

    def call(self):
        self.w(u'<div class="hcalendar">')
        for i in range(len(self.cw_rset.rows)):
            task = self.cw_rset.complete_entity(i, 0)
            self.w(u'<div class="vevent">')
            self.w(u'<h3 class="summary">%s</h3>' % xml_escape(task.dc_title()))
            self.w(u'<div class="description">%s</div>'
                   % task.dc_description(format='text/html'))
            icalendarable = task.cw_adapt_to('ICalendarable')
            if icalendarable.start:
                self.w(u'<abbr class="dtstart" title="%s">%s</abbr>'
                       % (icalendarable.start.isoformat(),
                          self._cw.format_date(icalendarable.start)))
            if icalendarable.stop:
                self.w(u'<abbr class="dtstop" title="%s">%s</abbr>'
                       % (icalendarable.stop.isoformat(),
                          self._cw.format_date(icalendarable.stop)))
            self.w(u'</div>')
        self.w(u'</div>')


class CalendarItemView(EntityView):
    __regid__ = 'calendaritem'

    def cell_call(self, row, col, dates=False):
        task = self.cw_rset.complete_entity(row, 0)
        task.view('oneline', w=self.w)
        if dates:
            icalendarable = task.cw_adapt_to('ICalendarable')
            if icalendarable.start and icalendarable.stop:
                self.w('<br/> %s' % self._cw._('from %(date)s')
                       % {'date': self._cw.format_date(icalendarable.start)})
                self.w('<br/> %s' % self._cw._('to %(date)s')
                       % {'date': self._cw.format_date(icalendarable.stop)})
            else:
                self.w('<br/>%s'%self._cw.format_date(icalendarable.start
                                                      or icalendarable.stop))

class CalendarLargeItemView(CalendarItemView):
    __regid__ = 'calendarlargeitem'


class _TaskEntry(object):
    def __init__(self, task, color, index=0):
        self.task = task
        self.color = color
        self.index = index
        self.length = 1
        icalendarable = task.cw_adapt_to('ICalendarable')
        self.start = icalendarable.start
        self.stop = icalendarable.stop

    def in_working_hours(self):
        """predicate returning True is the task is in working hours"""
        if todatetime(self.start).hour > 7 and todatetime(self.stop).hour < 20:
            return True
        return False

    def is_one_day_task(self):
        return self.start and self.stop and self.start.isocalendar() == self.stop.isocalendar()


class OneMonthCal(EntityView):
    """At some point, this view will probably replace ampm calendars"""
    __regid__ = 'onemonthcal'
    __select__ = adaptable('ICalendarable')

    paginable = False
    title = _('one month')

    def call(self):
        self._cw.add_js('cubicweb.ajax.js')
        self._cw.add_css('cubicweb.calendar.css')
        # XXX: restrict courses directy with RQL
        _today =  datetime.today()

        if 'year' in self._cw.form:
            year = int(self._cw.form['year'])
        else:
            year = _today.year
        if 'month' in self._cw.form:
            month = int(self._cw.form['month'])
        else:
            month = _today.month

        first_day_of_month = date(year, month, 1)
        firstday = first_day_of_month - timedelta(first_day_of_month.weekday())
        if month >= 12:
            last_day_of_month = date(year + 1, 1, 1) - timedelta(1)
        else:
            last_day_of_month = date(year, month + 1, 1) - timedelta(1)
        # date range exclude last day so we should at least add one day, hence
        # the 7
        lastday = last_day_of_month + timedelta(7 - last_day_of_month.weekday())
        month_dates = list(date_range(firstday, lastday))
        dates = {}
        task_max = 0
        for row in xrange(self.cw_rset.rowcount):
            task = self.cw_rset.get_entity(row, 0)
            if len(self.cw_rset[row]) > 1 and self.cw_rset.description[row][1] == 'CWUser':
                user = self.cw_rset.get_entity(row, 1)
            else:
                user = None
            the_dates = []
            icalendarable = task.cw_adapt_to('ICalendarable')
            tstart = icalendarable.start
            if tstart:
                tstart = todate(icalendarable.start)
                if tstart > lastday:
                    continue
                the_dates = [tstart]
            tstop = icalendarable.stop
            if tstop:
                tstop = todate(tstop)
                if tstop < firstday:
                    continue
                the_dates = [tstop]
            if tstart and tstop:
                if tstart.isocalendar() == tstop.isocalendar():
                    if firstday <= tstart <= lastday:
                        the_dates = [tstart]
                else:
                    the_dates = date_range(max(tstart, firstday),
                                           min(tstop + ONEDAY, lastday))
            if not the_dates:
                continue

            for d in the_dates:
                d_tasks = dates.setdefault((d.year, d.month, d.day), {})
                t_users = d_tasks.setdefault(task, set())
                t_users.add( user )
                if len(d_tasks) > task_max:
                    task_max = len(d_tasks)

        days = []
        nrows = max(3, task_max)
        # colors here are class names defined in cubicweb.css
        colors = [ "col%x" % i for i in range(12) ]
        next_color_index = 0

        visited_tasks = {} # holds a description of a task
        task_colors = {}   # remember a color assigned to a task
        for mdate in month_dates:
            d_tasks = dates.get((mdate.year, mdate.month, mdate.day), {})
            rows = [None] * nrows
            # every task that is "visited" for the first time
            # require a special treatment, so we put them in
            # 'postpone'
            postpone = []
            for task in d_tasks:
                if task in visited_tasks:
                    task_descr = visited_tasks[ task ]
                    rows[task_descr.index] = task_descr
                else:
                    postpone.append(task)
            for task in postpone:
                # to every 'new' task we must affect a color
                # (which must be the same for every user concerned
                # by the task)
                for i, t in enumerate(rows):
                    if t is None:
                        if task in task_colors:
                            color = task_colors[task]
                        else:
                            color = colors[next_color_index]
                            next_color_index = (next_color_index+1)%len(colors)
                            task_colors[task] = color
                        task_descr = _TaskEntry(task, color, i)
                        rows[i] = task_descr
                        visited_tasks[task] = task_descr
                        break
                else:
                    raise RuntimeError("is it possible we got it wrong?")

            days.append( rows )

        curdate = first_day_of_month
        self.w(u'<div id="onemonthcalid">')
        # build schedule
        self.w(u'<table class="omcalendar">')
        prevlink, nextlink = self._prevnext_links(curdate)  # XXX
        self.w(u'<tr><th><a href="%s">&lt;&lt;</a></th><th colspan="5">%s %s</th>'
               u'<th><a href="%s">&gt;&gt;</a></th></tr>' %
               (xml_escape(prevlink), self._cw._(curdate.strftime('%B').lower()),
                curdate.year, xml_escape(nextlink)))

        # output header
        self.w(u'<tr><th>%s</th><th>%s</th><th>%s</th><th>%s</th><th>%s</th><th>%s</th><th>%s</th></tr>' %
               tuple(self._cw._(day) for day in WEEKDAYS))
        # build calendar
        for mdate, task_rows in zip(month_dates, days):
            if mdate.weekday() == 0:
                self.w(u'<tr>')
            self._build_calendar_cell(mdate, task_rows, curdate)
            if mdate.weekday() == 6:
                self.w(u'</tr>')
        self.w(u'</table></div>')

    def _prevnext_links(self, curdate):
        prevdate = curdate - timedelta(31)
        nextdate = curdate + timedelta(31)
        rql = self.cw_rset.printable_rql()
        prevlink = self._cw.build_ajax_replace_url('onemonthcalid', rql, 'onemonthcal',
                                                   year=prevdate.year,
                                                   month=prevdate.month)
        nextlink = self._cw.build_ajax_replace_url('onemonthcalid', rql, 'onemonthcal',
                                                   year=nextdate.year,
                                                   month=nextdate.month)
        return prevlink, nextlink

    def _build_calendar_cell(self, celldate, rows, curdate):
        curmonth = curdate.month
        classes = ""
        if celldate.month != curmonth:
            classes += " outOfRange"
        if celldate == date.today():
            classes += " today"
        self.w(u'<td class="cell%s">' % classes)
        self.w(u'<div class="calCellTitle%s">' % classes)
        self.w(u'<div class="day">%s</div>' % celldate.day)

        if len(self.cw_rset.column_types(0)) == 1:
            etype = list(self.cw_rset.column_types(0))[0]
            url = self._cw.build_url(vid='creation', etype=etype,
                                     schedule=True,
                                     start=self._cw.format_date(celldate), stop=self._cw.format_date(celldate),
                                     __redirectrql=self.cw_rset.printable_rql(),
                                     __redirectparams=self._cw.build_url_params(year=curdate.year, month=curmonth),
                                     __redirectvid=self.__regid__
                                     )
            self.w(u'<div class="cmd"><a href="%s">%s</a></div>' % (xml_escape(url), self._cw._(u'add')))
            self.w(u'&#160;')
        self.w(u'</div>')
        self.w(u'<div class="cellContent">')
        for task_descr in rows:
            if task_descr:
                task = task_descr.task
                self.w(u'<div class="task %s">' % task_descr.color)
                task.view('calendaritem', w=self.w )
                url = task.absolute_url(vid='edition',
                                        __redirectrql=self.cw_rset.printable_rql(),
                                        __redirectparams=self._cw.build_url_params(year=curdate.year, month=curmonth),
                                        __redirectvid=self.__regid__
                                        )

                self.w(u'<div class="tooltip" ondblclick="stopPropagation(event); window.location.assign(\'%s\'); return false;">' % xml_escape(url))
                task.view('tooltip', w=self.w )
                self.w(u'</div>')
            else:
                self.w(u'<div class="task">')
                self.w(u"&#160;")
            self.w(u'</div>')
        self.w(u'</div>')
        self.w(u'</td>')


class OneWeekCal(EntityView):
    """At some point, this view will probably replace ampm calendars"""
    __regid__ = 'oneweekcal'
    __select__ = adaptable('ICalendarable')

    paginable = False
    title = _('one week')

    def call(self):
        self._cw.add_js( ('cubicweb.ajax.js', 'cubicweb.calendar.js') )
        self._cw.add_css('cubicweb.calendar.css')
        # XXX: restrict directly with RQL
        _today =  datetime.today()
        if 'year' in self._cw.form:
            year = int(self._cw.form['year'])
        else:
            year = _today.year
        if 'week' in self._cw.form:
            week = int(self._cw.form['week'])
        else:
            week = _today.isocalendar()[1]
        # week - 1 since we get week number > 0 while we want it to start from 0
        first_day_of_week = todate(strptime('%s-%s-1' % (year, week - 1), '%Y-%U-%w'))
        lastday = first_day_of_week + timedelta(6)
        firstday = first_day_of_week
        dates = [[] for i in range(7)]
        task_colors = {}   # remember a color assigned to a task
        # colors here are class names defined in cubicweb.css
        colors = [ "col%x" % i for i in range(12) ]
        next_color_index = 0
        done_tasks = []
        for row in xrange(self.cw_rset.rowcount):
            task = self.cw_rset.get_entity(row, 0)
            if task in done_tasks:
                continue
            done_tasks.append(task)
            the_dates = []
            icalendarable = task.cw_adapt_to('ICalendarable')
            tstart = icalendarable.start
            tstop = icalendarable.stop
            if tstart:
                tstart = todate(tstart)
                if tstart > lastday:
                    continue
                the_dates = [tstart]
            if tstop:
                tstop = todate(tstop)
                if tstop < firstday:
                    continue
                the_dates = [tstop]
            if tstart and tstop:
                the_dates = date_range(max(tstart, firstday),
                                       min(tstop + ONEDAY, lastday))
            if not the_dates:
                continue

            if task not in task_colors:
                task_colors[task] = colors[next_color_index]
                next_color_index = (next_color_index+1) % len(colors)

            for d in the_dates:
                day = d.weekday()
                task_descr = _TaskEntry(task, task_colors[task])
                dates[day].append(task_descr)

        self.w(u'<div id="oneweekcalid">')
        # build schedule
        self.w(u'<table class="omcalendar" id="week">')
        prevlink, nextlink = self._prevnext_links(first_day_of_week)  # XXX
        self.w(u'<tr><th class="transparent"></th>')
        self.w(u'<th><a href="%s">&lt;&lt;</a></th><th colspan="5">%s %s %s</th>'
               u'<th><a href="%s">&gt;&gt;</a></th></tr>' %
               (xml_escape(prevlink), first_day_of_week.year,
                self._cw._(u'week'), first_day_of_week.isocalendar()[1],
                xml_escape(nextlink)))

        # output header
        self.w(u'<tr>')
        self.w(u'<th class="transparent"></th>') # column for hours
        _today = date.today()
        for i, day in enumerate(WEEKDAYS):
            wdate = first_day_of_week + timedelta(i)
            if wdate.isocalendar() == _today.isocalendar():
                self.w(u'<th class="today">%s<br/>%s</th>' % (self._cw._(day), self._cw.format_date(wdate)))
            else:
                self.w(u'<th>%s<br/>%s</th>' % (self._cw._(day), self._cw.format_date(wdate)))
        self.w(u'</tr>')

        # build week calendar
        self.w(u'<tr>')
        self.w(u'<td style="width:5em;">') # column for hours
        extra = ""
        for h in range(8, 20):
            self.w(u'<div class="hour" %s>'%extra)
            self.w(u'%02d:00'%h)
            self.w(u'</div>')
        self.w(u'</td>')

        for i, day in enumerate(WEEKDAYS):
            wdate = first_day_of_week + timedelta(i)
            classes = ""
            if wdate.isocalendar() == _today.isocalendar():
                classes = " today"
            self.w(u'<td class="column %s" id="%s">' % (classes, day))
            if len(self.cw_rset.column_types(0)) == 1:
                etype = list(self.cw_rset.column_types(0))[0]
                url = self._cw.build_url(vid='creation', etype=etype,
                                         schedule=True,
                                         __redirectrql=self.cw_rset.printable_rql(),
                                         __redirectparams=self._cw.build_url_params(year=year, week=week),
                                         __redirectvid=self.__regid__
                                         )
                extra = ' ondblclick="addCalendarItem(event, hmin=8, hmax=20, year=%s, month=%s, day=%s, duration=2, baseurl=\'%s\')"' % (
                    wdate.year, wdate.month, wdate.day, xml_escape(url))
            else:
                extra = ""
            self.w(u'<div class="columndiv"%s>'% extra)
            for h in range(8, 20):
                self.w(u'<div class="hourline" style="top:%sex;">'%((h-7)*8))
                self.w(u'</div>')
            if dates[i]:
                self._build_calendar_cell(wdate, dates[i])
            self.w(u'</div>')
            self.w(u'</td>')
        self.w(u'</tr>')
        self.w(u'</table></div>')
        self.w(u'<div id="coord"></div>')
        self.w(u'<div id="debug">&#160;</div>')

    def _build_calendar_cell(self, date, task_descrs):
        inday_tasks = [t for t in task_descrs if t.is_one_day_task() and  t.in_working_hours()]
        wholeday_tasks = [t for t in task_descrs if not t.is_one_day_task()]
        inday_tasks.sort(key=lambda t:t.start)
        sorted_tasks = []
        for i, t in enumerate(wholeday_tasks):
            t.index = i
        ncols = len(wholeday_tasks)
        while inday_tasks:
            t = inday_tasks.pop(0)
            for i, c in enumerate(sorted_tasks):
                if not c or c[-1].stop <= t.start:
                    c.append(t)
                    t.index = i+ncols
                    break
            else:
                t.index = len(sorted_tasks) + ncols
                sorted_tasks.append([t])
        ncols += len(sorted_tasks)
        if ncols == 0:
            return

        inday_tasks = []
        for tasklist in sorted_tasks:
            inday_tasks += tasklist
        width = 100.0/ncols
        for task_desc in wholeday_tasks + inday_tasks:
            task = task_desc.task
            start_hour = 8
            start_min = 0
            stop_hour = 20
            stop_min = 0
            if task_desc.start:
                if date < todate(task_desc.start) < date + ONEDAY:
                    start_hour = max(8, task_desc.start.hour)
                    start_min = task_desc.start.minute
            if task_desc.stop:
                if date < todate(task_desc.stop) < date + ONEDAY:
                    stop_hour = min(20, task_desc.stop.hour)
                    if stop_hour < 20:
                        stop_min = task_desc.stop.minute

            height = 100.0*(stop_hour+stop_min/60.0-start_hour-start_min/60.0)/(20-8)
            top = 100.0*(start_hour+start_min/60.0-8)/(20-8)
            left = width*task_desc.index
            style = "height: %s%%; width: %s%%; top: %s%%; left: %s%%; " % \
                (height, width, top, left)
            self.w(u'<div class="task %s" style="%s">' % \
                       (task_desc.color, style))
            task.view('calendaritem', dates=False, w=self.w)
            url = task.absolute_url(vid='edition',
                                    __redirectrql=self.cw_rset.printable_rql(),
                                    __redirectparams=self._cw.build_url_params(year=date.year, week=date.isocalendar()[1]),
                                    __redirectvid=self.__regid__
                                 )

            self.w(u'<div class="tooltip" ondblclick="stopPropagation(event); window.location.assign(\'%s\'); return false;">' % xml_escape(url))
            task.view('tooltip', w=self.w)
            self.w(u'</div>')
            if task_desc.start is None:
                self.w(u'<div class="bottommarker">')
                self.w(u'<div class="bottommarkerline" style="margin: 0px 3px 0px 3px; height: 1px;">')
                self.w(u'</div>')
                self.w(u'<div class="bottommarkerline" style="margin: 0px 2px 0px 2px; height: 1px;">')
                self.w(u'</div>')
                self.w(u'<div class="bottommarkerline" style="margin: 0px 1px 0px 1px; height: 3ex; color: white; font-size: x-small; vertical-align: center; text-align: center;">')
                self.w(u'end')
                self.w(u'</div>')
                self.w(u'</div>')
            self.w(u'</div>')


    def _prevnext_links(self, curdate):
        prevdate = curdate - timedelta(7)
        nextdate = curdate + timedelta(7)
        rql = self.cw_rset.printable_rql()
        prevlink = self._cw.build_ajax_replace_url('oneweekcalid', rql, 'oneweekcal',
                                                   year=prevdate.year,
                                                   week=prevdate.isocalendar()[1])
        nextlink = self._cw.build_ajax_replace_url('oneweekcalid', rql, 'oneweekcal',
                                                   year=nextdate.year,
                                                   week=nextdate.isocalendar()[1])
        return prevlink, nextlink