Source

bloodhound-dashboard / bhdashboard / widgets / timeline.py

Full commit
#!/usr/bin/env python
# -*- coding: UTF-8 -*-

#  Licensed to the Apache Software Foundation (ASF) under one
#  or more contributor license agreements.  See the NOTICE file
#  distributed with this work for additional information
#  regarding copyright ownership.  The ASF licenses this file
#  to you under the Apache License, Version 2.0 (the
#  "License"); you may not use this file except in compliance
#  with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing,
#  software distributed under the License is distributed on an
#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
#  KIND, either express or implied.  See the License for the
#  specific language governing permissions and limitations
#  under the License.


r"""Project dashboard for Apache(TM) Bloodhound

Widgets displaying timeline data.
"""

from datetime import datetime, date, time, timedelta
from itertools import imap, islice
from types import MethodType

from genshi.builder import tag
from trac.core import Component, ExtensionPoint, implements, Interface, \
        TracError
from trac.config import IntOption
from trac.mimeview.api import RenderingContext
from trac.resource import Resource, resource_exists
from trac.timeline.web_ui import TimelineModule
from trac.ticket.api import TicketSystem
from trac.ticket.model import Ticket
from trac.util.translation import _
from trac.web.chrome import add_stylesheet

from bhdashboard.api import DateField, EnumField, ListField
from bhdashboard.util import WidgetBase, InvalidIdentifier, \
                              check_widget_name, dummy_request, \
                              merge_links, pretty_wrapper, trac_version, \
                              trac_tags

__metaclass__ = type

class ITimelineEventsFilter(Interface):
    """Filter timeline events displayed in a rendering context
    """
    def supported_providers():
        """List supported timeline providers. Filtering process will take 
        place only for the events contributed by listed providers.
        Return `None` and all events contributed by all timeline providers 
        will be processed.
        """
    def filter_event(context, provider, event, filters):
        """Decide whether a timeline event is relevant in a rendering context.

        :param context: rendering context, used to determine events scope
        :param provider: provider contributing event
        :param event: target event
        :param filters: active timeline filters
        :return: the event resulting from the filtering process or 
                  `None` if it has to be removed from the event stream or
                  `NotImplemented` if the filter doesn't care about it.
        """

class TimelineWidget(WidgetBase):
    """Display activity feed.
    """
    default_count = IntOption('widget_activity', 'limit', 25, 
                        """Maximum number of items displayed by default""")

    event_filters = ExtensionPoint(ITimelineEventsFilter)

    _filters_map = None

    @property
    def filters_map(self):
        """Quick access to timeline events filters to be applied for a 
        given timeline provider.
        """
        if self._filters_map is None:
            self._filters_map = {}
            for _filter in self.event_filters:
                providers = _filter.supported_providers()
                if providers is None:
                    providers = [None]
                for p in providers:
                    self._filters_map.setdefault(p, []).append(_filter)
        return self._filters_map

    def get_widget_params(self, name):
        """Return a dictionary containing arguments specification for
        the widget with specified name.
        """
        return {
                'from' : {
                        'desc' : """Display events before this date""",
                        'type' : DateField(), # TODO: Custom datetime format
                    },
                'daysback' : {
                        'desc' : """Event time window""",
                        'type' : int, 
                    },
                'precision' : {
                        'desc' : """Time precision""",
                        'type' : EnumField('second', 'minute', 'hour')
                    },
                'doneby' : {
                        'desc' : """Filter events related to user""",
                    },
                'filters' : {
                        'desc' : """Event filters""",
                        'type' : ListField()
                    },
                'max' : {
                        'desc' : """Limit the number of events displayed""",
                        'type' : int
                    },
                'realm' : {
                        'desc' : """Resource realm. Used to filter events""",
                    },
                'id' : {
                        'desc' : """Resource ID. Used to filter events""",
                    },
            }
    get_widget_params = pretty_wrapper(get_widget_params, check_widget_name)

    def render_widget(self, name, context, options):
        """Gather timeline events and render data in compact view
        """
        data = None
        req = context.req
        try:
            timemdl = self.env[TimelineModule]
            if timemdl is None :
                raise TracError('Timeline module not available (disabled?)')

            params = ('from', 'daysback', 'doneby', 'precision', 'filters', \
                        'max', 'realm', 'id')
            start, days, user, precision, filters, count, realm, rid = \
                    self.bind_params(name, options, *params)
            if count is None:
                count = self.default_count

            fakereq = dummy_request(self.env, req.authname)
            fakereq.args = {
                    'author' : user or '',
                    'daysback' : days or '',
                    'max' : count,
                    'precision' : precision,
                    'user' : user
                }
            if start is not None:
                fakereq.args['from'] = start.strftime('%x %X')

            if (realm, rid) != (None, None):
                # Override rendering context
                resource = Resource(realm, rid)
                if resource_exists(self.env, resource) or \
                        realm == rid == '':
                    context = RenderingContext(resource)
                    context.req = req
                else:
                    self.log.warning("TimelineWidget: Resource %s not found",
                            resource)
            # FIXME: Filter also if existence check is not conclusive ?
            if resource_exists(self.env, context.resource):
                module = FilteredTimeline(self.env, context)
                self.log.debug('Filtering timeline events for %s', \
                        context.resource)
            else:
                module = timemdl
            data = module.process_request(fakereq)[1]
        except TracError, exc:
            if data is not None:
                exc.title = data.get('title', 'Activity')
            raise
        else:
            merge_links(srcreq=fakereq, dstreq=req,
                        exclude=["stylesheet", "alternate"])
            add_stylesheet(req, 'dashboard/css/timeline.css')
            data['today'] = today = datetime.now(req.tz)
            data['yesterday'] = today - timedelta(days=1)
            data['context'] = context
            return 'widget_timeline.html', \
                    {
                        'title' : _('Activity'),
                        'data' : data, 
                        'altlinks' : fakereq.chrome.get('links',
                                                        {}).get('alternate')
                    }, \
                    context

    render_widget = pretty_wrapper(render_widget, check_widget_name)

class FilteredTimeline:
    """This is a class (not a component ;) aimed at overriding some parts of
    TimelineModule without patching it in order to inject code needed to filter
    timeline events according to rendering context. It acts as a wrapper on top
    of TimelineModule.
    """
    def __init__(self, env, context, keep_mismatched=False):
        """Initialization

        :param env: Environment object
        :param context: Rendering context
        """
        self.env = env
        self.context = context
        self.keep_mismatched = keep_mismatched

    # Access to TimelineModule's members

    process_request = TimelineModule.__dict__['process_request']
    _provider_failure = TimelineModule.__dict__['_provider_failure']
    _event_data = TimelineModule.__dict__['_event_data']

    @property
    def event_providers(self):
        """Introduce wrappers around timeline event providers in order to
        filter event streams.
        """
        for p in TimelineModule(self.env).event_providers:
            yield TimelineFilterAdapter(p, self.context, self.keep_mismatched)

    def __getattr__(self, attrnm):
        """Forward attribute access request to TimelineModule
        """
        try:
            value = getattr(TimelineModule(self.env), attrnm)
            if isinstance(value, MethodType):
                raise AttributeError()
        except AttributeError:
            raise AttributeError("'%s' object has no attribute '%s'" % \
                    (self.__class__.__name__, attrnm))
        else:
            return value

class TimelineFilterAdapter:
    """Wrapper class used to filter timeline event streams transparently.
    Therefore it is compatible with `ITimelineEventProvider` interface 
    and reuses the implementation provided by real provider.
    """
    def __init__(self, provider, context, keep_mismatched=False):
        """Initialize wrapper object by providing real timeline events provider.
        """
        self.provider = provider
        self.context = context
        self.keep_mismatched = keep_mismatched

    # ITimelineEventProvider methods

    #def get_timeline_filters(self, req):
    #def render_timeline_event(self, context, field, event):

    def get_timeline_events(self, req, start, stop, filters):
        """Filter timeline events according to context.
        """
        filters_map = TimelineWidget(self.env).filters_map
        evfilters = filters_map.get(self.provider.__class__.__name__, []) + \
                filters_map.get(None, [])
        self.log.debug('Applying filters %s for %s against %s', evfilters, 
                self.context.resource, self.provider)
        if evfilters:
            for event in self.provider.get_timeline_events(
                    req, start, stop, filters):
                match = False
                for f in evfilters:
                    new_event = f.filter_event(self.context, self.provider,
                            event, filters)
                    if new_event is None:
                        event = None
                        match = True
                        break
                    elif new_event is NotImplemented:
                        pass
                    else:
                        event = new_event
                        match = True
                if event is not None and (match or self.keep_mismatched):
                    yield event
        else:
            if self.keep_mismatched:
                for event in self.provider.get_timeline_events(
                        req, start, stop, filters):
                    yield event

    def __getattr__(self, attrnm):
        """Forward attribute access request to real provider
        """
        try:
            value = getattr(self.provider, attrnm)
        except AttributeError:
            raise AttributeError("'%s' object has no attribute '%s'" % \
                    (self.__class__.__name__, attrnm))
        else:
            return value

class TicketFieldTimelineFilter(Component):
    """A class filtering ticket events related to a given resource
    associated via ticket fields.
    """
    implements(ITimelineEventsFilter)

    @property
    def fields(self):
        """Available ticket fields
        """
        field_names = getattr(self, '_fields', None)
        if field_names is None:
            self._fields = set(f['name'] \
                    for f in TicketSystem(self.env).get_ticket_fields())
        return self._fields

    # ITimelineEventsFilter methods

    def supported_providers(self):
        """This filter will work on ticket events. It also intercepts events
        even when multi-product ticket module is installed.
        """
        yield 'TicketModule'
        yield 'ProductTicketModule'

    def filter_event(self, context, provider, event, filters):
        """Decide whether the target of a ticket event has a particular custom
        field set to the context resource's identifier.
        """
        if context.resource is not None:
            field_name = context.resource.realm
            if field_name in self.fields:
                try:
                    ticket_ids = event[3][0]
                except:
                    self.log.exception('Unknown ticket event %s ... [SKIP]',
                            event)
                else:
                    if not isinstance(ticket_ids, list):
                        ticket_ids = [ticket_ids]
                context._ticket_cache = ticket_cache = \
                        getattr(context, '_ticket_cache', None) or {}
                for t in ticket_ids:
                    if isinstance(t, Resource):
                        t = t.id
                    if isinstance(t, (int, basestring)):
                        t = ticket_cache.get(t) or Ticket(self.env, t)
                    if t[field_name] == context.resource.id:
                        return event
                    ticket_cache[t.id] = t
                else:
                    return None
        return NotImplemented