blob: 9a12e5959aef94dfef18b0ed62db0d9a47d1600b [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 sys
from genshi.builder import tag
from genshi.core import TEXT
from genshi.filters.transform import Transformer
from genshi.output import DocType
from trac.config import ListOption, Option
from trac.core import Component, TracError, implements
from trac.mimeview.api import get_mimetype
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.versioncontrol.web_ui.browser import BrowserModule
from trac.web.api import IRequestFilter, IRequestHandler, ITemplateStreamFilter
from trac.web.chrome import (add_stylesheet, add_warning, INavigationContributor,
ITemplateProvider, prevnext_nav, Chrome, add_script)
from trac.wiki.admin import WikiAdmin
from trac.wiki.formatter import format_to_html
from themeengine.api import ThemeBase, ThemeEngineSystem
from bhdashboard.util import dummy_request
from bhdashboard.web_ui import DashboardModule
from bhdashboard import wiki
from multiproduct.env import ProductEnvironment
from multiproduct.web_ui import PRODUCT_RE, ProductModule
from bhtheme.translation import _, add_domain
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
from pkg_resources import get_distribution
application_version = get_distribution('BloodhoundTheme').version