Author: matevz Date: Tue Mar 19 17:22:51 2013 New Revision: 1458416 URL: http://svn.apache.org/r1458416 Log: #325 - Multiproduct UI: Dashboard (ProductQuery added for ticket lists)
Modified: incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/web_ui.py incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/widgets/query.py incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/widgets/templates/widget_grid.html incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/widgets/ticket.py incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/ticket/query.py incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_theme/bhtheme/theme.py Modified: incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/web_ui.py URL: http://svn.apache.org/viewvc/incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/web_ui.py?rev=1458416&r1=1458415&r2=1458416&view=diff ============================================================================== --- incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/web_ui.py (original) +++ incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/web_ui.py Tue Mar 19 17:22:51 2013 @@ -28,7 +28,7 @@ __metaclass__ = type from itertools import izip import pkg_resources -import re +import re, copy from uuid import uuid4 from genshi.builder import tag @@ -104,7 +104,10 @@ class DashboardModule(Component): add_ctxtnav(req, _('Reports'), req.href.report()) context = Context.from_request(req) template, layout_data = self.expand_layout_data(context, - 'bootstrap_grid', self.DASHBOARD_SCHEMA) + 'bootstrap_grid', + self.DASHBOARD_SCHEMA if isinstance(self.env, ProductEnvironment) + else self.DASHBOARD_GLOBAL_SCHEMA + ) widgets = self.expand_widget_data(context, layout_data) return template, { 'context' : Context.from_request(req), @@ -230,6 +233,17 @@ class DashboardModule(Component): } } + # global dashboard: add milestone column, group by product + DASHBOARD_GLOBAL_SCHEMA = copy.deepcopy(DASHBOARD_SCHEMA) + DASHBOARD_GLOBAL_SCHEMA['widgets']['active tickets']['args'][2]['args']['query'] = ( + 'status=!closed&group=product&col=id&col=summary&col=owner&col=status&' + 'col=priority&order=priority&col=milestone' + ) + DASHBOARD_GLOBAL_SCHEMA['widgets']['my tickets']['args'][2]['args']['query'] = ( + 'status=!closed&group=product&col=id&col=summary&col=owner&col=status&' + 'col=priority&order=priority&col=milestone&owner=$USER&' + ) + # Public API def expand_layout_data(self, context, layout_name, schema, embed=False): """Determine the template needed to render a specific layout Modified: incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/widgets/query.py URL: http://svn.apache.org/viewvc/incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/widgets/query.py?rev=1458416&r1=1458415&r2=1458416&view=diff ============================================================================== --- incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/widgets/query.py (original) +++ incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/widgets/query.py Tue Mar 19 17:22:51 2013 @@ -41,6 +41,9 @@ from bhdashboard.util import WidgetBase, merge_links, pretty_wrapper, trac_version, \ trac_tags +from multiproduct.env import ProductEnvironment +from multiproduct.ticket.query import ProductQueryModule + class TicketQueryWidget(WidgetBase): """Display tickets matching a TracQuery using a grid """ @@ -91,7 +94,9 @@ class TicketQueryWidget(WidgetBase): more_link_href = req.href('query', args) args.update({'page' : page, 'max': maxrows}) - qrymdl = self.env[QueryModule] + qrymdl = self.env[QueryModule + if isinstance(self.env, ProductEnvironment) + else ProductQueryModule] if qrymdl is None : raise TracError('Query module not available (disabled?)') data = qrymdl.process_request(fakereq)[1] @@ -121,7 +126,8 @@ class TicketQueryWidget(WidgetBase): } \ for hidx, h in enumerate(headers)]], 'id' : t['id'], - 'resource' : Resource('ticket', t['id']) + 'resource' : Resource('ticket', t['id']), + 'href': t['href'] } for t in tickets]) \ for group_value, tickets in data['groups'] ])) return 'widget_grid.html', \ Modified: incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/widgets/templates/widget_grid.html URL: http://svn.apache.org/viewvc/incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/widgets/templates/widget_grid.html?rev=1458416&r1=1458415&r2=1458416&view=diff ============================================================================== --- incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/widgets/templates/widget_grid.html (original) +++ incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/widgets/templates/widget_grid.html Tue Mar 19 17:22:51 2013 @@ -92,13 +92,13 @@ <!--! for the ticket listing --> <py:when test="col in ('ticket', 'id')"> <td class="ticket" py:attrs="td_attrs"> - <a title="View ${row.resource.realm}" href="${url_of(row.resource)}">#$cell.value</a> + <a title="View ${row.resource.realm}" href="$row.href">#$cell.value</a> </td> </py:when> <py:when test="col == 'summary' and row.id"> <td class="$col" py:attrs="td_attrs"> - <a title="View ${row.resource.realm}" href="${url_of(row.resource)}">$cell.value</a> + <a title="View ${row.resource.realm}" href="${row.href if row.href else url_of(row.resource)}">$cell.value</a> </td> </py:when> Modified: incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/widgets/ticket.py URL: http://svn.apache.org/viewvc/incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/widgets/ticket.py?rev=1458416&r1=1458415&r2=1458416&view=diff ============================================================================== --- incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/widgets/ticket.py (original) +++ incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/widgets/ticket.py Tue Mar 19 17:22:51 2013 @@ -45,6 +45,8 @@ from bhdashboard.util import WidgetBase, pretty_wrapper, resolve_ep_class, \ trac_version, trac_tags +from multiproduct.env import Product, ProductEnvironment + class TicketFieldValuesWidget(WidgetBase): """Display a tag cloud representing frequency of values assigned to ticket fields. @@ -179,13 +181,14 @@ class TicketFieldValuesWidget(WidgetBase "GROUP BY COALESCE(%(name)s, '')" sql = sql % field # TODO : Implement threshold and max - db = self.env.get_db_cnx() - try : + + db_query = req.perm.env.db_query \ + if isinstance(req.perm.env, ProductEnvironment) \ + else req.perm.env.db_direct_query + with db_query as db: cursor = db.cursor() cursor.execute(sql) items = cursor.fetchall() - finally: - cursor.close() QUERY_COLS = ['id', 'summary', 'owner', 'type', 'status', 'priority'] item_link= lambda item: req.href.query(col=QUERY_COLS + [fieldnm], Modified: incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/ticket/query.py URL: http://svn.apache.org/viewvc/incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/ticket/query.py?rev=1458416&r1=1458415&r2=1458416&view=diff ============================================================================== --- incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/ticket/query.py (original) +++ incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/ticket/query.py Tue Mar 19 17:22:51 2013 @@ -22,19 +22,27 @@ from __future__ import with_statement from itertools import groupby from math import ceil +from datetime import datetime, timedelta +import re from genshi.builder import tag from trac.db import get_column_names -from trac.ticket.query import Query, TicketQueryMacro +from trac.mimeview.api import Mimeview +from trac.ticket.api import TicketSystem +from trac.ticket.query import Query, QueryModule, TicketQueryMacro from trac.util import Ranges, as_bool -from trac.util.datefmt import from_utimestamp +from trac.util.datefmt import from_utimestamp, utc, to_timestamp from trac.util.text import shorten_line from trac.util.translation import _, tag_ -from trac.web.chrome import Chrome, add_stylesheet +from trac.web.chrome import Chrome, add_stylesheet, add_link, web_context, \ + add_script_data, add_script from multiproduct.dbcursor import GLOBAL_PRODUCT -from multiproduct.env import lookup_product_env, resolve_product_href +from multiproduct.env import lookup_product_env, resolve_product_href, \ + Product, ProductEnvironment +from multiproduct.hooks import ProductizedHref + class ProductQuery(Query): """Product Overrides for TracQuery. @@ -52,12 +60,17 @@ class ProductQuery(Query): def get_columns(self): super(ProductQuery, self).get_columns() - if not 'product' in self.cols: + if not 'product' in self.cols and self.group != 'product': # make sure 'product' is always present # (needed for product context, href, permission checks ...) + # but don't implicitly include it if items are grouped by product self.cols.insert(0, 'product') return self.cols + def _get_ticket_href(self, prefix, tid): + product_env = ProductEnvironment(self.env, prefix) + return product_env.href.ticket(tid) + def execute(self, req=None, db=None, cached_ids=None, authname=None, tzinfo=None, href=None, locale=None): """Retrieve the list of matching tickets. @@ -65,13 +78,13 @@ class ProductQuery(Query): :since 1.0: the `db` parameter is no longer needed and will be removed in version 1.1.1 """ - if req is not None: - href = req.href with self.env.db_direct_query as db: cursor = db.cursor() self.num_items = 0 sql, args = self.get_sql(req, cached_ids, authname, tzinfo, locale) + if sql.startswith('SELECT ') and not sql.startswith('SELECT DISTINCT '): + sql = 'SELECT DISTINCT * FROM (' + sql + ')' self.num_items = self._count(sql, args) if self.num_items <= self.max: @@ -96,6 +109,7 @@ class ProductQuery(Query): [None] results = [] + product_idx = columns.index('product') column_indices = range(len(columns)) for row in cursor: result = {} @@ -105,8 +119,8 @@ class ProductQuery(Query): val = val or 'anonymous' elif name == 'id': val = int(val) - if href is not None: - result['href'] = href.ticket(val) + result['href'] = self._get_ticket_href( + row[product_idx], val) elif name in self.time_fields: val = from_utimestamp(val) elif field and field['type'] == 'checkbox': @@ -121,6 +135,181 @@ class ProductQuery(Query): cursor.close() return results + +class ProductQueryModule(QueryModule): + def process_request(self, req): + req.perm.assert_permission('TICKET_VIEW') + + constraints = self._get_constraints(req) + args = req.args + if not constraints and not 'order' in req.args: + # If no constraints are given in the URL, use the default ones. + if req.authname and req.authname != 'anonymous': + qstring = self.default_query + user = req.authname + else: + email = req.session.get('email') + name = req.session.get('name') + qstring = self.default_anonymous_query + user = email or name or None + + self.log.debug('QueryModule: Using default query: %s', str(qstring)) + if qstring.startswith('?'): + arg_list = parse_arg_list(qstring[1:]) + args = arg_list_to_args(arg_list) + constraints = self._get_constraints(arg_list=arg_list) + else: + query = ProductQuery.from_string(self.env, qstring) + args = {'order': query.order, 'group': query.group, + 'col': query.cols, 'max': query.max} + if query.desc: + args['desc'] = '1' + if query.groupdesc: + args['groupdesc'] = '1' + constraints = query.constraints + + # Substitute $USER, or ensure no field constraints that depend + # on $USER are used if we have no username. + for clause in constraints: + for field, vals in clause.items(): + for (i, val) in enumerate(vals): + if user: + vals[i] = val.replace('$USER', user) + elif val.endswith('$USER'): + del clause[field] + break + + cols = args.get('col') + if isinstance(cols, basestring): + cols = [cols] + # Since we don't show 'id' as an option to the user, + # we need to re-insert it here. + if cols and 'id' not in cols: + cols.insert(0, 'id') + rows = args.get('row', []) + if isinstance(rows, basestring): + rows = [rows] + format = req.args.get('format') + max = args.get('max') + if max is None and format in ('csv', 'tab'): + max = 0 # unlimited unless specified explicitly + query = ProductQuery(self.env, req.args.get('report'), + constraints, cols, args.get('order'), + 'desc' in args, args.get('group'), + 'groupdesc' in args, 'verbose' in args, + rows, + args.get('page'), + max) + + if 'update' in req.args: + # Reset session vars + for var in ('query_constraints', 'query_time', 'query_tickets'): + if var in req.session: + del req.session[var] + req.redirect(query.get_href(req.href)) + + # Add registered converters + for conversion in Mimeview(self.env).get_supported_conversions( + 'trac.ticket.Query'): + add_link(req, 'alternate', + query.get_href(req.href, format=conversion[0]), + conversion[1], conversion[4], conversion[0]) + + if format: + filename = 'query' if format != 'rss' else None + Mimeview(self.env).send_converted(req, 'trac.ticket.Query', query, + format, filename=filename) + + return self.display_html(req, query) + + def display_html(self, req, query): + # The most recent query is stored in the user session; + orig_list = None + orig_time = datetime.now(utc) + query_time = int(req.session.get('query_time', 0)) + query_time = datetime.fromtimestamp(query_time, utc) + query_constraints = unicode(query.constraints) + try: + if query_constraints != req.session.get('query_constraints') \ + or query_time < orig_time - timedelta(hours=1): + tickets = query.execute(req) + # New or outdated query, (re-)initialize session vars + req.session['query_constraints'] = query_constraints + req.session['query_tickets'] = ' '.join([str(t['id']) + for t in tickets]) + else: + orig_list = [int(id) for id + in req.session.get('query_tickets', '').split()] + tickets = query.execute(req, cached_ids=orig_list) + orig_time = query_time + except QueryValueError, e: + tickets = [] + for error in e.errors: + add_warning(req, error) + + context = web_context(req, 'query') + owner_field = [f for f in query.fields if f['name'] == 'owner'] + if owner_field: + TicketSystem(self.env).eventually_restrict_owner(owner_field[0]) + data = query.template_data(context, tickets, orig_list, orig_time, req) + + req.session['query_href'] = query.get_href(context.href) + req.session['query_time'] = to_timestamp(orig_time) + req.session['query_tickets'] = ' '.join([str(t['id']) + for t in tickets]) + title = _('Custom Query') + + # Only interact with the report module if it is actually enabled. + # + # Note that with saved custom queries, there will be some convergence + # between the report module and the query module. + from trac.ticket.report import ReportModule + if 'REPORT_VIEW' in req.perm and \ + self.env.is_component_enabled(ReportModule): + data['report_href'] = req.href.report() + add_ctxtnav(req, _('Available Reports'), req.href.report()) + add_ctxtnav(req, _('Custom Query'), req.href.query()) + if query.id: + for title, description in self.env.db_query(""" + SELECT title, description FROM report WHERE id=%s + """, (query.id,)): + data['report_resource'] = Resource('report', query.id) + data['description'] = description + else: + data['report_href'] = None + + # Only interact with the batch modify module it it is enabled + # TODO: fix this for multiproduct + """ + from trac.ticket.batch import BatchModifyModule + if 'TICKET_BATCH_MODIFY' in req.perm and \ + self.env.is_component_enabled(BatchModifyModule): + self.env[BatchModifyModule].add_template_data(req, data, tickets) + """ + + data.setdefault('report', None) + data.setdefault('description', None) + data['title'] = title + + data['all_columns'] = query.get_all_columns() + # Don't allow the user to remove the id column + data['all_columns'].remove('id') + data['all_textareas'] = query.get_all_textareas() + + properties = dict((name, dict((key, field[key]) + for key in ('type', 'label', 'options', + 'optgroups') + if key in field)) + for name, field in data['fields'].iteritems()) + add_script_data(req, properties=properties, modes=data['modes']) + + add_stylesheet(req, 'common/css/report.css') + Chrome(self.env).add_jquery_ui(req) + add_script(req, 'common/js/query.js') + + return 'query.html', data, None + + class ProductTicketQueryMacro(TicketQueryMacro): """TracQuery macro retrieving results across product boundaries. """ Modified: incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_theme/bhtheme/theme.py URL: http://svn.apache.org/viewvc/incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_theme/bhtheme/theme.py?rev=1458416&r1=1458415&r2=1458416&view=diff ============================================================================== --- incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_theme/bhtheme/theme.py (original) +++ incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_theme/bhtheme/theme.py Tue Mar 19 17:22:51 2013 @@ -355,7 +355,6 @@ class BloodhoundTheme(ThemeBase): if href_fcn is None: href_fcn = req.href.products product_list = [] - is_product_scope = isinstance(req.perm.env, ProductEnvironment) for product in Product.select(self.env): if 'PRODUCT_VIEW' in req.product_perm(product.prefix, product.resource): product_list.append((product.prefix, product.name,