web/views/calendar.py
author Nicolas Chauvat <nicolas.chauvat@logilab.fr>
Fri, 16 Jul 2010 11:00:42 +0200
changeset 5979 561b6c2eb288
parent 5895 6a3f776292a5
child 6736 36ed2bf7ad3d
child 6864 ea95004494a2
permissions -rw-r--r--
[web] eid key is deprecated

# 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, warn=False) # 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.ajax_replace_url('onemonthcalid', rql=rql,
                                             vid='onemonthcal',
                                             year=prevdate.year,
                                             month=prevdate.month)
        nextlink = self._cw.ajax_replace_url('onemonthcalid', rql=rql,
                                             vid='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 = set()
        for row in xrange(self.cw_rset.rowcount):
            task = self.cw_rset.get_entity(row, 0)
            if task.eid in done_tasks:
                continue
            done_tasks.add(task.eid)
            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.ajax_replace_url('oneweekcalid', rql=rql,
                                             vid='oneweekcal',
                                             year=prevdate.year,
                                             week=prevdate.isocalendar()[1])
        nextlink = self._cw.ajax_replace_url('oneweekcalid', rql=rql,
                                             vid='oneweekcal',
                                             year=nextdate.year,
                                             week=nextdate.isocalendar()[1])
        return prevlink, nextlink