web/views/calendar.py
author Sylvain Thénault <sylvain.thenault@logilab.fr>
Wed, 22 Jul 2009 18:35:08 +0200
changeset 2422 96da7dc42eb5
parent 2312 af4d8f75c5db
child 2996 866a2c135c33
permissions -rw-r--r--
quick and dirty support from simple sparql queries + base ui

"""html calendar views

:organization: Logilab
:copyright: 2001-2009 LOGILAB S.A. (Paris, FRANCE), license is LGPL v2.
:contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
:license: GNU Lesser General Public License, v2.1 - http://www.gnu.org/licenses
"""
__docformat__ = "restructuredtext en"
_ = unicode

from datetime import datetime, date, timedelta

from logilab.mtconverter import xml_escape

from cubicweb.interfaces import ICalendarable
from cubicweb.selectors import implements
from cubicweb.utils import strptime, date_range, todate, todatetime
from cubicweb.view import EntityView


# 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__ = implements(ICalendarable)
        need_navigation = False
        content_type = 'text/calendar'
        title = _('iCalendar')
        templatable = False
        id = 'ical'

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

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

except ImportError:
    pass

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

    Does apply to ICalendarable compatible entities
    """
    id = 'hcal'
    __select__ = implements(ICalendarable)
    need_navigation = False
    title = _('hCalendar')
    #templatable = False

    def call(self):
        self.w(u'<div class="hcalendar">')
        for i in range(len(self.rset.rows)):
            task = self.complete_entity(i)
            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'))
            if task.start:
                self.w(u'<abbr class="dtstart" title="%s">%s</abbr>' % (task.start.isoformat(), self.format_date(task.start)))
            if task.stop:
                self.w(u'<abbr class="dtstop" title="%s">%s</abbr>' % (task.stop.isoformat(), self.format_date(task.stop)))
            self.w(u'</div>')
        self.w(u'</div>')


class CalendarItemView(EntityView):
    id = 'calendaritem'

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

class CalendarLargeItemView(CalendarItemView):
    id = 'calendarlargeitem'


class _TaskEntry(object):
    def __init__(self, task, color, index=0):
        self.task = task
        self.color = color
        self.index = index
        self.length = 1

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

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


class OneMonthCal(EntityView):
    """At some point, this view will probably replace ampm calendars"""
    id = 'onemonthcal'
    __select__ = implements(ICalendarable)
    need_navigation = False
    title = _('one month')

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

        if 'year' in self.req.form:
            year = int(self.req.form['year'])
        else:
            year = _today.year
        if 'month' in self.req.form:
            month = int(self.req.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)
        lastday = last_day_of_month + timedelta(6 - last_day_of_month.weekday())
        month_dates = list(date_range(firstday, lastday))
        dates = {}
        task_max = 0
        for row in xrange(self.rset.rowcount):
            task = self.rset.get_entity(row, 0)
            if len(self.rset[row]) > 1 and self.rset.description[row][1] == 'CWUser':
                user = self.rset.get_entity(row, 1)
            else:
                user = None
            the_dates = []
            tstart = task.start
            if tstart:
                tstart = todate(task.start)
                if tstart > lastday:
                    continue
                the_dates = [tstart]
            tstop = task.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, 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.req._(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.req._(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.rset.printable_rql()
        prevlink = self.req.build_ajax_replace_url('onemonthcalid', rql, 'onemonthcal',
                                                   year=prevdate.year,
                                                   month=prevdate.month)
        nextlink = self.req.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.rset.column_types(0)) == 1:
            etype = list(self.rset.column_types(0))[0]
            url = self.build_url(vid='creation', etype=etype,
                                 schedule=True,
                                 start=self.format_date(celldate), stop=self.format_date(celldate),
                                 __redirectrql=self.rset.printable_rql(),
                                 __redirectparams=self.req.build_url_params(year=curdate.year, month=curmonth),
                                 __redirectvid=self.id
                                 )
            self.w(u'<div class="cmd"><a href="%s">%s</a></div>' % (xml_escape(url), self.req._(u'add')))
            self.w(u'&nbsp;')
        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.rset.printable_rql(),
                                        __redirectparams=self.req.build_url_params(year=curdate.year, month=curmonth),
                                        __redirectvid=self.id
                                        )

                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"&nbsp;")
            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"""
    id = 'oneweekcal'
    __select__ = implements(ICalendarable)
    need_navigation = False
    title = _('one week')

    def call(self):
        self.req.add_js( ('cubicweb.ajax.js', 'cubicweb.calendar.js') )
        self.req.add_css('cubicweb.calendar.css')
        # XXX: restrict directly with RQL
        _today =  datetime.today()
        if 'year' in self.req.form:
            year = int(self.req.form['year'])
        else:
            year = _today.year
        if 'week' in self.req.form:
            week = int(self.req.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.rset.rowcount):
            task = self.rset.get_entity(row, 0)
            if task in done_tasks:
                continue
            done_tasks.append(task)
            the_dates = []
            tstart = task.start
            tstop = task.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, 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.req._(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.req._(day), self.format_date(wdate)))
            else:
                self.w(u'<th>%s<br/>%s</th>' % (self.req._(day), self.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.rset.column_types(0)) == 1:
                etype = list(self.rset.column_types(0))[0]
                url = self.build_url(vid='creation', etype=etype,
                                     schedule=True,
                                     __redirectrql=self.rset.printable_rql(),
                                     __redirectparams=self.req.build_url_params(year=year, week=week),
                                     __redirectvid=self.id
                                     )
                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">&nbsp;</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.task.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].task.stop <= t.task.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.start:
                if date < todate(task.start) < date + ONEDAY:
                    start_hour = max(8, task.start.hour)
                    start_min = task.start.minute
            if task.stop:
                if date < todate(task.stop) < date + ONEDAY:
                    stop_hour = min(20, task.stop.hour)
                    if stop_hour < 20:
                        stop_min = task.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.rset.printable_rql(),
                                    __redirectparams=self.req.build_url_params(year=date.year, week=date.isocalendar()[1]),
                                    __redirectvid=self.id
                                 )

            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.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.rset.printable_rql()
        prevlink = self.req.build_ajax_replace_url('oneweekcalid', rql, 'oneweekcal',
                                                   year=prevdate.year,
                                                   week=prevdate.isocalendar()[1])
        nextlink = self.req.build_ajax_replace_url('oneweekcalid', rql, 'oneweekcal',
                                                   year=nextdate.year,
                                                   week=nextdate.isocalendar()[1])
        return prevlink, nextlink