blob: b6efa84e886d68f67363cd955dee3601507fac9e [file] [log] [blame]
# -*- 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.
import random
import re
import string
import sys
from bhdashboard import wiki
from bhdashboard.util import dummy_request
from bhdashboard.web_ui import DashboardModule
from bhtheme.translation import _, add_domain
from genshi.builder import tag
from genshi.core import TEXT
from genshi.filters.transform import Transformer
from genshi.output import DocType
from multiproduct.env import ProductEnvironment
from multiproduct.web_ui import PRODUCT_RE, ProductModule
from pkg_resources import get_distribution
from themeengine.api import ThemeBase, ThemeEngineSystem
from trac.config import ListOption, Option
from trac.core import Component, TracError, implements
from trac.mimeview.api import get_mimetype
from trac.perm import IPermissionRequestor
from trac.resource import get_resource_url, Neighborhood, Resource
from trac.ticket.api import TicketSystem
from trac.ticket.model import Ticket, Milestone
from trac.ticket.notification import TicketNotifyEmail
from trac.ticket.web_ui import TicketModule
from trac.util.compat import set
from trac.util.presentation import to_json
from trac.util.translation import cleandoc_
from trac.versioncontrol.web_ui.browser import BrowserModule
from trac.web.api import IRequestFilter, ITemplateStreamFilter
from trac.web.chrome import (add_stylesheet, add_warning, INavigationContributor,
ITemplateProvider, prevnext_nav, Chrome, add_script)
from trac.web.main import IRequestHandler
from trac.wiki.admin import WikiAdmin
from trac.wiki.formatter import format_to_html
from trac.wiki.macros import WikiMacroBase
try:
from multiproduct.ticket.web_ui import ProductTicketModule
except ImportError:
ProductTicketModule = None
class BloodhoundTheme(ThemeBase):
"""Look and feel of Bloodhound issue tracker.
"""
template = htdocs = css = screenshot = disable_trac_css = True
disable_all_trac_css = True
BLOODHOUND_KEEP_CSS = set(
(
'diff.css', 'code.css'
)
)
BLOODHOUND_TEMPLATE_MAP = {
# Admin
'admin_accountsconfig.html': ('bh_admin_accountsconfig.html', '_modify_admin_breadcrumb'),
'admin_accountsnotification.html': ('bh_admin_accountsnotification.html', '_modify_admin_breadcrumb'),
'admin_basics.html': ('bh_admin_basics.html', '_modify_admin_breadcrumb'),
'admin_components.html': ('bh_admin_components.html', '_modify_admin_breadcrumb'),
'admin_enums.html': ('bh_admin_enums.html', '_modify_admin_breadcrumb'),
'admin_logging.html': ('bh_admin_logging.html', '_modify_admin_breadcrumb'),
'admin_milestones.html': ('bh_admin_milestones.html', '_modify_admin_breadcrumb'),
'admin_perms.html': ('bh_admin_perms.html', '_modify_admin_breadcrumb'),
'admin_plugins.html': ('bh_admin_plugins.html', '_modify_admin_breadcrumb'),
'admin_products.html': ('bh_admin_products.html', '_modify_admin_breadcrumb'),
'admin_repositories.html': ('bh_admin_repositories.html', '_modify_admin_breadcrumb'),
'admin_users.html': ('bh_admin_users.html', '_modify_admin_breadcrumb'),
'admin_versions.html': ('bh_admin_versions.html', '_modify_admin_breadcrumb'),
# no template substitutions below - use the default template,
# but call the modifier nonetheless
'repository_links.html': ('repository_links.html', '_modify_admin_breadcrumb'),
# Preferences
'prefs.html': ('bh_prefs.html', None),
'prefs_account.html': ('bh_prefs_account.html', None),
'prefs_advanced.html': ('bh_prefs_advanced.html', None),
'prefs_datetime.html': ('bh_prefs_datetime.html', None),
'prefs_general.html': ('bh_prefs_general.html', None),
'prefs_keybindings.html': ('bh_prefs_keybindings.html', None),
'prefs_language.html': ('bh_prefs_language.html', None),
'prefs_pygments.html': ('bh_prefs_pygments.html', None),
'prefs_userinterface.html': ('bh_prefs_userinterface.html', None),
# Search
'search.html': ('bh_search.html', '_modify_search_data'),
# Wiki
'wiki_delete.html': ('bh_wiki_delete.html', None),
'wiki_diff.html': ('bh_wiki_diff.html', None),
'wiki_edit.html': ('bh_wiki_edit.html', None),
'wiki_rename.html': ('bh_wiki_rename.html', None),
'wiki_view.html': ('bh_wiki_view.html', '_modify_wiki_page_path'),
# Ticket
'diff_view.html': ('bh_diff_view.html', None),
'manage.html': ('manage.html', '_modify_resource_breadcrumb'),
'milestone_edit.html': ('bh_milestone_edit.html', '_modify_roadmap_page'),
'milestone_delete.html': ('bh_milestone_delete.html', '_modify_roadmap_page'),
'milestone_view.html': ('bh_milestone_view.html', '_modify_roadmap_page'),
'query.html': ('bh_query.html', '_add_products_general_breadcrumb'),
'report_delete.html': ('bh_report_delete.html', '_add_products_general_breadcrumb'),
'report_edit.html': ('bh_report_edit.html', '_add_products_general_breadcrumb'),
'report_list.html': ('bh_report_list.html', '_add_products_general_breadcrumb'),
'report_view.html': ('bh_report_view.html', '_add_products_general_breadcrumb'),
'roadmap.html': ('bh_roadmap.html', '_modify_roadmap_page'),
'ticket.html': ('bh_ticket.html', '_modify_ticket'),
'ticket_delete.html': ('bh_ticket_delete.html', None),
'ticket_preview.html': ('bh_ticket_preview.html', None),
# Attachment
'attachment.html': ('bh_attachment.html', None),
'preview_file.html': ('bh_preview_file.html', None),
# Version control
'browser.html': ('bh_browser.html', '_modify_browser'),
'changeset.html': ('bh_changeset.html', None),
'diff_form.html': ('bh_diff_form.html', None),
'dir_entries.html': ('bh_dir_entries.html', None),
'revisionlog.html': ('bh_revisionlog.html', '_modify_browser'),
# Multi Product
'product_view.html': ('bh_product_view.html', '_add_products_general_breadcrumb'),
'product_list.html': ('bh_product_list.html', '_modify_product_list'),
'product_edit.html': ('bh_product_edit.html', '_add_products_general_breadcrumb'),
# General purpose
'about.html': ('bh_about.html', None),
'history_view.html': ('bh_history_view.html', None),
'timeline.html': ('bh_timeline.html', None),
# Account manager plugin
'account_details.html': ('bh_account_details.html', None),
'login.html': ('bh_login.html', None),
'register.html': ('bh_register.html', None),
'reset_password.html': ('bh_reset_password.html', None),
'user_table.html': ('bh_user_table.html', None),
'verify_email.html': ('bh_verify_email.html', None),
}
BOOTSTRAP_CSS_DEFAULTS = (
# ('XPath expression', ['default', 'bootstrap', 'css', 'classes'])
("body//table[not(contains(@class, 'table'))]", # TODO: Accurate ?
['table', 'table-condensed']),
)
labels_application_short = Option('labels', 'application_short',
'Bloodhound', """A short version of application name most commonly
displayed in text, titles and labels""", doc_domain='bhtheme')
labels_application_full = Option('labels', 'application_full',
'Apache Bloodhound', """This is full name with trade mark and
everything, it is currently used in footers and about page only""",
doc_domain='bhtheme')
labels_footer_left_prefix = Option('labels', 'footer_left_prefix', '',
"""Text to display before full application name in footers""",
doc_domain='bhtheme')
labels_footer_left_postfix = Option('labels', 'footer_left_postfix', '',
"""Text to display after full application name in footers""",
doc_domain='bhtheme')
labels_footer_right = Option('labels', 'footer_right', '',
"""Text to use as the right aligned footer""", doc_domain='bhtheme')
_wiki_pages = None
Chrome.default_html_doctype = DocType.HTML5
implements(IRequestFilter, INavigationContributor, ITemplateProvider,
ITemplateStreamFilter)
from trac.web import main
main.default_tracker = 'http://issues.apache.org/bloodhound'
def _get_whitelabelling(self):
"""Gets the whitelabelling config values"""
return {
'application_short': self.labels_application_short,
'application_full': self.labels_application_full,
'footer_left_prefix': self.labels_footer_left_prefix,
'footer_left_postfix': self.labels_footer_left_postfix,
'footer_right': self.labels_footer_right,
'application_version': application_version
}
# ITemplateStreamFilter methods
def filter_stream(self, req, method, filename, stream, data):
"""Insert default Bootstrap CSS classes if rendering
legacy templates (i.e. determined by template name prefix)
and renames wiki guide links.
"""
tx = Transformer('body')
def add_classes(classes):
"""Return a function ensuring CSS classes will be there for element.
"""
def attr_modifier(name, event):
attrs = event[1][1]
class_list = attrs.get(name, '').split()
self.log.debug('BH Theme : Element classes ' + str(class_list))
out_classes = ' '.join(set(class_list + classes))
self.log.debug('BH Theme : Inserting class ' + out_classes)
return out_classes
return attr_modifier
# Insert default bootstrap CSS classes if necessary
for xpath, classes in self.BOOTSTRAP_CSS_DEFAULTS:
tx = tx.end().select(xpath) \
.attr('class', add_classes(classes))
# Rename wiki guide links
tx = tx.end() \
.select("body//a[contains(@href,'/wiki/%s')]" % wiki.GUIDE_NAME) \
.map(lambda text: wiki.new_name(text), TEXT)
# Rename trac error
app_short = self.labels_application_short
tx = tx.end() \
.select("body//div[@class='error']/h1") \
.map(lambda text: text.replace("Trac", app_short), TEXT)
return stream | tx
# IRequestFilter methods
def pre_process_request(self, req, handler):
"""Pre process request filter"""
def hwiki(*args, **kw):
def new_name(name):
new_name = wiki.new_name(name)
if new_name != name:
if not self._wiki_pages:
wiki_admin = WikiAdmin(self.env)
self._wiki_pages = wiki_admin.get_wiki_list()
if new_name in self._wiki_pages:
return new_name
return name
a = tuple([new_name(x) for x in args])
return req.href.__call__("wiki", *a, **kw)
req.href.wiki = hwiki
return handler
def post_process_request(self, req, template, data, content_type):
"""Post process request filter.
Removes all trac provided css if required"""
if template is None and data is None and \
sys.exc_info() == (None, None, None):
return template, data, content_type
def is_active_theme():
is_active = False
active_theme = ThemeEngineSystem(self.env).theme
if active_theme is not None:
this_theme_name = self.get_theme_names().next()
is_active = active_theme['name'] == this_theme_name
return is_active
req.chrome['labels'] = self._get_whitelabelling()
if data is not None:
data['product_list'] = \
ProductModule.get_product_list(self.env, req)
links = req.chrome.get('links', {})
# replace favicon if appropriate
if self.env.project_icon == 'common/trac.ico':
bh_icon = 'theme/img/bh.ico'
new_icon = {'href': req.href.chrome(bh_icon),
'type': get_mimetype(bh_icon)}
if links.get('icon'):
links.get('icon')[0].update(new_icon)
if links.get('shortcut icon'):
links.get('shortcut icon')[0].update(new_icon)
is_active_theme = is_active_theme()
if self.disable_all_trac_css and is_active_theme:
# Move 'admin' entry from mainnav to metanav
for i, entry in enumerate(req.chrome['nav'].get('mainnav', [])):
if entry['name'] == 'admin':
req.chrome['nav'].setdefault('metanav', []) \
.append(req.chrome['nav']['mainnav'].pop(i))
if self.disable_all_trac_css:
stylesheets = links.get('stylesheet', [])
if stylesheets:
path = '/chrome/common/css/'
_iter = ([ss, ss.get('href', '')] for ss in stylesheets)
links['stylesheet'] = \
[ss for ss, href in _iter if not path in href or
href.rsplit('/', 1)[-1] in self.BLOODHOUND_KEEP_CSS]
template, modifier = \
self.BLOODHOUND_TEMPLATE_MAP.get(template, (template, None))
if modifier is not None:
modifier = getattr(self, modifier)
modifier(req, template, data, content_type, is_active_theme)
if is_active_theme and data is not None:
data['responsive_layout'] = \
self.env.config.getbool('bloodhound', 'responsive_layout',
'true')
data['bhrelations'] = \
self.env.config.getbool('components', 'bhrelations.*', 'false')
if req.locale is not None:
add_script(req, 'theme/bloodhound/%s.js' % req.locale)
return template, data, content_type
# ITemplateProvider methods
def get_htdocs_dirs(self):
"""Ensure dashboard htdocs will be there even if
`bhdashboard.web_ui.DashboardModule` is disabled.
"""
if not self.env.is_component_enabled(DashboardModule):
return DashboardModule(self.env).get_htdocs_dirs()
def get_templates_dirs(self):
"""Ensure dashboard templates will be there even if
`bhdashboard.web_ui.DashboardModule` is disabled.
"""
if not self.env.is_component_enabled(DashboardModule):
return DashboardModule(self.env).get_templates_dirs()
# Request modifiers
def _modify_search_data(self, req, template, data, content_type, is_active):
"""Insert breadcumbs and context navigation items in search web UI
"""
if is_active:
# Insert query string in search box (see bloodhound_theme.html)
req.search_query = data.get('query')
# Context nav
prevnext_nav(req, _('Previous'), _('Next'))
# Breadcrumbs nav
data['resourcepath_template'] = 'bh_path_search.html'
def _modify_wiki_page_path(self, req, template, data, content_type,
is_active):
"""Override wiki breadcrumbs nav items
"""
if is_active:
data['resourcepath_template'] = 'bh_path_wikipage.html'
def _modify_roadmap_page(self, req, template, data, content_type,
is_active):
"""Insert roadmap.css + products breadcrumb
"""
add_stylesheet(req, 'dashboard/css/roadmap.css')
self._add_products_general_breadcrumb(req, template, data,
content_type, is_active)
data['milestone_list'] = [m.name for m in Milestone.select(self.env)]
req.chrome['ctxtnav'] = []
def _modify_ticket(self, req, template, data, content_type, is_active):
"""Ticket modifications
"""
self._modify_resource_breadcrumb(req, template, data, content_type,
is_active)
# add a creation event to the changelog if the ticket exists
ticket = data['ticket']
if ticket.exists:
data['changes'] = [{'comment': '',
'author': ticket['reporter'],
'fields': {u'reported': {'label': u'Reported'},
},
'permanent': 1,
'cnum': 0,
'date': ticket['time'],
},
] + data['changes']
# and set default order
if not req.session.get('ticket_comments_order'):
req.session['ticket_comments_order'] = 'newest'
def _modify_resource_breadcrumb(self, req, template, data, content_type,
is_active):
"""Provides logic for breadcrumb resource permissions
"""
if data and ('ticket' in data.keys()) and data['ticket'].exists:
data['resourcepath_template'] = 'bh_path_ticket.html'
# determine path permissions
for resname, permname in [('milestone', 'MILESTONE_VIEW'),
('product', 'PRODUCT_VIEW')]:
res = Resource(resname, data['ticket'][resname])
data['path_show_' + resname] = permname in req.perm(res)
# add milestone list + current milestone to the breadcrumb
data['milestone_list'] = [m.name
for m in Milestone.select(self.env)]
mname = data['ticket']['milestone']
if mname:
data['milestone'] = Milestone(self.env, mname)
def _modify_admin_breadcrumb(self, req, template, data, content_type, is_active):
# override 'normal' product list with the admin one
def admin_url(prefix):
env = ProductEnvironment.lookup_env(self.env, prefix)
href = ProductEnvironment.resolve_href(env, self.env)
return href.admin()
global_settings = (None, _('(Global settings)'), admin_url(None))
data['admin_product_list'] = [global_settings] + \
ProductModule.get_product_list(self.env, req, admin_url)
if isinstance(req.perm.env, ProductEnvironment):
product = req.perm.env.product
data['admin_current_product'] = \
(product.prefix, product.name,
req.href.products(product.prefix, 'admin'))
else:
data['admin_current_product'] = global_settings
data['resourcepath_template'] = 'bh_path_general.html'
def _modify_browser(self, req, template, data, content_type, is_active):
"""Locate path to file in breadcrumbs area rather than title.
Add browser-specific CSS.
"""
data.update({
'resourcepath_template': 'bh_path_links.html',
'path_depth_limit': 2
})
add_stylesheet(req, 'theme/css/browser.css')
def _add_products_general_breadcrumb(self, req, template, data,
content_type, is_active):
if isinstance(req.perm.env, ProductEnvironment):
data['resourcepath_template'] = 'bh_path_general.html'
def _modify_product_list(self, req, template, data, content_type,
is_active):
"""Transform products list into media list by adding
configured product icon as well as further navigation items.
"""
products = data.pop('products')
context = data['context']
with self.env.db_query as db:
icons = db.execute("""
SELECT product, value FROM bloodhound_productconfig
WHERE product IN (%s) AND section='project' AND
option='icon'""" % ', '.join(["%s"] * len(products)),
tuple(p.prefix for p in products))
icons = dict(icons)
data['thumbsize'] = 64
# FIXME: Gray icon for missing products
no_thumbnail = req.href('chrome/theme/img/bh.ico')
product_ctx = lambda item: context.child(item.resource)
def product_media_data(icons, product):
return dict(href=product.href(),
thumb=icons.get(product.prefix, no_thumbnail),
title=product.name,
description=format_to_html(self.env,
product_ctx(product),
product.description),
links={'extras': (([{'href': req.href.products(
product.prefix, action='edit'),
'title': _('Edit product %(prefix)s',
prefix=product.prefix),
'icon': tag.i(class_='icon-edit'),
'label': _('Edit')}, ]
if 'PRODUCT_MODIFY' in req.perm
else []) +
[{'href': product.href(),
'title': _('Home page'),
'icon': tag.i(class_='icon-home'),
'label': _('Home')},
{'href': product.href.dashboard(),
'title': _('Tickets dashboard'),
'icon': tag.i(class_='icon-tasks'),
'label': _('Tickets')},
{'href': product.href.wiki(),
'title': _('Wiki'),
'icon': tag.i(class_='icon-book'),
'label': _('Wiki')}]),
'main': {'href': product.href(),
'title': None,
'icon': tag.i(class_='icon-chevron-right'),
'label': _('Browse')}})
data['products'] = [product_media_data(icons, product)
for product in products]
# INavigationContributor methods
def get_active_navigation_item(self, req):
return
def get_navigation_items(self, req):
if 'BROWSER_VIEW' in req.perm and 'VERSIONCONTROL_ADMIN' in req.perm:
bm = self.env[BrowserModule]
if bm and not list(bm.get_navigation_items(req)):
yield ('mainnav', 'browser',
tag.a(_('Source'),
href=req.href.wiki('TracRepositoryAdmin')))
class QCTSelectFieldUpdate(Component):
implements(IRequestHandler)
def match_request(self, req):
return req.path_info == '/update-menus'
def process_request(self, req):
product = req.args.get('product')
fields_to_update = req.args.get('fields_to_update[]');
env = ProductEnvironment(self.env.parent, req.args.get('product'))
ticket_fields = TicketSystem(env).get_ticket_fields()
data = dict([f['name'], f['options']] for f in ticket_fields
if f['type'] == 'select' and f['name'] in fields_to_update)
req.send(to_json(data), 'application/json')
class QuickCreateTicketDialog(Component):
implements(IRequestFilter, IRequestHandler)
qct_fields = ListOption('ticket', 'quick_create_fields',
'product, version, type',
doc="""Multiple selection fields displayed in create ticket menu""",
doc_domain='bhtheme')
def __init__(self, *args, **kwargs):
import pkg_resources
locale_dir = pkg_resources.resource_filename(__name__, 'locale')
add_domain(self.env.path, locale_dir)
super(QuickCreateTicketDialog, self).__init__(*args, **kwargs)
# IRequestFilter(Interface):
def pre_process_request(self, req, handler):
"""Nothing to do.
"""
return handler
def post_process_request(self, req, template, data, content_type):
"""Append necessary ticket data
"""
try:
tm = self._get_ticket_module()
except TracError:
# no ticket module so no create ticket button
return template, data, content_type
if (template, data, content_type) != (None,) * 3: # TODO: Check !
if data is None:
data = {}
dum_req = dummy_request(self.env)
dum_req.perm = req.perm
ticket = Ticket(self.env)
tm._populate(dum_req, ticket, False)
all_fields = dict([f['name'], f]
for f in tm._prepare_fields(dum_req, ticket)
if f['type'] == 'select')
product_field = all_fields.get('product')
if product_field:
# When at product scope, set the default selection to the
# product at current scope. When at global scope the default
# selection is determined by [ticket] default_product
if self.env.product and \
self.env.product.prefix in product_field['options']:
product_field['value'] = self.env.product.prefix
# Transform the options field to dictionary of product
# attributes and filter out products for which user doesn't
# have TICKET_CREATE permission
product_field['options'] = [
dict(value=p,
new_ticket_url=dum_req.href.products(p, 'newticket'),
description=ProductEnvironment.lookup_env(self.env, p)
.product.name)
for p in product_field['options']
if req.perm.has_permission('TICKET_CREATE',
Neighborhood('product', p)
.child(None, None))]
else:
msg = _("Missing ticket field '%(field)s'.", field='product')
if ProductTicketModule is not None and \
self.env[ProductTicketModule] is not None:
# Display warning alert to users
add_warning(req, msg)
else:
# Include message in logs since this might be a failure
self.log.warning(msg)
data['qct'] = {
'fields': [all_fields[k] for k in self.qct_fields
if k in all_fields],
'hidden_fields': [all_fields[k] for k in all_fields.keys()
if k not in self.qct_fields]}
return template, data, content_type
# IRequestHandler methods
def match_request(self, req):
"""Handle requests sent to /qct
"""
m = PRODUCT_RE.match(req.path_info)
return req.path_info == '/qct' or \
(m and m.group('pathinfo').strip('/') == 'qct')
def process_request(self, req):
"""Forward new ticket request to `trac.ticket.web_ui.TicketModule`
but return plain text suitable for AJAX requests.
"""
try:
tm = self._get_ticket_module()
req.perm.require('TICKET_CREATE')
summary = req.args.pop('field_summary', '')
desc = ""
attrs = dict([k[6:], v] for k, v in req.args.iteritems()
if k.startswith('field_'))
product, tid = self.create(req, summary, desc, attrs, True)
except Exception, exc:
self.log.exception("BH: Quick create ticket failed %s" % (exc,))
req.send(str(exc), 'plain/text', 500)
else:
tres = Neighborhood('product', product)('ticket', tid)
href = req.href
req.send(to_json({'product': product, 'id': tid,
'url': get_resource_url(self.env, tres, href)}),
'application/json')
def _get_ticket_module(self):
ptm = None
if ProductTicketModule is not None:
ptm = self.env[ProductTicketModule]
tm = self.env[TicketModule]
if not (tm is None) ^ (ptm is None):
raise TracError('Unable to load TicketModule (disabled)?')
if tm is None:
tm = ptm
return tm
# Public API
def create(self, req, summary, description, attributes={}, notify=False):
""" Create a new ticket, returning the ticket ID.
PS: Borrowed from XmlRpcPlugin.
"""
if 'product' in attributes:
env = self.env.parent or self.env
if attributes['product']:
env = ProductEnvironment(env, attributes['product'])
else:
env = self.env
t = Ticket(env)
t['summary'] = summary
t['description'] = description
t['reporter'] = req.authname
for k, v in attributes.iteritems():
t[k] = v
t['status'] = 'new'
t['resolution'] = ''
t.insert()
if notify:
try:
tn = TicketNotifyEmail(env)
tn.notify(t, newticket=True)
except Exception, e:
self.log.exception("Failure sending notification on creation "
"of ticket #%s: %s" % (t.id, e))
return t['product'], t.id
application_version = get_distribution('BloodhoundTheme').version
class BatchCreateTicketsMacro(WikiMacroBase):
def parse_macro(self, parser, name, content):
pass
implements(
IRequestFilter,
IRequestHandler,
ITemplateStreamFilter,
IPermissionRequestor)
_description = cleandoc_(
"""
Helps to create batch of tickets at once.
This macro accepts only one argument, which should be an integer value equal to the number of tickets that is going to create as a batch.
Example:
{{{
[[BatchCreateTickets(5)]] # This will create an empty table with 5 rows.
}}}
The empty table which will be created will contain the following tickets fields.
* `Summary` //(This field is mandatory.)//
* `Description`
* `Product`
* `Priority`
* `Milestone`
* `Component`
`BatchCreateTickets` has also make it possible to increase or decrease the size of the empty table created. After
filling the appropriate ticket fields you can create that batch of tickets and will be able to view the
details of the created tickets as a ticket table.
""")
bct_fields = ListOption(
'ticket',
'batch_create_fields',
'product, version, type',
doc="""Multiple selection fields displayed in create ticket menu""",
doc_domain='bhtheme')
def __init__(self, *args, **kwargs):
import pkg_resources
locale_dir = pkg_resources.resource_filename(__name__, 'locale')
add_domain(self.env.path, locale_dir)
self.rows = 0
self.rqst = None
self.file = None
super(BatchCreateTicketsMacro, self).__init__(*args, **kwargs)
# IPermissionRequestor methods
def get_permission_actions(self):
return ['TICKET_BATCH_CREATE']
def expand_macro(self, formatter, name, args):
"""Set the number of rows for empty ticket table to be generated.
Return none as the template will not changed at this point.
`name` is the actual name of the macro. (here it'll be
`'BatchCreateTickets'`),
`args` is the text enclosed in parenthesis at the call of the macro.
Note that if there are ''no'' parenthesis (like in, e.g.
[[BatchCreateTickets]]), then `args` is `None` and its not a valid argument.
Or if the argument is not a valid type(like in, e.g.
[[BatchCreateTickets("Hello")]] "Hello" is a string which can't be parsed to an integer)
then the bh will raise an error.
"""
self.rows = args
# check the permission conditions and allow the feature only on wiki formatted pages.
if (
self.env.product is not None) and (
self.file == 'bh_wiki_view.html' or self.file == 'bh_wiki_edit.html' or self.file is None) and (
self.rqst.perm.has_permission('TRAC_ADMIN') or self.rqst.perm.has_permission(
'TICKET_BATCH_CREATE')):
# todo let the user select the product when creating tickets
# generate the required data to be parsed to the js functions too create the empty ticket table.
product_id = str(self.env.product.resource.id)
product_name = self.env.db_query(
"SELECT * FROM bloodhound_product WHERE prefix=%s", (product_id,))
milestones = self.env.db_query(
"SELECT * FROM milestone WHERE product=%s", (product_id,))
components = self.env.db_query(
"SELECT * FROM component WHERE product=%s", (product_id,))
random_string = '%s%s' % ('-', ''.join(random.choice(string.lowercase) for i in range(10)))
form = tag.form(
tag.div(
tag.span(
tag.script(
type="text/javascript",
charset="utf-8",
src=str(self.rqst.href.chrome('theme/js/batchcreate.js'))),
tag.script(
# pass the relevant arguments to the js function as JSON parameters.
"emptyTable(" + to_json(str(self.rows)) + "," + to_json(product_name) + "," +
to_json(milestones) + "," + to_json(components) + "," +
to_json(self.rqst.href()) + "," +
to_json(str(self.rqst.environ["HTTP_COOKIE"])) + "," +
to_json(random_string) + ")",
id="js-caller" + random_string,
type="text/javascript"),
class_="input-group-btn"),
class_='report',
id="div-empty-table" + random_string),
method="get",
style="display:inline",
id="batchcreate" + random_string)
try:
int(self.rows)
except TracError:
print "Enter a valid argument (integer) to the BatchCreateTickets macro."
return form
else:
return None
# IRequestFilter(Interface):
def pre_process_request(self, req, handler):
"""Nothing to do.
"""
self.rqst = req
return handler
def post_process_request(self, req, template, data, content_type):
"""Append necessary ticket data
"""
try:
tm = self._get_ticket_module()
except TracError:
# no ticket module so no create ticket button
return template, data, content_type
if (template, data, content_type) != (None,) * 3: # TODO: Check !
if data is None:
data = {}
dum_req = dummy_request(self.env)
dum_req.perm = req.perm
ticket = Ticket(self.env)
tm._populate(dum_req, ticket, False)
all_fields = dict([f['name'], f]
for f in tm._prepare_fields(dum_req, ticket)
if f['type'] == 'select')
product_field = all_fields.get('product')
if product_field:
# When at product scope, set the default selection to the
# product at current scope. When at global scope the default
# selection is determined by [ticket] default_product
if self.env.product and \
self.env.product.prefix in product_field['options']:
product_field['value'] = self.env.product.prefix
# Transform the options field to dictionary of product
# attributes and filter out products for which user doesn't
# have TICKET_CREATE permission
product_field['options'] = [
dict(value=p,
new_ticket_url=dum_req.href.products(p, 'newticket'),
description=ProductEnvironment.lookup_env(self.env, p)
.product.name
)
for p in product_field['options']
if req.perm.has_permission('TICKET_CREATE',
Neighborhood('product', p)
.child(None, None))]
else:
msg = _("Missing ticket field '%(field)s'.", field='product')
if ProductTicketModule is not None and \
self.env[ProductTicketModule] is not None:
# Display warning alert to users
add_warning(req, msg)
else:
# Include message in logs since this might be a failure
self.log.warning(msg)
data['bct'] = {
'fields': [all_fields[k] for k in self.bct_fields
if k in all_fields],
'hidden_fields': [all_fields[k] for k in all_fields.keys()
if k not in self.bct_fields]}
return template, data, content_type
# IRequestHandler methods
def match_request(self, req):
"""Handle requests sent to /bct
"""
m = PRODUCT_RE.match(req.path_info)
return req.path_info == '/bct' or \
(m and m.group('pathinfo').strip('/') == 'bct')
def process_request(self, req):
self.log.debug("BatchCreateTicketsModule: process_request entered")
"""Forward new ticket request to `trac.ticket.web_ui.TicketModule`
but return plain text suitable for AJAX requests.
"""
try:
tm = self._get_ticket_module()
req.perm.require('TICKET_BATCH_CREATE')
attrs = dict([k[6:], v] for k, v in req.args.iteritems()
if k.startswith('field_'))
# new_tkts variable will contain the tickets that have been created as a batch
# that information will be used to load the resultant query table
product, tid, new_tkts = self.batch_create(
req, attrs, True)
except Exception as exc:
self.log.exception("BH: Batch create tickets failed %s" % (exc,))
req.send(str(exc), 'plain/text', 500)
else:
tkt_list = []
tkt_dict = {}
num_of_tkts = len(new_tkts)
for i in range(0, num_of_tkts):
tres = Neighborhood(
'product',
new_tkts[i].values['product'])(
'ticket',
tid -
num_of_tkts +
i +
1)
href = req.href
tkt_list.append(
to_json(
{
'product': new_tkts[i].values['product'],
'id': tid - num_of_tkts + i + 1,
'url': get_resource_url(
self.env,
tres,
href),
'summary': new_tkts[i].values['summary'],
'status': new_tkts[i].values['status'],
'milestone': new_tkts[i].values['milestone'],
'component': new_tkts[i].values['component'],
'priority': new_tkts[i].values['priority'],
'description': new_tkts[i].values['description']}))
tkt_dict["tickets"] = tkt_list
self._update_wiki_content(num_of_tkts)
# send the HTTP POST request
req.send(to_json(tkt_dict), 'application/json')
# Public API
def _update_wiki_content(self, num_of_tkts):
"""Editing the wiki content
After creating the tickets successfully the feature requires to display the details of the created tickets
within the wiki.
To do that at this point the wiki content will be updated.
ie. [[BatchCreateTickets(x)]] to [[CreatedTickets(start id,end id)]]
"""
max_uid = self.env.db_query("SELECT MAX(uid) FROM ticket")
max_time = self.env.db_query("SELECT MAX(time) FROM wiki")
wiki_content = self.env.db_query(
"SELECT * FROM wiki WHERE time==%s", (max_time[0][0],))
wiki_string = wiki_content[0][5]
# regex pattern of the macro declaration.
pattern = '\[\[BatchCreateTickets\([0-9]+\)\]\]'
l = re.search(pattern, wiki_string)
l1 = str(wiki_string[l.regs[0][0]:l.regs[0][1]])
updated_wiki_content = wiki_string.replace(
l1, "[[CreatedTickets(" + str(max_uid[0][0] - num_of_tkts) + "," + str(max_uid[0][0]) + ")]]")
with self.env.db_transaction as db:
db("UPDATE wiki SET text=%s WHERE time=%s",
(updated_wiki_content, max_time[0][0]))
return None
def _get_ticket_module(self):
ptm = None
if ProductTicketModule is not None:
ptm = self.env[ProductTicketModule]
tm = self.env[TicketModule]
if not (tm is None) ^ (ptm is None):
raise TracError('Unable to load TicketModule (disabled)?')
if tm is None:
tm = ptm
return tm
# ITemplateStreamFilter methods
def filter_stream(self, req, method, filename, stream, data):
self.file = filename
return stream
# Public API
def batch_create(self, req, attributes={}, notify=False):
""" Create batch of tickets, returning created tickets.
"""
num_of_tkts = attributes.__len__() / 6
created_tickets = []
loop_condition = True
i = -1
# iterate data row by row and create tickets using those user filled data fields.
while loop_condition:
if num_of_tkts <= 0:
loop_condition = False
break
i += 1
if 'summary' + str(i) not in attributes:
continue
if 'product' + str(i) in attributes:
env = self.env.parent or self.env
if attributes['product' + str(i)]:
env = ProductEnvironment(
env,
attributes[
'product' +
str(i)])
else:
env = self.env
# If the summary field of a particular data row is empty skip creating that ticket.
if attributes.get('summary' + str(i)) == "":
num_of_tkts -= 1
continue
description = attributes.pop('description' + str(i))
status = 'new'
summary = attributes.pop('summary' + str(i))
product = attributes.pop('product' + str(i))
priority = attributes.pop('priority' + str(i))
milestone = attributes.pop('milestone' + str(i))
component = attributes.pop('component' + str(i))
# Create the tickets using extracted data.
t = Ticket(env)
t['summary'] = summary
t['description'] = description
t['reporter'] = req.authname
t['status'] = status
t['resolution'] = ''
t['product'] = product
t['priority'] = priority
t['milestone'] = milestone
t['component'] = component
# Insert the data into the DB.
t.insert()
created_tickets.append(t)
if notify:
try:
tn = TicketNotifyEmail(env)
tn.notify(t, newticket=True)
except Exception as e:
self.log.exception(
"Failure sending notification on creation "
"of ticket #%s: %s" %
(t.id, e))
num_of_tkts -= 1
return t['product'], t.id, created_tickets
class CreatedTicketsMacro(WikiMacroBase):
implements(ITemplateStreamFilter)
_description = cleandoc_(
"""
Helps to generate a ticket table within the wiki.
You can use this macro in order to display the details of a batch of tickets. `CreatedTickets` macro takes exactly
two integer arguments. The arguments defines the range of the tickets that will be displayed in the ticket table.
Example:
{{{
[[CreatedTickets(10,15)]] # This will create a ticket table with tickets which has id's between 10 and 15.
}}}
""")
def __init__(self, *args, **kwargs):
import pkg_resources
locale_dir = pkg_resources.resource_filename(__name__, 'locale')
add_domain(self.env.path, locale_dir)
self.rqst = None
self.file = None
super(CreatedTicketsMacro, self).__init__(*args, **kwargs)
# Template Stream Filter methods
def filter_stream(self, req, method, filename, stream, data):
self.rqst = req
self.file = filename
return stream
def expand_macro(self, formatter, name, args):
"""Set the number of rows for empty ticket table to be generated.
Return none as the template will not changed at this point.
`name` is the actual name of the macro. (here it'll be
`'BatchCreateTickets'`),
`args` is the text enclosed in parenthesis at the call of the macro.
Note that if there are ''no'' parenthesis (like in, e.g.
[[BatchCreateTickets]]), then `args` is `None` and its not a valid argument.
Or if the argument is not a valid type(like in, e.g.
[[BatchCreateTickets("Hello")]] "Hello" is a string which can't be parsed to an integer)
then the bh will raise an error.
"""
if self.file == 'bh_wiki_view.html' or self.file == 'bh_wiki_edit.html' or self.file is None:
# Extract the macro arguments.
id_range = args.split(',')
start_id = int(id_range[0])
end_id = int(id_range[1])
display_tickets = self.env.db_query(
"SELECT id,summary,product,status,milestone,component FROM ticket WHERE uid>=%s and uid<=%s",
(start_id +
1,
end_id),
)
display_tickets_list = []
for i in range(0, end_id - start_id):
tres = Neighborhood(
'product',
display_tickets[i][2])(
'ticket',
display_tickets[i][0])
href = self.rqst.href
display_tickets_list.append(
to_json(
{
'product': display_tickets[i][2],
'id': display_tickets[i][0],
'url': get_resource_url(
self.env,
tres,
href),
'summary': display_tickets[i][1],
'status': display_tickets[i][3],
'milestone': display_tickets[i][4],
'component': display_tickets[i][5]
}))
random_string = '%s%s' % ('-', ''.join(random.choice(string.lowercase) for i in range(10)))
# Send the ticket data to be displayed on the ticket table as JSON parameters.
form = tag.form(
tag.div(
tag.span(
tag.script(
type='text/javascript',
charset='utf-8',
src=str(self.rqst.href.chrome('theme/js/batchcreate.js'))),
tag.script(
'display_created_tickets(' + to_json(display_tickets_list) + ',' +
to_json(random_string) + ')',
id='js-caller' + random_string,
type='text/javascript'),
class_='input-group-btn'),
class_='report',
id='div-created-ticket-table' + random_string),
method='get',
style='display:inline',
id='batchcreate' + random_string)
return form
else:
return None