| # -*- 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 |