blob: fa680a022146f180ae3de1969bce110a92554fb4 [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 copy
import os
import shutil
from genshi.builder import tag, Element
from genshi.core import escape, Markup, unescape
from pkg_resources import resource_filename
from trac.attachment import Attachment
from trac.config import Option, PathOption
from trac.core import Component, TracError, implements, Interface
from trac.db import Table, Column, DatabaseManager, Index
import trac.db_default
from trac.env import IEnvironmentSetupParticipant, Environment
from trac.perm import IPermissionRequestor, PermissionCache
from trac.resource import IExternalResourceConnector, IResourceChangeListener,\
IResourceManager, ResourceNotFound
from trac.ticket.api import ITicketFieldProvider, ITicketManipulator
from trac.util.text import to_unicode, unquote_label, unicode_unquote
from trac.web.chrome import ITemplateProvider, add_warning
from trac.web.main import FakePerm, FakeSession
from trac.wiki.admin import WikiAdmin
from trac.wiki.api import IWikiSyntaxProvider
from trac.wiki.parser import WikiParser
from multiproduct.dbcursor import GLOBAL_PRODUCT
from multiproduct.model import Product, ProductResourceMap, ProductSetting
from multiproduct.util import EmbeddedLinkFormatter, IDENTIFIER, \
using_mysql_backend, using_sqlite_backend
from multiproduct.util.translation import _, N_, add_domain
__all__ = ['MultiProductSystem', 'PRODUCT_SYNTAX_DELIMITER']
DB_VERSION = 5
DB_SYSTEM_KEY = 'bloodhound_multi_product_version'
PLUGIN_NAME = 'Bloodhound multi product'
class ISupportMultiProductEnvironment(Interface):
"""Extension point interface for components that are aware of multi
product environment and its specifics.
Component implementing this interface is handled in a special way in the
following scenarios:
* if implementing `IEnvironmentSetupParticipant` interface, the component
will only be invoked once per global environment creation/upgrade. It is
up to the component to install/update it's environment specifics (schema,
possibly files, etc.) for all products. In contrast, components that don't
implement `ISupportMultiProductEnvironment` interface will be, during
install/update, invoked per product environment.
"""
pass
class MultiProductSystem(Component):
"""Creates the database tables and template directories"""
implements(IEnvironmentSetupParticipant, IExternalResourceConnector,
IPermissionRequestor, IResourceChangeListener, IResourceManager,
ISupportMultiProductEnvironment, ITemplateProvider,
ITicketFieldProvider, IWikiSyntaxProvider, ITicketManipulator)
default_product_prefix = Option(
'multiproduct',
'default_product_prefix',
default='@',
doc="""Prefix used for default product when migrating single-product
installations to multi-product.""", doc_domain='multiproduct')
default_product = Option('ticket', 'default_product', '',
"""Default product for newly created tickets.""")
product_base_url = Option('multiproduct', 'product_base_url', '',
"""A pattern used to generate the base URL of product environments,
e.g. the use cases listed in bh:wiki:/Proposals/BEP-0003#url-mapping .
Both absolute as well as relative URLs are supported. The later
will be resolved with respect to the base URL of the parent global
environment. The pattern may contain references to $(envname)s,
$(prefix)s and $(name)s placeholders representing the environment name,
product prefix and product name respectively . If nothing is set the
following will be used `products/$(prefix)s`
Note the usage of `$(...)s` instead of `%(...)s` as the later form
would be interpreted by the ConfigParser itself. """,
doc_domain='multiproduct')
product_config_parent = PathOption('inherit', 'multiproduct', '',
"""The path to the configuration file containing the settings shared
by sibling product environments. By default will inherit
global environment configuration.
""", doc_domain='multiproduct')
SCHEMA = [mcls._get_schema()
for mcls in (Product, ProductResourceMap)]
# Tables which should be migrated (extended with 'product' column)
MIGRATE_TABLES = ['component',
'milestone',
'version',
'enum',
'permission',
'wiki',
'report',
]
PRODUCT_POPULATE_TABLES = list(set(MIGRATE_TABLES) - set(['wiki']))
def __init__(self, *args, **kwargs):
import pkg_resources
locale_dir = pkg_resources.resource_filename(__name__, 'locale')
add_domain(self.env.path, locale_dir)
super(MultiProductSystem, self).__init__(*args, **kwargs)
def get_version(self):
"""Finds the current version of the bloodhound database schema"""
rows = self.env.db_direct_query("""
SELECT value FROM system WHERE name = %s
""", (DB_SYSTEM_KEY,))
return int(rows[0][0]) if rows else -1
# IEnvironmentSetupParticipant methods
def environment_created(self):
"""Insertion of any default data into the database."""
self.log.debug("creating environment for %s plugin." % PLUGIN_NAME)
def environment_needs_upgrade(self, db_dummy=None):
"""Detects if the installed db version matches the running system"""
db_installed_version = self.get_version()
if db_installed_version > DB_VERSION:
raise TracError('''Current db version (%d) newer than supported by
this version of the %s (%d).''' % (db_installed_version,
PLUGIN_NAME,
DB_VERSION))
needs_upgrade = db_installed_version < DB_VERSION
if not needs_upgrade:
self.env.enable_multiproduct_schema(True)
return needs_upgrade
def _update_db_version(self, db, version):
old_version = self.get_version()
if old_version != -1:
self.log.info("Updating multiproduct database schema from version %d"
" to %d" % (old_version, version))
db("""UPDATE system SET value=%s
WHERE name=%s""", (version, DB_SYSTEM_KEY))
else:
self.log.info("Initial multiproduct database schema set to version %d" % version)
db("""
INSERT INTO system (name, value) VALUES ('%s','%s')
""" % (DB_SYSTEM_KEY, version))
return version
_system_wiki_list = None
@property
def system_wiki_list(self):
if MultiProductSystem._system_wiki_list is None:
MultiProductSystem._system_wiki_list = self._get_system_wiki_list()
return MultiProductSystem._system_wiki_list
def _get_system_wiki_list(self):
"""Helper function that enumerates all 'system' wikis. The
list is combined of default wiki pages and pages that are
bundled with Bloodhound dashboard and search plugins"""
from bhdashboard import wiki
paths = [resource_filename('trac.wiki',
'default-pages')] + \
[resource_filename('bhdashboard',
'default-pages')] + \
[resource_filename('bhsearch',
'default-pages')]
pages = []
original_pages = []
for path in paths:
for page in os.listdir(path):
filename = os.path.join(path, page)
page = unicode_unquote(page.encode('utf-8'))
if os.path.isfile(filename):
original_pages.append(page)
for original_name in original_pages:
if original_name.startswith('Trac'):
new_name = wiki.new_name(original_name)
if not new_name:
continue
if new_name in original_pages:
continue
name = new_name
# original trac wikis should also be included in the list
pages.append(original_name)
else:
name = original_name
pages.append(name)
return pages
def upgrade_environment(self, db_dummy=None):
"""Installs or updates tables to current version"""
self.log.debug("upgrading existing environment for %s plugin." %
PLUGIN_NAME)
db_installed_version = self.get_version()
with self.env.db_direct_transaction as db:
if db_installed_version < 1:
self._add_column_product_to_ticket(db)
self._create_multiproduct_tables(db)
db_installed_version = self._update_db_version(db, 1)
if db_installed_version < 2:
self._replace_product_on_ticket_with_product_prefix(db)
db_installed_version = self._update_db_version(db, 2)
if db_installed_version < 3:
SYSTEM_TABLES = ['system']
TICKET_TABLES = [
'ticket_change', 'ticket_custom', 'attachment',
]
table_defs = self._add_product_column_to_tables(
self.MIGRATE_TABLES + TICKET_TABLES + SYSTEM_TABLES,
db_installed_version)
table_columns = self._get_table_columns(table_defs)
create_temp_table = lambda table: self._create_temp_table(
db, table, table_columns, table_defs)
self._insert_default_product(db)
self._upgrade_tickets(db, TICKET_TABLES, create_temp_table)
self._upgrade_wikis(db, create_temp_table)
self._upgrade_system_tables(db, create_temp_table)
self._soft_link_repositories_to_default_product(db)
self._upgrade_table_system(SYSTEM_TABLES, create_temp_table, db)
self._enable_multiproduct_hooks()
db_installed_version = self._update_db_version(db, 3)
if db_installed_version < 4:
self._create_product_tables_for_plugins(db)
db_installed_version = self._update_db_version(db, 4)
if db_installed_version < 5:
table_defs = self._add_product_column_to_tables(
['ticket'], db_installed_version)
self._modify_ticket_pk(db, table_defs)
db_installed_version = self._update_db_version(db, 5)
self.env.enable_multiproduct_schema(True)
def _add_column_product_to_ticket(self, db):
self.log.debug("Adding field product to ticket table")
db("ALTER TABLE ticket ADD COLUMN product TEXT")
def _create_multiproduct_tables(self, db):
self.log.debug("Creating initial db tables for %s plugin." %
PLUGIN_NAME)
db_connector, dummy = DatabaseManager(self.env)._get_connector()
for table in self.SCHEMA:
for statement in db_connector.to_sql(table):
db(statement)
def _replace_product_on_ticket_with_product_prefix(self, db):
for prod in Product.select(self.env):
db("""UPDATE ticket SET product=%s
WHERE product=%s""", (prod.prefix, prod.name))
def _create_temp_table(self, db, table, table_columns, table_defs):
"""creates temporary table with the new schema and
drops original table"""
table_temp_name = '%s_temp' % table
if table == 'report':
cols = ','.join([c for c in table_columns[table] if c != 'id'])
else:
cols = ','.join(table_columns[table])
self.log.info("Migrating table '%s' to a new schema", table)
db("""CREATE TABLE %s AS SELECT %s FROM %s""" %
(table_temp_name, cols, table))
db("""DROP TABLE %s""" % table)
db_connector, _ = DatabaseManager(self.env)._get_connector()
table_schema = [t for t in table_defs if t.name == table][0]
for sql in db_connector.to_sql(table_schema):
db(sql)
return table_temp_name, cols
def _drop_temp_table(self, db, table):
db("""DROP TABLE %s""" % table)
def _add_product_column_to_tables(self, tables, current_version):
"""Extend trac default schema by adding product column
and extending key with product.
"""
table_defs = [copy.deepcopy(t) for t in trac.db_default.schema
if
t.name in tables]
for t in table_defs:
t.columns.append(Column('product'))
if isinstance(t.key, list):
t.key = tuple(t.key) + tuple(['product'])
elif isinstance(t.key, tuple):
t.key = t.key + tuple(['product'])
else:
raise TracError(
"Invalid table '%s' schema key '%s' while upgrading "
"plugin '%s' from version %d to %d'" %
(t.name, t.key, PLUGIN_NAME, current_version, 3))
return table_defs
def _get_table_columns(self, table_defs, all_columns=False):
table_columns = dict()
for table in table_defs:
table_definition = \
[t for t in table_defs if t.name == table.name][0]
column_names = \
[column.name for column in table_definition.columns]
table_columns[table.name] = \
[c for c in column_names if all_columns or c != 'product']
return table_columns
def _insert_default_product(self, db):
self.log.info("Creating default product")
db("""INSERT INTO bloodhound_product (prefix, name, description, owner)
VALUES ('%s', '%s', '%s', '')
""" % (self.default_product_prefix, 'Default', 'Default product'))
def _upgrade_tickets(self, db, TICKET_TABLES, create_temp_table):
# migrate tickets that don't have product assigned to default product
# - update ticket table product column
# - update ticket related tables by:
# - upgrading schema
# - update product column to match ticket's product
self.log.info("Migrating tickets w/o product to default product")
db("""UPDATE ticket SET product='%s'
WHERE (product IS NULL OR product='')
""" % self.default_product_prefix)
self._migrate_attachments(
db("""SELECT a.type, a.id, a.filename
FROM attachment a
INNER JOIN ticket t ON a.id = %(t.id)s
WHERE a.type='ticket'
""" % {'t.id': db.cast('t.id', 'text')}),
to_product=self.default_product_prefix
)
self.log.info("Migrating ticket tables to a new schema")
for table in TICKET_TABLES:
temp_table_name, cols = create_temp_table(table)
db("""INSERT INTO %s (%s, product)
SELECT %s, '' FROM %s""" %
(table, cols, cols, temp_table_name))
self._drop_temp_table(db, temp_table_name)
if table == 'attachment':
db("""UPDATE attachment
SET product=(SELECT ticket.product
FROM ticket
WHERE %(ticket.id)s=attachment.id
LIMIT 1)
WHERE attachment.type='ticket'
AND EXISTS(SELECT ticket.product
FROM ticket
WHERE %(ticket.id)s=attachment.id)
""" % {'ticket.id': db.cast('ticket.id', 'text')})
else:
db("""UPDATE %(table)s
SET product=(SELECT ticket.product
FROM ticket
WHERE ticket.id=%(table)s.ticket)
""" % {'table': table})
def _upgrade_system_tables(self, db, create_temp_table):
# migrate system table (except wiki which is handled separately)
# to a new schema
# - create tables with the new schema
# - populate system tables with global configuration for each product
# - exception is permission table where permissions
# are also populated in global scope
#
# permission table specifics: 'anonymous' and 'authenticated' users
# should by default have a PRODUCT_VIEW permission for all products
self.log.info("Migrating system tables to a new schema")
for table in self.MIGRATE_TABLES:
if table == 'wiki':
continue
temp_table_name, cols = create_temp_table(table)
for product in Product.select(self.env):
self.log.info("Populating table '%s' for product '%s' ('%s')",
table, product.name, product.prefix)
db("""INSERT INTO %s (%s, product) SELECT %s,'%s' FROM %s""" %
(table, cols, cols, product.prefix, temp_table_name))
if table == 'permission':
db.executemany(
"""INSERT INTO permission (username, action, product)
VALUES (%s, %s, %s)""",
[('anonymous', 'PRODUCT_VIEW', product.prefix),
('authenticated', 'PRODUCT_VIEW', product.prefix)])
if table == 'permission':
self.log.info("Populating table '%s' for global scope", table)
db("""INSERT INTO %s (%s, product) SELECT %s,'%s' FROM %s""" %
(table, cols, cols, '', temp_table_name))
self._drop_temp_table(db, temp_table_name)
db.executemany(
"""INSERT INTO permission (username, action, product)
VALUES (%s, %s, %s)""",
[('anonymous', 'PRODUCT_VIEW', ''),
('authenticated', 'PRODUCT_VIEW', '')])
def _upgrade_wikis(self, db, create_temp_table):
# migrate wiki table
# - populate system wikis to all products + global scope
# - update wiki attachment product to match wiki product
table = 'wiki'
temp_table_name, cols = create_temp_table(table)
self.log.info("Migrating wikis to default product")
db("""INSERT INTO %(table)s (%(cols)s, product)
SELECT %(cols)s, '%(default_product)s' FROM %(temp_table)s
""" % dict(table=table,
temp_table=temp_table_name,
cols=cols,
default_product=self.default_product_prefix,))
db("""UPDATE attachment
SET product='%s'
WHERE attachment.type='wiki'
""" % self.default_product_prefix)
self._migrate_attachments(
db("""SELECT type, id, filename
FROM attachment
WHERE type='wiki'
AND product='%s'
""" % (self.default_product_prefix)),
to_product=self.default_product_prefix,
)
self._drop_temp_table(db, temp_table_name)
def _migrate_attachments(self, attachments, to_product=None, copy=False):
for type, id, filename in attachments:
old_path = Attachment._get_path(self.env.path, type, id, filename)
new_path = self.env.path
if to_product:
new_path = os.path.join(new_path, 'products', to_product)
new_path = Attachment._get_path(new_path, type, id, filename)
dirname = os.path.dirname(new_path)
if not os.path.exists(old_path):
self.log.warning(
"Missing attachment files for %s:%s/%s",
type, id, filename)
continue
if os.path.exists(new_path):
# TODO: Do we want to overwrite?
continue
try:
if not os.path.exists(dirname):
os.makedirs(dirname)
if copy:
if hasattr(os, 'link'):
# TODO: It this safe?
os.link(old_path, new_path)
else:
shutil.copy(old_path, new_path)
else:
os.rename(old_path, new_path)
except OSError as err:
self.log.warning(
"Could not move attachment %s from %s %s to"
"product @ (%s)",
filename, type, id, str(err)
)
def _soft_link_repositories_to_default_product(self, db):
# soft link existing repositories to default product
repositories_linked = []
for id, name in db("""SELECT id, value FROM repository
WHERE name='name'"""):
if id in repositories_linked:
continue
db("""INSERT INTO repository (id, name, value)
VALUES (%s, 'product', '%s')""" %
(id, self.default_product_prefix))
repositories_linked.append(id)
self.log.info("Repository '%s' (%s) soft linked to default product",
name, id)
def _upgrade_table_system(self, SYSTEM_TABLES, create_temp_table, db):
# Update system tables
# Upgrade schema
self.log.info("Migrating system tables to a new schema")
for table in SYSTEM_TABLES:
temp_table_name, cols = create_temp_table(table)
db("""INSERT INTO %s (%s, product)
SELECT %s,'' FROM %s""" %
(table, cols, cols, temp_table_name))
self._drop_temp_table(db, temp_table_name)
def _enable_multiproduct_hooks(self):
# enable multi product hooks in environment configuration
config_update = False
if not 'environment_factory' in self.env.config['trac']:
self.env.config['trac'].set('environment_factory',
'multiproduct.hooks.MultiProductEnvironmentFactory')
config_update = True
if not 'request_factory' in self.env.config['trac']:
self.env.config['trac'].set('request_factory',
'multiproduct.hooks.ProductRequestFactory')
config_update = True
if config_update:
self.log.info(
"Enabling multi product hooks in environment configuration")
self.env.config.save()
def _create_product_tables_for_plugins(self, db):
self.log.debug("creating additional db tables for %s plugin." %
PLUGIN_NAME)
db_connector, dummy = DatabaseManager(self.env)._get_connector()
for statement in db_connector.to_sql(ProductSetting._get_schema()):
db(statement)
def _modify_ticket_pk(self, db, table_defs):
self.log.debug("Modifying ticket primary key: id -> uid")
table_columns = self._get_table_columns(table_defs, True)
db_connector, _ = DatabaseManager(self.env)._get_connector()
def rename_id_to_uid(table):
for c in table.columns:
if c.name == 'id':
c.name = 'uid'
break
table.key = ['uid']
def add_new_id_column(table):
id_column = Column('id', type='int', auto_increment=True)
if using_sqlite_backend(self.env) or using_mysql_backend(self.env):
# sqlite and mysql don't support multiple auto increment columns
id_column.auto_increment = False
table.columns.append(id_column)
table.indices.append(Index(['product', 'id'], unique=True))
for t in table_defs:
rename_id_to_uid(t)
add_new_id_column(t)
temp_table_name, cols = self._create_temp_table(
db, t.name, table_columns, table_defs)
db("""INSERT INTO ticket (%s, uid)
SELECT %s, id FROM ticket_temp""" %
(cols, cols))
self._drop_temp_table(db, temp_table_name)
db.update_sequence(db.cursor(), 'ticket', 'id')
db.update_sequence(db.cursor(), 'ticket', 'uid')
# IResourceChangeListener methods
def match_resource(self, resource):
return isinstance(resource, Product)
def resource_created(self, resource, context):
import trac.db_default
from multiproduct.env import EnvironmentStub
# Don't populate product database when running from within test
# environment stub as test cases really don't expect that ...
if isinstance(self.env, EnvironmentStub):
return
product = resource
self.log.debug("Adding product info (%s) to tables:" % product.prefix)
with self.env.db_direct_transaction as db:
# create the default entries for this Product from defaults
for table in trac.db_default.get_data(db):
if not table[0] in self.PRODUCT_POPULATE_TABLES:
continue
self.log.debug(" -> %s" % table[0])
cols = table[1] + ('product', )
rows = [p + (product.prefix, ) for p in table[2]]
db.executemany(
"INSERT INTO %s (%s) VALUES (%s)" %
(table[0], ','.join(cols), ','.join(['%s' for c in cols])),
rows)
# Import default pages in product wiki
wikiadmin = WikiAdmin(ProductEnvironment(self.env, product.prefix))
pages = ('TitleIndex', 'RecentChanges', 'InterTrac', 'InterWiki')
for page in pages:
filename = resource_filename('trac.wiki', 'default-pages/' + page)
wikiadmin.import_page(filename, page)
def resource_changed(self, resource, old_values, context):
return
def resource_deleted(self, resource, context):
return
def resource_version_deleted(self, resource, context):
return
# ITemplateProvider methods
def get_templates_dirs(self):
"""provide the plugin templates"""
return [resource_filename(__name__, 'templates')]
def get_htdocs_dirs(self):
"""proved the plugin htdocs"""
return []
# IPermissionRequestor methods
def get_permission_actions(self):
acts = ['PRODUCT_CREATE', 'PRODUCT_DELETE', 'PRODUCT_MODIFY',
'PRODUCT_VIEW']
if not isinstance(self.env, ProductEnvironment):
return acts + [('PRODUCT_ADMIN', acts)] + [('ROADMAP_ADMIN', acts)]
else:
# In product context PRODUCT_ADMIN will be provided by product env
# to ensure it will always be handy
return acts
# ITicketFieldProvider methods
def get_select_fields(self):
"""Product select fields"""
return [(35, {'name': 'product', 'label': _('Product'),
'cls': Product, 'pk': 'prefix', 'optional': False,
'value': self.default_product})]
def get_radio_fields(self):
"""Product radio fields"""
return []
# IResourceManager methods
def get_resource_realms(self):
"""Manage 'product' realm.
"""
yield 'product'
def get_resource_description(self, resource, format='default', context=None,
**kwargs):
"""Describe product resource.
"""
desc = resource.id
if format != 'compact':
desc = _('Product %(name)s', name=resource.id)
if context:
return self._render_link(context, resource.id, desc)
else:
return desc
def resource_exists(self, resource):
"""Check whether product exists physically.
"""
products = Product.select(self.env, where={'name' : resource.id})
return bool(products)
# IExternalResourceConnector methods
def get_supported_neighborhoods(self):
"""Neighborhoods for `product` and `global` environments.
"""
yield 'product'
yield 'global'
def load_manager(self, neighborhood):
"""Load global environment or product environment given its prefix
"""
if neighborhood._realm == 'global':
# FIXME: ResourceNotFound if neighborhood ID != None ?
prefix = GLOBAL_PRODUCT
elif neighborhood._realm == 'product':
prefix = neighborhood._id
else:
raise ResourceNotFound(_(u'Unsupported neighborhood %(realm)s',
realm=neighborhood._realm))
try:
return lookup_product_env(self.env, prefix)
except LookupError:
raise ResourceNotFound(_(u'Unknown product prefix %(prefix)s',
prefix=prefix))
def manager_exists(self, neighborhood):
"""Check whether the target environment exists physically.
"""
if neighborhood._realm == 'global':
# Global environment
return isinstance(self.env, (Environment, ProductEnvironment))
elif neighborhood._realm == 'product':
prefix = neighborhood._id
if not prefix:
# Global environment
return True
return Product(lookup_product_env(self.env, GLOBAL_PRODUCT),
{'prefix' : prefix})._exists
# IWikiSyntaxProvider methods
short_syntax_delimiter = u'->'
def get_wiki_syntax(self):
yield (r'(?<!\S)!?(?P<pid>%s)%s(?P<ptarget>%s:(?:%s)|%s|%s(?:%s*%s)?)' %
(IDENTIFIER,
PRODUCT_SYNTAX_DELIMITER_RE,
WikiParser.LINK_SCHEME, WikiParser.QUOTED_STRING,
WikiParser.QUOTED_STRING, WikiParser.SHREF_TARGET_FIRST,
WikiParser.SHREF_TARGET_MIDDLE, WikiParser.SHREF_TARGET_LAST),
lambda f, m, fm :
self._format_link(f, 'product',
'%s:%s' % (fm.group('pid'),
unquote_label(fm.group('ptarget'))),
fm.group(0), fm))
if self.env[ProductTicketModule] is not None:
yield (r"(?<!\S)!?(?P<jtp>%s)-(?P<jtt>\d+)(?P<jtf>[?#]\S+)?" %
(IDENTIFIER,),
lambda f, m, fm :
self._format_link(f, 'product',
'%s:ticket:%s' %
(fm.group('jtp'),
fm.group('jtt') +
(fm.group('jtf') or '')),
m, fm))
def get_link_resolvers(self):
yield ('global', self._format_link)
yield ('product', self._format_link)
# ITicketManipulator methods
def validate_ticket(self, req, ticket):
# check whether the owner exists in db, add a warning if not
if req.args.get('action') == 'reassign' and \
ticket['owner'] != self.env.config.get('ticket', 'default_owner'):
owner = self.env.db_direct_query(
"SELECT sid FROM session WHERE sid=%s",
(ticket['owner'], ))
if not owner:
# Note: add_warning() is used intead of returning a list of
# error tuples, since the latter results in trac rendering
# errors (ticket's change.date is not populated)
add_warning(req, _('The user "%s" does not exist.') %
ticket['owner'])
return []
# Internal methods
def _render_link(self, context, name, label, extra='', prefix=None):
"""Render link to product page.
"""
product_env = product = None
env = self.env
if isinstance(env, ProductEnvironment):
if (prefix is not None and env.product.prefix == prefix) \
or (prefix is None and env.name == name):
product_env = env
env = env.parent
try:
if product_env is None:
if prefix is not None:
product_env = ProductEnvironment(env, to_unicode(prefix))
else:
product = Product.select(env,
where={'name' : to_unicode(name)})
if not product:
raise LookupError("Missing product")
product_env = ProductEnvironment(env,
to_unicode(product[0]))
except LookupError:
pass
if product_env is not None:
product = product_env.product
href = resolve_product_href(to_env=product_env, at_env=self.env)
if 'PRODUCT_VIEW' in context.perm(product.resource):
return tag.a(label, class_='product', href=href() + extra,
title=product.name)
if 'PRODUCT_CREATE' in context.perm('product', name):
params = [('action', 'new')]
if prefix:
params.append( ('prefix', prefix) )
if name:
params.append( ('name', name) )
return tag.a(label, class_='missing product',
href=env.href('products', params),
rel='nofollow')
return tag.a(label, class_='missing product')
def _format_link(self, formatter, ns, target, label, fullmatch):
link, params, fragment = formatter.split_link(target)
expr = link.split(':', 1)
if ns == 'product' and len(expr) == 1:
# product:prefix form
return self._render_link(formatter.context, None, label,
params + fragment, expr[0])
elif ns == 'global' or (ns == 'product' and expr[0] == ''):
# global scope
sublink = link if ns == 'global' else expr[1]
target_env = self.env.parent \
if isinstance(self.env, ProductEnvironment) \
else self.env
return self._make_sublink(target_env, sublink, formatter, ns,
target, label, fullmatch,
extra=params + fragment)
else:
# product:prefix:realm:id:...
prefix, sublink = expr
try:
target_env = lookup_product_env(self.env, prefix)
except LookupError:
return tag.a(label, class_='missing product')
# TODO: Check for nested product links
# e.g. product:p1:product:p2:ticket:1
return self._make_sublink(target_env, sublink, formatter, ns,
target, label, fullmatch,
extra=params + fragment)
FakePermClass = FakePerm
def _make_sublink(self, env, sublink, formatter, ns, target, label,
fullmatch, extra=''):
parent_match = {'ns' : ns,
'target' : target,
'label': Markup(escape(unescape(label)
if isinstance(label, Markup)
else label)),
'fullmatch' : fullmatch,
}
# Tweak nested context to work in target product/global scope
subctx = formatter.context.child()
subctx.href = resolve_product_href(to_env=env, at_env=self.env)
try:
req = formatter.context.req
except AttributeError:
pass
else:
# Authenticate in local context but use foreign permissions
subctx.perm = self.FakePermClass() \
if isinstance(req.session, FakeSession) \
else PermissionCache(env, req.authname)
subctx.req = req
subformatter = EmbeddedLinkFormatter(env, subctx, parent_match)
subformatter.auto_quote = True
ctxtag = '[%s] ' % (env.product.prefix,) \
if isinstance(env, ProductEnvironment) \
else '<global> '
subformatter.enhance_link = lambda link : (
link(title=ctxtag + link.attrib.get('title'))
if isinstance(link, Element)
and 'title' in link.attrib
else link)
link = subformatter.match(sublink + extra)
if link:
return link
else:
# Return outermost match unchanged like if it was !-escaped
for itype, match in fullmatch.groupdict().items():
if match and not itype in formatter.wikiparser.helper_patterns:
return escape(match)
PRODUCT_SYNTAX_DELIMITER = MultiProductSystem.short_syntax_delimiter
PRODUCT_SYNTAX_DELIMITER_RE = ''.join('[%s]' % c
for c in PRODUCT_SYNTAX_DELIMITER)
from multiproduct.env import ProductEnvironment, lookup_product_env, \
resolve_product_href
from multiproduct.ticket.web_ui import ProductTicketModule