Author: gjm
Date: Tue Sep 11 16:54:46 2012
New Revision: 1383477
URL: http://svn.apache.org/viewvc?rev=1383477&view=rev
Log:
timeline filters API for dashboard - towards #94
Modified:
incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/tests/test_webui.py
incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/web_ui.py
incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/widgets/containers.py
incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/widgets/templates/widget_timeline.html
incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/widgets/timeline.py
Modified:
incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/tests/test_webui.py
URL:
http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/tests/test_webui.py?rev=1383477&r1=1383476&r2=1383477&view=diff
==============================================================================
---
incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/tests/test_webui.py
(original)
+++
incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/tests/test_webui.py
Tue Sep 11 16:54:46 2012
@@ -118,7 +118,12 @@ __test__ = {
""",
'Rendering templates' : r"""
>>> dbm = DashboardModule(env)
- >>> pprint(dbm.expand_widget_data(auth_req))
+ >>> from trac.mimeview.api import Context
+ >>> context = Context.from_request(auth_req)
+
+ #FIXME: This won't work. Missing schema
+
+ >>> pprint(dbm.expand_widget_data(context))
[{'content': <genshi.core.Stream object at ...>,
'title': <Element "a">}]
""",
Modified: incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/web_ui.py
URL:
http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/web_ui.py?rev=1383477&r1=1383476&r2=1383477&view=diff
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/web_ui.py
(original)
+++ incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/web_ui.py Tue
Sep 11 16:54:46 2012
@@ -96,9 +96,10 @@ class DashboardModule(Component):
add_ctxtnav(req, _('Custom Query'), req.href.query())
if self.env[ReportModule] is not None:
add_ctxtnav(req, _('Reports'), req.href.report())
- template, layout_data = self.expand_layout_data(req,
+ context = Context.from_request(req)
+ template, layout_data = self.expand_layout_data(context,
'bootstrap_grid', self.DASHBOARD_SCHEMA)
- widgets = self.expand_widget_data(req, layout_data)
+ widgets = self.expand_widget_data(context, layout_data)
return template, {
'context' : Context.from_request(req),
'layout' : layout_data,
@@ -221,15 +222,14 @@ class DashboardModule(Component):
}
# Public API
- def expand_layout_data(self, req, layout_name, schema, embed=False):
+ def expand_layout_data(self, context, layout_name, schema, embed=False):
"""Determine the template needed to render a specific layout
and the data needed to place the widgets at expected
location.
"""
layout = DashboardSystem(self.env).resolve_layout(layout_name)
- ctx = Context.from_request(req)
- template = layout.expand_layout(layout_name, ctx, {
+ template = layout.expand_layout(layout_name, context, {
'schema' : schema,
'embed' : embed
})['template']
@@ -262,7 +262,7 @@ class DashboardModule(Component):
{ 'title' : _('Widget error'), 'data' : data}, \
ctx
- def expand_widget_data(self, req, schema):
+ def expand_widget_data(self, context, schema):
"""Expand raw widget data and format it for use in template
"""
# TODO: Implement dynamic dashboard specification
@@ -272,10 +272,9 @@ class DashboardModule(Component):
for wnm in wp.get_widgets()
)
self.log.debug("Bloodhound: Widget index %s" % (widgets_index,))
- ctx = Context.from_request(req)
for w in widgets_spec.itervalues():
w['c'] = widgets_index.get(w['args'][0])
- w['args'][1] = ctx
+ w['args'][1] = context
self.log.debug("Bloodhound: Widget specs %s" % (widgets_spec,))
chrome = Chrome(self.env)
render = chrome.render_template
@@ -323,8 +322,8 @@ class DashboardChrome:
widgets = {}
schema['widgets'] = widgets
template, layout_data = dbmod.expand_layout_data(
- context.req, layout, schema, True)
- widgets = dbmod.expand_widget_data(context.req, layout_data)
+ context, layout, schema, True)
+ widgets = dbmod.expand_widget_data(context, layout_data)
return Chrome(self.env).render_template(context.req, template,
dict(context=context, layout=layout_data,
widgets=widgets, title='',
@@ -345,7 +344,7 @@ class DashboardChrome:
elif isinstance(argsdef, Stream):
options['args'] = parse_args_tag(argsdef)
return dbmod.expand_widget_data(
- context.req,
+ context,
{'widgets' : { 0 : widget }}
)[0]
Modified:
incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/widgets/containers.py
URL:
http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/widgets/containers.py?rev=1383477&r1=1383476&r2=1383477&view=diff
==============================================================================
---
incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/widgets/containers.py
(original)
+++
incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/widgets/containers.py
Tue Sep 11 16:54:46 2012
@@ -64,7 +64,6 @@ class ContainerWidget(WidgetBase):
"""Count ocurrences of values assigned to given ticket field.
"""
dbsys = DashboardSystem(self.env)
- req = context.req
params = ('layout', 'schema', 'show_captions', 'title')
layout, schema, show_captions, title = \
self.bind_params(name, options, *params)
@@ -72,7 +71,7 @@ class ContainerWidget(WidgetBase):
dbmod = DashboardModule(self.env)
layout_data = lp.expand_layout(layout, context,
{ 'schema' : schema, 'embed' : True })
- widgets = dbmod.expand_widget_data(req, schema)
+ widgets = dbmod.expand_widget_data(context, schema)
return layout_data['template'], \
{
Modified:
incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/widgets/templates/widget_timeline.html
URL:
http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/widgets/templates/widget_timeline.html?rev=1383477&r1=1383476&r2=1383477&view=diff
==============================================================================
---
incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/widgets/templates/widget_timeline.html
(original)
+++
incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/widgets/templates/widget_timeline.html
Tue Sep 11 16:54:46 2012
@@ -21,9 +21,11 @@
xmlns="http://www.w3.org/1999/xhtml"
xmlns:py="http://genshi.edgewall.org/"
xmlns:xi="http://www.w3.org/2001/XInclude"
- py:with="today = format_date(today); yesterday = format_date(yesterday)">
+ py:with="today = format_date(today); yesterday = format_date(yesterday)"
+ py:choose="">
- <table py:for="day, events in groupby(events, key=lambda e:
format_date(e.date))"
+ <table py:when="events"
+ py:for="day, events in groupby(events, key=lambda e:
format_date(e.date))"
class="table" id="activityfeed">
<thead>
<tr>
@@ -47,4 +49,15 @@
</tr>
</tbody>
</table>
-</div>
\ No newline at end of file
+ <py:otherwise>
+ <py:def function="timeline_empty()">
+ No events reported for <em>${summary_of(context.resource)}</em> in the
+ last <em>$daysback</em> days since
+ <span class="date">${format_date(fromdate)}</span>.
+ This may happen if system is not configured correctly.
+ Please contact your administrator if you think this is the case.
+ </py:def>
+ <xi:include href="widget_alert.html"
+ py:with="msglabel = 'Warning'; msgbody = timeline_empty()" />
+ </py:otherwise>
+</div>
Modified:
incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/widgets/timeline.py
URL:
http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/widgets/timeline.py?rev=1383477&r1=1383476&r2=1383477&view=diff
==============================================================================
---
incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/widgets/timeline.py
(original)
+++
incubator/bloodhound/trunk/bloodhound_dashboard/bhdashboard/widgets/timeline.py
Tue Sep 11 16:54:46 2012
@@ -26,10 +26,14 @@ 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 implements, TracError
+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.util.translation import _
from trac.web.chrome import add_stylesheet
@@ -40,12 +44,54 @@ from bhdashboard.util import WidgetBase,
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.
@@ -74,6 +120,15 @@ class TimelineWidget(WidgetBase):
'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""",
+ },
+ 'version' : {
+ 'desc' : """Resource version. Used to filter events""",
+ },
}
get_widget_params = pretty_wrapper(get_widget_params, check_widget_name)
@@ -83,9 +138,13 @@ class TimelineWidget(WidgetBase):
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')
- start, days, user, precision, filters, count = \
+ '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
@@ -101,14 +160,27 @@ class TimelineWidget(WidgetBase):
if start is not None:
fakereq.args['from'] = start.strftime('%x %X')
- timemdl = self.env[TimelineModule]
- if timemdl is None :
- raise TracError('Timeline module not available (disabled?)')
-
- data = timemdl.process_request(fakereq)[1]
+ 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', 'TracReports')
+ exc.title = data.get('title', 'Activity')
raise
else:
merge_links(srcreq=fakereq, dstreq=req,
@@ -116,6 +188,7 @@ class TimelineWidget(WidgetBase):
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'),
@@ -127,3 +200,106 @@ class TimelineWidget(WidgetBase):
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
+