| |
| # 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.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 _ |
| from trac.versioncontrol.web_ui.browser import BrowserModule |
| from trac.web.api import IRequestFilter, IRequestHandler, ITemplateStreamFilter |
| from trac.web.chrome import (add_stylesheet, INavigationContributor, |
| ITemplateProvider, prevnext_nav, Chrome) |
| from trac.wiki.admin import WikiAdmin |
| |
| 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 |
| |
| 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': ('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'), |
| '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'), |
| |
| # 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""") |
| |
| 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""") |
| |
| labels_footer_left_prefix = Option('labels', 'footer_left_prefix', '', |
| """Text to display before full application name in footers""") |
| |
| labels_footer_left_postfix = Option('labels', 'footer_left_postfix', '', |
| """Text to display after full application name in footers""") |
| |
| labels_footer_right = Option('labels', 'footer_right', '', |
| """Text to use as the right aligned footer""") |
| |
| _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: |
| 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') |
| |
| 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 |
| if data['ticket'].exists: |
| data['changes'] = [{'comment': '', |
| 'author': data['author_id'], |
| 'fields': {u'reported': {'label': u'Reported'}, |
| }, |
| 'permanent': 1, |
| 'cnum': 0, |
| 'date': data['start_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' |
| |
| # 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(_('Browse Source'), |
| href=req.href.wiki('TracRepositoryAdmin'))) |
| |
| |
| 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""") |
| |
| # 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 = {} |
| req = dummy_request(self.env) |
| ticket = Ticket(self.env) |
| tm._populate(req, ticket, False) |
| all_fields = dict([f['name'], f] |
| for f in tm._prepare_fields(req, ticket) |
| if f['type'] == 'select') |
| |
| product_field = all_fields['product'] |
| if product_field: |
| if self.env.product: |
| product_field['value'] = self.env.product.prefix |
| else: |
| # Global scope, now check default_product_prefix is valid |
| default_prefix = self.config.get('multiproduct', |
| 'default_product_prefix') |
| try: |
| ProductEnvironment.lookup_env(self.env, default_prefix) |
| except LookupError: |
| product_field['value'] = product_field['options'][0] |
| else: |
| product_field['value'] = default_prefix |
| |
| 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. |
| """ |
| t = Ticket(self.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(self.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 |