# -*- coding: utf-8 -*- # # Copyright (C) 2003-2009 Edgewall Software # Copyright (C) 2003-2005 Jonas Borgström # Copyright (C) 2004-2005 Christopher Lenz # Copyright (C) 2005-2006 Christian Boos # All rights reserved. # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. The terms # are also available at http://trac.edgewall.org/wiki/TracLicense. # # This software consists of voluntary contributions made by many # individuals. For the exact contribution history, see the revision # history and logs, available at http://trac.edgewall.org/log/. # # Author: Jonas Borgström # Christopher Lenz from datetime import datetime, timedelta import pkg_resources import re import time from urlparse import urlparse from genshi.builder import tag from trac.config import IntOption, BoolOption from trac.core import * from trac.mimeview import Context from trac.perm import IPermissionRequestor from trac.timeline.api import ITimelineEventProvider from trac.util.compat import sorted from trac.util.datefmt import format_date, format_datetime, parse_date, \ to_timestamp, utc, pretty_timedelta from trac.util.text import exception_to_unicode, to_unicode from trac.util.translation import _ from trac.web import IRequestHandler, IRequestFilter from trac.web.chrome import add_link, add_stylesheet, prevnext_nav, Chrome, \ INavigationContributor, ITemplateProvider from trac.wiki.api import IWikiSyntaxProvider class TimelineModule(Component): implements(INavigationContributor, IPermissionRequestor, IRequestHandler, IRequestFilter, ITemplateProvider, IWikiSyntaxProvider) event_providers = ExtensionPoint(ITimelineEventProvider) default_daysback = IntOption('timeline', 'default_daysback', 30, """Default number of days displayed in the Timeline, in days. (''since 0.9.'')""") max_daysback = IntOption('timeline', 'max_daysback', 90, """Maximum number of days (-1 for unlimited) displayable in the Timeline. (''since 0.11'')""") abbreviated_messages = BoolOption('timeline', 'abbreviated_messages', True, """Whether wiki-formatted event messages should be truncated or not. This only affects the default rendering, and can be overriden by specific event providers, see their own documentation. (''Since 0.11'')""") # INavigationContributor methods def get_active_navigation_item(self, req): return 'timeline' def get_navigation_items(self, req): if 'TIMELINE_VIEW' in req.perm: yield ('mainnav', 'timeline', tag.a(_('Timeline'), href=req.href.timeline(), accesskey=2)) # IPermissionRequestor methods def get_permission_actions(self): return ['TIMELINE_VIEW'] # IRequestHandler methods def match_request(self, req): return req.path_info == '/timeline' def process_request(self, req): req.perm.assert_permission('TIMELINE_VIEW') format = req.args.get('format') maxrows = int(req.args.get('max', 0)) # Parse the from date and adjust the timestamp to the last second of # the day fromdate = today = datetime.now(req.tz) precisedate = precision = None if 'from' in req.args: precisedate = parse_date(req.args.get('from'), req.tz) fromdate = precisedate precision = req.args.get('precision', '') if precision.startswith('second'): precision = timedelta(seconds=1) elif precision.startswith('minute'): precision = timedelta(minutes=1) elif precision.startswith('hour'): precision = timedelta(hours=1) else: precision = None fromdate = fromdate.replace(hour=23, minute=59, second=59) try: daysback = int(req.args.get('daysback', '')) except ValueError: try: daysback = int(req.session.get('timeline.daysback', '')) except ValueError: daysback = self.default_daysback daysback = max(0, daysback) if self.max_daysback >= 0: daysback = min(self.max_daysback, daysback) data = {'fromdate': fromdate, 'daysback': daysback, 'today': format_date(today), 'yesterday': format_date(today - timedelta(days=1)), 'precisedate': precisedate, 'precision': precision, 'events': [], 'filters': [], 'abbreviated_messages': self.abbreviated_messages} available_filters = [] for event_provider in self.event_providers: available_filters += event_provider.get_timeline_filters(req) filters = [] # check the request or session for enabled filters, or use default for test in (lambda f: f[0] in req.args, lambda f: req.session.get('timeline.filter.%s' % f[0], '') == '1', lambda f: len(f) == 2 or f[2]): if filters: break filters = [f[0] for f in available_filters if test(f)] # save the results of submitting the timeline form to the session if 'update' in req.args: for filter in available_filters: key = 'timeline.filter.%s' % filter[0] if filter[0] in req.args: req.session[key] = '1' elif key in req.session: del req.session[key] stop = fromdate start = stop - timedelta(days=daysback + 1) # gather all events for the given period of time events = [] for provider in self.event_providers: try: for event in provider.get_timeline_events(req, start, stop, filters): events.append(self._event_data(provider, event)) except Exception, e: # cope with a failure of that provider self._provider_failure(e, req, provider, filters, [f[0] for f in available_filters]) # prepare sorted global list events = sorted(events, key=lambda e: e['date'], reverse=True) if maxrows: events = events[:maxrows] data['events'] = events if format == 'rss': # Get the email addresses of all known users email_map = {} if Chrome(self.env).show_email_addresses: for username, name, email in self.env.get_known_users(): if email: email_map[username] = email data['email_map'] = email_map rss_context = Context.from_request(req, absurls=True) rss_context.set_hints(wiki_flavor='html', shorten_lines=False) data['context'] = rss_context return 'timeline.rss', data, 'application/rss+xml' else: req.session['timeline.daysback'] = daysback html_context = Context.from_request(req) html_context.set_hints(wiki_flavor='oneliner', shorten_lines=self.abbreviated_messages) data['context'] = html_context add_stylesheet(req, 'common/css/timeline.css') rss_href = req.href.timeline([(f, 'on') for f in filters], daysback=90, max=50, format='rss') add_link(req, 'alternate', rss_href, _('RSS Feed'), 'application/rss+xml', 'rss') for filter_ in available_filters: data['filters'].append({'name': filter_[0], 'label': filter_[1], 'enabled': filter_[0] in filters}) # Navigation to the previous/next period of 'daysback' days previous_start = format_date(fromdate - timedelta(days=daysback+1), format='%Y-%m-%d', tzinfo=req.tz) add_link(req, 'prev', req.href.timeline(from_=previous_start, daysback=daysback), _('Previous period')) if today - fromdate > timedelta(days=0): next_start = format_date(fromdate + timedelta(days=daysback+1), format='%Y-%m-%d', tzinfo=req.tz) add_link(req, 'next', req.href.timeline(from_=next_start, daysback=daysback), _('Next period')) prevnext_nav(req, 'Period') return 'timeline.html', data, None # ITemplateProvider methods def get_htdocs_dirs(self): return [] def get_templates_dirs(self): return [pkg_resources.resource_filename('trac.timeline', 'templates')] # IRequestFilter methods def pre_process_request(self, req, handler): return handler def post_process_request(self, req, template, data, content_type): if data: def timeline_link(date): return self.get_timeline_link(req, date, format_date(date), precision='second') def dateinfo(date): return self.get_timeline_link(req, date, pretty_timedelta(date), precision='second') def dateinfo_delta(date,reference_date): return self.get_timeline_link(req, date, pretty_timedelta(date,reference_date), precision='second') data['timeline_link'] = timeline_link data['dateinfo'] = dateinfo data['dateinfo_delta'] = dateinfo_delta return template, data, content_type # IWikiSyntaxProvider methods def get_wiki_syntax(self): return [] def get_link_resolvers(self): def link_resolver(formatter, ns, target, label): precision = None time = target.split("T", 1) if len(time) > 1: time = time[1].split("Z")[0] if len(time) >= 6: precision = 'seconds' elif len(time) >= 4: precision = 'minutes' elif len(time) >= 2: precision = 'hours' try: return self.get_timeline_link(formatter.req, parse_date(target, utc), label, precision) except TracError, e: return tag.a(label, title=to_unicode(e.message), class_='timeline missing') yield ('timeline', link_resolver) # Public methods def get_timeline_link(self, req, date, label=None, precision='hours'): iso_date = display_date = format_datetime(date, 'iso8601', req.tz) fmt = req.session.get('datefmt') if fmt and fmt != 'iso8601': display_date = format_datetime(date, fmt, req.tz) return tag.a(label or iso_date, class_='timeline', title=_("%(date)s in Timeline", date=display_date), href=req.href.timeline(from_=iso_date, precision=precision)) # Internal methods def _event_data(self, provider, event): """Compose the timeline event date from the event tuple and prepared provider methods""" if len(event) == 6: # 0.10 events kind, url, title, date, author, markup = event data = {'url': url, 'title': title, 'description': markup} render = lambda field, context: data.get(field) else: # 0.11 events if len(event) == 5: # with special provider kind, date, author, data, provider = event else: kind, date, author, data = event render = lambda field, context: \ provider.render_timeline_event(context, field, event) if isinstance(date, datetime): dateuid = to_timestamp(date) else: dateuid = date date = datetime.fromtimestamp(date, utc) return {'kind': kind, 'author': author, 'date': date, 'dateuid': dateuid, 'render': render, 'event': event, 'data': data, 'provider': provider} def _provider_failure(self, exc, req, ep, current_filters, all_filters): """Raise a TracError exception explaining the failure of a provider. At the same time, the message will contain a link to the timeline without the filters corresponding to the guilty event provider `ep`. """ ep_name, exc_name = [i.__class__.__name__ for i in (ep, exc)] self.log.error('Timeline event provider failed: %s', exception_to_unicode(exc, traceback=True)) guilty_filters = [f[0] for f in ep.get_timeline_filters(req)] guilty_kinds = [f[1] for f in ep.get_timeline_filters(req)] other_filters = [f for f in current_filters if not f in guilty_filters] if not other_filters: other_filters = [f for f in all_filters if not f in guilty_filters] args = [(a, req.args.get(a)) for a in ('from', 'format', 'max', 'daysback')] href = req.href.timeline(args+[(f, 'on') for f in other_filters]) raise TracError(tag( tag.p(', '.join(guilty_kinds), ' event provider (', tag.tt(ep_name), ') failed:', tag.br(), exc_name, ': ', to_unicode(exc), class_='message'), tag.p('You may want to see the other kind of events from the ', tag.a('Timeline', href=href))))