class BotTrapCheck(GenericRegistrationInspector): _domain = 'acct_mgr' _description = cleandoc_(""" A collection of simple bot checks. ''This check is bypassed for requests by an authenticated user.'' """) reg_basic_question = Option( 'account-manager', 'register_basic_question', '', doc="A question to ask instead of the standard prompt, to which " "the value of register_basic_token is the answer. Setting to " "empty string (default value) keeps the standard prompt.") reg_basic_token = Option( 'account-manager', 'register_basic_token', '', doc="A string required as input to pass verification.") def render_registration_fields(self, req, data): """Add a hidden text input field to the registration form, and a visible one with mandatory input as well, if token is configured. """ if self.reg_basic_token: # Preserve last input for editing on failure instead of typing # everything again. old_value = req.args.get('basic_token', '') if self.reg_basic_question: # TRANSLATOR: Question-style hint for visible bot trap # registration input field. hint = tag.p(_("Please answer above: %(question)s", question=self.reg_basic_question), class_='hint') else: # TRANSLATOR: Verbatim token hint for visible bot trap # registration input field. hint = tag.p(tag_( "Please type [%(token)s] as verification token, " "exactly replicating everything within the braces.", token=tag.b(self.reg_basic_token)), class_='hint') insert = tag( tag.label(_("Parole:"), tag.input(type='text', name='basic_token', size=20, class_='textwidget', value=old_value)), hint) else: insert = None # TRANSLATOR: Registration form hint for hidden bot trap input field. insert = tag(insert, tag.input(type='hidden', name='sentinel', title=_("Better do not fill this field."))) return insert, data def validate_registration(self, req): if req.authname and req.authname != 'anonymous': return # Input must be an exact replication of the required token. basic_token = req.args.get('basic_token', '') # Unlike the former, the hidden bot-trap input field must stay empty. keep_empty = req.args.get('sentinel', '') if keep_empty or self.reg_basic_token and \ self.reg_basic_token != basic_token: raise RegistrationError(N_("Are you human? If so, try harder!"))
class AuthzSourcePolicy(Component): """Permission policy for `source:` and `changeset:` resources using a Subversion authz file. `FILE_VIEW` and `BROWSER_VIEW` permissions are granted as specified in the authz file. `CHANGESET_VIEW` permission is granted for changesets where `FILE_VIEW` is granted on at least one modified file, as well as for empty changesets. """ implements(IPermissionPolicy) authz_file = PathOption('svn', 'authz_file', '', """The path to the Subversion [%(svnbook)s authorization (authz) file]. To enable authz permission checking, the `AuthzSourcePolicy` permission policy must be added to `[trac] permission_policies`. Non-absolute paths are relative to the Environment `conf` directory. """, doc_args={ 'svnbook': 'http://svnbook.red-bean.com/en/1.7/' 'svn.serverconfig.pathbasedauthz.html' }) authz_module_name = Option( 'svn', 'authz_module_name', '', """The module prefix used in the `authz_file` for the default repository. If left empty, the global section is used. """) _handled_perms = frozenset([(None, 'BROWSER_VIEW'), (None, 'CHANGESET_VIEW'), (None, 'FILE_VIEW'), (None, 'LOG_VIEW'), ('source', 'BROWSER_VIEW'), ('source', 'FILE_VIEW'), ('source', 'LOG_VIEW'), ('changeset', 'CHANGESET_VIEW')]) def __init__(self): self._mtime = 0 self._authz = {} self._users = set() # IPermissionPolicy methods def check_permission(self, action, username, resource, perm): realm = resource.realm if resource else None if (realm, action) in self._handled_perms: authz, users = self._get_authz_info() if authz is None: return False if username == 'anonymous': usernames = '$anonymous', '*' else: usernames = username, '$authenticated', '*' if resource is None: return True if users & set(usernames) else None rm = RepositoryManager(self.env) try: repos = rm.get_repository(resource.parent.id) except TracError: return True # Allow error to be displayed in the repo index if repos is None: return True modules = [resource.parent.id or self.authz_module_name] if modules[0]: modules.append('') def check_path_0(spath): sections = [ authz.get(module, {}).get(spath) for module in modules ] sections = [section for section in sections if section] denied = False for user in usernames: for section in sections: if user in section: if section[user]: return True denied = True # Don't check section without module name # because the section with module name defines # the user's permissions. break if denied: # All users has no readable permission. return False def check_path(path): path = '/' + pathjoin(repos.scope, path) if path != '/': path += '/' # Allow access to parent directories of allowed resources for spath in set( sum((list(authz.get(module, {})) for module in modules), [])): if spath.startswith(path): result = check_path_0(spath) if result is True: return True # Walk from resource up parent directories for spath in parent_iter(path): result = check_path_0(spath) if result is not None: return result if realm == 'source': return check_path(resource.id) elif realm == 'changeset': changes = list(repos.get_changeset(resource.id).get_changes()) if not changes or any( check_path(change[0]) for change in changes): return True def _get_authz_info(self): if not self.authz_file: self.log.error("The [svn] authz_file configuration option in " "trac.ini is empty or not defined") raise ConfigurationError() try: mtime = os.path.getmtime(self.authz_file) except OSError as e: self.log.error( "Error accessing svn authz permission policy " "file: %s", exception_to_unicode(e)) raise ConfigurationError() if mtime != self._mtime: self._mtime = mtime rm = RepositoryManager(self.env) modules = set(repos.reponame for repos in rm.get_real_repositories()) if '' in modules and self.authz_module_name: modules.add(self.authz_module_name) modules.add('') self.log.info("Parsing authz file: %s", self.authz_file) try: self._authz = parse(self.authz_file, modules) except ParsingError as e: self.log.error( "Error parsing svn authz permission policy " "file: %s", exception_to_unicode(e)) raise ConfigurationError() else: self._users = { user for paths in self._authz.itervalues() for path in paths.itervalues() for user, result in path.iteritems() if result } return self._authz, self._users
def get_estimation_suffix(): return Option('estimation-tools', 'estimation_suffix', 'h', doc="""Suffix used for estimations. Defaults to 'h'""")
class AuthCaptcha(Component): ### class data implements(IRequestFilter, ITemplateStreamFilter, ITemplateProvider, IAuthenticator, IEnvironmentSetupParticipant, IRequireComponents, INavigationContributor) dict_file = Option( 'captchaauth', 'dictionary_file', default= "http://java.sun.com/docs/books/tutorial/collections/interfaces/examples/dictionary.txt" ) captcha_type = Option('captchaauth', 'type', default="png") realms = ListOption('captchaauth', 'realms', default="wiki, newticket") permissions = { 'wiki': ['WIKI_CREATE', 'WIKI_MODIFY'], 'newticket': ['TICKET_CREATE'] } xpath = {'ticket.html': "//div[@class='buttons']"} delete = {'ticket.html': "//div[@class='field']"} ### IRequestFilter methods def pre_process_request(self, req, handler): """Called after initial handler selection, and can be used to change the selected handler or redirect request. Always returns the request handler, even if unchanged. """ if req.method == 'GET': if req.path_info.strip('/') in ['register', 'login' ] and req.authname != 'anonymous': login_module = LoginModule(self.env) login_module._do_logout(req) req.redirect(req.href(req.path_info)) if req.method == 'POST': realm = self.realm(req) # set the session data for name and email if CAPTCHA-authenticated if 'captchaauth' in req.args: name, email = self.identify(req) for field in 'name', 'email': value = locals()[field] if value: req.session[field] = value req.session.save() if req.authname != 'anonymous' and realm == 'newticket': req.args['author'] = name if email: req.args['author'] += ' <%s>' % email # redirect anonymous user posts that are not CAPTCHA-identified if req.authname == 'anonymous' and realm in self.realms: if 'captchaauth' in req.args and 'captchaid' in req.args: # add warnings from CAPTCHA authentication captcha = self.captcha(req) if req.args['captchaauth'] != captcha: add_warning( req, "You typed the wrong word. Please try again.") try: # delete used CAPTCHA execute_non_query( self.env, "DELETE FROM captcha WHERE id=%s", req.args['captchaid']) except: pass name, email = self.identify(req) if not name: add_warning(req, 'Please provide your name') if AccountManager and name in AccountManager( self.env).get_users(): add_warning( req, '%s is already taken as by a registered user. Please login or use a different name' % name) # redirect to previous location location = req.get_header('referer') if location: location, query = urllib.splitquery(location) if realm == 'newticket': args = [(key.split('field_', 1)[-1], value) for key, value in req.args.items() if key.startswith('field_')] location += '?%s' % urllib.urlencode(args) else: location = req.href() req.redirect(location) return handler # for ClearSilver templates def post_process_request(self, req, template, content_type): """Do any post-processing the request might need; typically adding values to req.hdf, or changing template or mime type. Always returns a tuple of (template, content_type), even if unchanged. Note that `template`, `content_type` will be `None` if: - called when processing an error page - the default request handler did not return any result (for 0.10 compatibility; only used together with ClearSilver templates) """ return (template, content_type) # for Genshi templates def post_process_request(self, req, template, data, content_type): """Do any post-processing the request might need; typically adding values to the template `data` dictionary, or changing template or mime type. `data` may be update in place. Always returns a tuple of (template, data, content_type), even if unchanged. Note that `template`, `data`, `content_type` will be `None` if: - called when processing an error page - the default request handler did not return any result (Since 0.11) """ return (template, data, content_type) ### ITemplateStreamFilter method def filter_stream(self, req, method, filename, stream, data): """Return a filtered Genshi event stream, or the original unfiltered stream if no match. `req` is the current request object, `method` is the Genshi render method (xml, xhtml or text), `filename` is the filename of the template to be rendered, `stream` is the event stream and `data` is the data for the current template. See the Genshi documentation for more information. """ # only show CAPTCHAs for anonymous users if req.authname != 'anonymous': return stream # only put CAPTCHAs in the realms specified realm = self.realm(req) if realm not in self.realms: return stream # add the CAPTCHA to the stream if filename in self.xpath: # store CAPTCHA in DB and session word = random_word(self.dict_file) insert_update(self.env, 'captcha', 'id', req.session.sid, dict(word=word)) req.session['captcha'] = word req.session.save() # render the template chrome = Chrome(self.env) template = chrome.load_template('captcha.html') _data = {} # CAPTCHA type if self.captcha_type == 'png': captcha = tag.img(None, src=req.href('captcha.png')) else: captcha = Markup(skimpyAPI.Pre(word).data()) _data['captcha'] = captcha _data['email'] = req.session.get('email', '') _data['name'] = req.session.get('name', '') _data['captchaid'] = req.session.sid xpath = self.xpath[filename] stream |= Transformer(xpath).before(template.generate(**_data)) if filename in self.delete: stream |= Transformer(self.delete[filename]).remove() return stream ### methods for ITemplateProvider """Extension point interface for components that provide their own ClearSilver templates and accompanying static resources. """ def get_htdocs_dirs(self): """Return a list of directories with static resources (such as style sheets, images, etc.) Each item in the list must be a `(prefix, abspath)` tuple. The `prefix` part defines the path in the URL that requests to these resources are prefixed with. The `abspath` is the absolute path to the directory containing the resources on the local file system. """ return [] def get_templates_dirs(self): """Return a list of directories containing the provided template files. """ return [resource_filename(__name__, 'templates')] ### method for IAuthenticator """Extension point interface for components that can provide the name of the remote user.""" def authenticate(self, req): """Return the name of the remote user, or `None` if the identity of the user is unknown.""" # check for an authenticated user login_module = LoginModule(self.env) remote_user = login_module.authenticate(req) if remote_user: return remote_user # authenticate via a CAPTCHA if 'captchaauth' in req.args and 'captchaid' in req.args: # ensure CAPTCHA identification captcha = self.captcha(req) if captcha != req.args['captchaauth']: return # ensure sane identity name, email = self.identify(req) if name is None: return if AccountManager and name in AccountManager(self.env).get_users(): return # delete used CAPTCHA on success try: execute_non_query(self.env, "DELETE FROM captcha WHERE id=%s", req.args['captchaid']) except: pass # log the user in req.environ['REMOTE_USER'] = name login_module._do_login(req) ### methods for INavigationContributor """Extension point interface for components that contribute items to the navigation. """ def get_active_navigation_item(self, req): """This method is only called for the `IRequestHandler` processing the request. It should return the name of the navigation item that should be highlighted as active/current. """ return None def get_navigation_items(self, req): """Should return an iterable object over the list of navigation items to add, each being a tuple in the form (category, name, text). """ if req.authname != 'anonymous' and 'captcha' in req.session: return [('metanav', '_login', tag.a("Login", href=req.href.login())), ('metanav', '_register', tag.a("Register", href=req.href.register()))] return [] ### methods for IEnvironmentSetupParticipant """Extension point interface for components that need to participate in the creation and upgrading of Trac environments, for example to create additional database tables.""" def environment_created(self): """Called when a new Trac environment is created.""" if self.environment_needs_upgrade(None): self.upgrade_environment(None) def environment_needs_upgrade(self, db): """Called when Trac checks whether the environment needs to be upgraded. Should return `True` if this participant needs an upgrade to be performed, `False` otherwise. """ try: get_table(self.env, 'captcha') except: return True return False def upgrade_environment(self, db): """Actually perform an environment upgrade. Implementations of this method should not commit any database transactions. This is done implicitly after all participants have performed the upgrades they need without an error being raised. """ # table of CAPTCHAs captcha_table = Table('captcha', key='key')[Column('id'), Column('word')] create_table(self.env, captcha_table) ### method for IRequireComponents def requires(self): """list of component classes that this component depends on""" return [ImageCaptcha] ### internal methods def identify(self, req): """ identify the user, ensuring uniqueness (TODO); returns a tuple of (name, email) or success or None """ name = req.args.get('name', None).strip() email = req.args.get('email', None).strip() return name, email def realm(self, req): """ returns the realm according to the request """ path = req.path_info.strip('/').split('/') if not path: return # TODO: default handler ('/') return path[0] def captcha(self, req): return get_scalar(self.env, "SELECT word FROM captcha WHERE id=%s", 0, req.args['captchaid'])
class TagInputAutoComplete(TagTemplateProvider): """[opt] Provides auto-complete functionality for tag input fields. This module is based on KeywordSuggestModule from KeywordSuggestPlugin 0.5dev. """ implements(IRequestFilter, ITemplateStreamFilter) field_opt = Option( 'tags', 'complete_field', 'keywords', "Ticket field to which a drop-down tag list should be attached.") help_opt = Option( 'tags', 'ticket_help', None, "If specified, 'keywords' label on ticket view will be turned into a " "link to this URL.") helpnewwindow_opt = BoolOption( 'tags', 'ticket_help_newwindow', False, "If true and keywords_help specified, wiki page will open in a new " "window. Default is false.") # Needs to be reimplemented, refs th:#8141. #mustmatch = BoolOption('tags', 'complete_mustmatch', False, # "If true, input fields accept values from the word list only.") matchcontains_opt = BoolOption( 'tags', 'complete_matchcontains', True, "Include partial matches in suggestion list. Default is true.") separator_opt = Option( 'tags', 'separator', ' ', "Character(s) to use as separators between tags. Default is a " "single whitespace.") sticky_tags_opt = ListOption( 'tags', 'complete_sticky_tags', '', ',', doc="A list of comma separated values available for input.") def __init__(self): self.tags_enabled = self.env.is_enabled(TagSystem) @property def separator(self): return self.separator_opt.strip('\'') or ' ' # IRequestFilter methods def pre_process_request(self, req, handler): return handler def post_process_request(self, req, template, data, content_type): if template is not None and \ (req.path_info.startswith('/ticket/') or req.path_info.startswith('/newticket') or (self.tags_enabled and req.path_info.startswith('/wiki/'))): # In Trac 1.0 and later, jQuery-UI is included from the core. if trac_version >= '1.0': Chrome(self.env).add_jquery_ui(req) else: add_script(req, 'tags/js/jquery-ui-1.8.16.custom.min.js') add_stylesheet(req, 'tags/css/jquery-ui-1.8.16.custom.css') return template, data, content_type # ITemplateStreamFilter method def filter_stream(self, req, method, filename, stream, data): if not (filename == 'ticket.html' or (self.tags_enabled and filename == 'wiki_edit.html')): return stream keywords = self._get_keywords_string(req) if not keywords: self.log.debug( "No keywords found. TagInputAutoComplete is disabled.") return stream matchfromstart = '"^" +' if self.matchcontains_opt: matchfromstart = '' js = """ jQuery(document).ready(function($) { var keywords = [ %(keywords)s ] var sep = '%(separator)s'.trim() + ' ' function split( val ) { return val.split( /%(separator)s\s*|\s+/ ); } function extractLast( term ) { return split( term ).pop(); } $('%(field)s') // don't navigate away from field on tab when selecting // an item .bind( "keydown", function( event ) { if ( event.keyCode === $.ui.keyCode.TAB && $( this ).data( "autocomplete" ).menu.active ) { event.preventDefault(); } }) .autocomplete({ delay: 0, minLength: 0, source: function( request, response ) { // delegate back to autocomplete, but extract // the last term response( $.ui.autocomplete.filter( keywords, extractLast( request.term ) ) ); }, focus: function() { // prevent value inserted on focus return false; }, select: function( event, ui ) { var terms = split( this.value ); // remove the current input terms.pop(); // add the selected item terms.push( ui.item.value ); // add placeholder to get the comma-and-space at // the end terms.push( "" ); this.value = terms.join( sep ); return false; } }); });""" # Inject transient part of JavaScript into ticket.html template. if req.path_info.startswith('/ticket/') or \ req.path_info.startswith('/newticket'): js_ticket = js % { 'field': '#field-' + self.field_opt, 'keywords': keywords, 'matchfromstart': matchfromstart, 'separator': self.separator } stream = stream | Transformer('.//head')\ .append(builder.script(Markup(js_ticket), type='text/javascript')) # Turn keywords field label into link to an arbitrary resource. if self.help_opt: link = self._get_help_link(req) if self.helpnewwindow_opt: link = builder.a(href=link, target='blank') else: link = builder.a(href=link) xpath = '//label[@for="field-keywords"]/text()' stream = stream | Transformer(xpath).wrap(link) # Inject transient part of JavaScript into wiki.html template. elif self.tags_enabled and req.path_info.startswith('/wiki/'): js_wiki = js % { 'field': '#tags', 'keywords': keywords, 'matchfromstart': matchfromstart, 'separator': self.separator } stream = stream | Transformer('.//head')\ .append(builder.script(Markup(js_wiki), type='text/javascript')) return stream # Private methods def _get_keywords_string(self, req): keywords = set(self.sticky_tags_opt) # prevent duplicates if self.tags_enabled: # Use TagsPlugin >= 0.7 performance-enhanced API. tags = TagSystem(self.env).get_all_tags(req) keywords.update(tags.keys()) if keywords: keywords = sorted(keywords) keywords = ','.join( ("'%s'" % javascript_quote(_keyword) for _keyword in keywords)) else: keywords = '' return keywords def _get_help_link(self, req): link = resource_id = None if self.help_opt.startswith('/'): # Assume valid URL to arbitrary resource inside # of the current Trac environment. link = req.href(self.help_opt) if not link and ':' in self.help_opt: realm, resource_id = self.help_opt.split(':', 1) # Validate realm-like prefix against resource realm list, # but exclude 'wiki' to allow deferred page creation. rsys = ResourceSystem(self.env) if realm in set(rsys.get_known_realms()) - set('wiki'): mgr = rsys.get_resource_manager(realm) # Handle optional IResourceManager method gracefully. try: if mgr.resource_exists(Resource(realm, resource_id)): link = mgr.get_resource_url(resource_id, req.href) except AttributeError: # Assume generic resource URL build rule. link = req.href(realm, resource_id) if not link: if not resource_id: # Assume wiki page name for backwards-compatibility. resource_id = self.help_opt # Preserve anchor without 'path_safe' arg (since Trac 0.12.2dev). if '#' in resource_id: path, anchor = resource_id.split('#', 1) else: anchor = None path = resource_id if hasattr(unicode_quote_plus, "safe"): # Use method for query string quoting (since Trac 0.13dev). anchor = unicode_quote_plus(anchor, safe="?!~*'()") else: anchor = unicode_quote_plus(anchor) link = '#'.join([req.href.wiki(path), anchor]) return link
class TracJSGanttSupport(Component): implements(IRequestFilter, ITemplateProvider) Option('trac-jsgantt', 'option.format', 'day', """Initial format of Gantt chart""") Option('trac-jsgantt', 'option.formats', 'day|week|month|quarter', """Formats to show for Gantt chart""") IntOption('trac-jsgantt', 'option.sample', 0, """Show sample Gantt""") IntOption('trac-jsgantt', 'option.res', 1, """Show resource column""") IntOption('trac-jsgantt', 'option.dur', 1, """Show duration column""") IntOption('trac-jsgantt', 'option.comp', 1, """Show percent complete column""") Option('trac-jsgantt', 'option.caption', 'Resource', """Caption to follow task in Gantt""") IntOption('trac-jsgantt', 'option.startDate', 1, """Show start date column""") IntOption('trac-jsgantt', 'option.endDate', 1, """Show finish date column""") Option('trac-jsgantt', 'option.dateDisplay', 'mm/dd/yyyy', """Format to display dates""") IntOption('trac-jsgantt', 'option.openLevel', 999, """How many levels of task hierarchy to show open""") IntOption('trac-jsgantt', 'option.expandClosedTickets', 1, """Show children of closed tasks in the task hierarchy""") Option('trac-jsgantt', 'option.colorBy', 'priority', """Field to use to color tasks""") IntOption('trac-jsgantt', 'option.lwidth', None, """Width (in pixels) of left table""") IntOption('trac-jsgantt', 'option.showdep', 1, """Show dependencies in Gantt""") IntOption('trac-jsgantt', 'option.userMap', 1, """Map user IDs to user names""") IntOption('trac-jsgantt', 'option.omitMilestones', 0, """Omit milestones""") Option('trac-jsgantt', 'option.schedule', 'alap', """Schedule algorithm: alap or asap""") IntOption('trac-jsgantt', 'option.doResourceLeveling', 0, """Resource level (1) or not (0)""") # This seems to be the first floating point option. Option('trac-jsgantt', 'option.hoursPerDay', '8.0', """Hours worked per day""") Option( 'trac-jsgantt', 'option.display', None, """Display filter for tickets in the form 'field1:value1|field2:value2' or 'field:value1|value2'; displays tickets where field1==value1, etc.""" ) Option( 'trac-jsgantt', 'option.order', 'wbs', """Fields to sort tasks by before display. May include tickets fields (including custom fields) or 'wbs'.""" ) Option('trac-jsgantt', 'option.scrollTo', None, """Date to scroll chart to (yyyy-mm--dd or 'today')""") Option( 'trac-jsGantt', 'option.linkStyle', 'standard', """Style for ticket links; jsgantt (new window) or standard browser behavior like ticket links.""" ) # ITemplateProvider methods def get_htdocs_dirs(self): return [('tracjsgantt', resource_filename(__name__, 'htdocs'))] def get_templates_dirs(self): return [] # IRequestFilter methods def pre_process_request(self, req, handler): # I think we should look for a TracJSGantt on the page and set # a flag for the post_process_request handler if found return handler def post_process_request(self, req, template, data, content_type): add_script(req, 'tracjsgantt/jsgantt.js') add_stylesheet(req, 'tracjsgantt/jsgantt.css') add_stylesheet(req, 'tracjsgantt/tracjsgantt.css') return template, data, content_type
class CodeReviewerModule(Component): """Base component for reviewing changesets.""" implements(ITemplateProvider, IRequestFilter) # config options statuses = ListOption('codereviewer', 'status_choices', default=CodeReview.STATUSES, doc="Review status choices.") passed = ListOption('codereviewer', 'passed', default=[], doc="Ticket field changes on a PASSED submit.") failed = ListOption('codereviewer', 'failed', default=[], doc="Ticket field changes on a FAILED submit.") completeness = ListOption( 'codereviewer', 'completeness', default=[], doc="Ticket field values enabling ticket completeness.") command = Option('codereviewer', 'command', default='', doc="Command to execute upon ticket completeness.") # ITemplateProvider methods def get_htdocs_dirs(self): from pkg_resources import resource_filename return [('coderev', resource_filename(__name__, 'htdocs'))] def get_templates_dirs(self): return [] # IRequestFilter methods def pre_process_request(self, req, handler): return handler def post_process_request(self, req, template, data, content_type): if data is None: return template, data, content_type if req.path_info.startswith('/changeset') and \ data.get('changeset') is not False and \ 'CODEREVIEWER_MODIFY' in req.perm: changeset = data['changeset'] repos = changeset.repos reponame, rev = repos.reponame, repos.db_rev(changeset.rev) review = CodeReview(self.env, reponame, rev) tickets = req.args.getlist('tickets') if req.method == 'POST': status_changed = \ review.encode(req.args['status']) != review.status if review.save(req.authname, req.args['status'], req.args['summary']): self._update_tickets(changeset, review, status_changed) tickets = review.tickets req.redirect(req.href(req.path_info, tickets=tickets)) ctx = web_context(req) format_summary = functools.partial(format_to_html, self.env, ctx, escape_newlines=True) format_time = functools.partial(user_time, req, format_datetime) add_stylesheet(req, 'coderev/coderev.css') add_script(req, 'coderev/coderev.js') add_script_data( req, { 'review': { 'status': review.status, 'encoded_status': review.encode(review.status), 'summaries': [ dict([ ('html_summary', format_summary(r['summary'])), ('pretty_when', format_time(r['when'])), ('pretty_timedelta', pretty_timedelta( r['when'])), ('reviewer', r['reviewer']), ('status', r['status']) ]) for r in CodeReview.select(self.env, reponame, rev) ], }, 'tickets': tickets, 'statuses': self.statuses, 'form_token': req.form_token, }) req.send_header('Cache-Control', 'no-cache') elif req.path_info.startswith('/ticket/'): add_stylesheet(req, 'coderev/coderev.css') return template, data, content_type # Private methods def _update_tickets(self, changeset, review, status_changed): """Updates the tickets referenced by the given review's changeset with a comment of field changes. Field changes and command execution may occur if specified in trac.ini and the review's changeset is the last one of the ticket.""" status = review.encode(review.status).lower() # build comment comment = None if status_changed or review['summary']: if status_changed: comment = "Code review set to %s" % review['status'] else: comment = "Code review comment" repos = changeset.repos ref = review.changeset disp_ref = str(repos.short_rev(review.changeset)) if review.repo: ref += '/' + review.repo disp_ref += '/' + review.repo comment += ' for [changeset:"%s" %s]' % (ref, disp_ref) if review['summary']: comment += ":\n\n%s" % review['summary'] invoked = False for ticket in review.tickets: tkt = Ticket(self.env, ticket) # determine ticket changes changes = {} if self._is_complete(ticket, review, failed_ok=True): changes = self._get_ticket_changes(tkt, status) # update ticket if there's a review summary or ticket changes if comment or changes: for field, value in changes.items(): tkt[field] = value tkt.save_changes(review['reviewer'], comment) # check to invoke command if not invoked and self._is_complete(ticket, review): self._execute_command() invoked = True def _is_complete(self, ticket, review, failed_ok=False): """Returns True if the ticket is complete (or only the last review failed if ok_failed is True) and therefore actions (e.g., ticket changes and executing commands) should be taken. A ticket is complete when its completeness criteria is met and the review has PASSED and is the ticket's last review with no other PENDING reviews. Completeness criteria is defined in trac.ini like this: completeness = phase=(codereview|verifying|releasing) The above means that the ticket's phase field must have a value of either codereview, verifying, or releasing for the ticket to be considered complete. This helps prevent actions from being taken if there's a code review of partial work before the ticket is really ready to be fully tested and released. """ # check review's completeness reason = is_incomplete(self.env, review, ticket) if failed_ok and reason and CodeReview.NOT_PASSED in reason: return True return not reason def _get_ticket_changes(self, tkt, status): """Return a dict of field-value pairs of ticket fields to change for the given ticket as defined in trac.ini. As one workflow opinion, the changes are processed in order: passed = phase=verifying,owner={captain} In the above example, if the review passed and the ticket's phase already = verifying, then the owner change will not be included. """ changes = {} for group in getattr(self, status, []): if '=' not in group: continue field, value = group.split('=', 1) if value.startswith('{'): value = tkt[value.strip('{}')] if tkt[field] == value: break # no more changes once ticket already has target value changes[field] = value return changes def _execute_command(self): if not self.command: return p = Popen(self.command, shell=True, stderr=STDOUT, stdout=PIPE) out = p.communicate()[0] if p.returncode == 0: self.log.info('command: %s', self.command) else: self.log.error('command error: %s\n%s', self.command, out)
class Environment(Component, ComponentManager): """Trac environment manager. Trac stores project information in a Trac environment. It consists of a directory structure containing among other things: * a configuration file, * project-specific templates and plugins, * the wiki and ticket attachments files, * the SQLite database file (stores tickets, wiki pages...) in case the database backend is SQLite """ implements(ISystemInfoProvider) required = True system_info_providers = ExtensionPoint(ISystemInfoProvider) setup_participants = ExtensionPoint(IEnvironmentSetupParticipant) components_section = ConfigSection( 'components', """Enable or disable components provided by Trac and plugins. The component to enable/disable is specified by the option name. The enabled state is determined by the option value: setting the value to `enabled` or `on` will enable the component, any other value (typically `disabled` or `off`) will disable the component. The option name is either the fully qualified name of the component or the module/package prefix of the component. The former enables/disables a specific component, while the latter enables/disables any component in the specified package/module. Consider the following configuration snippet: {{{#!ini [components] trac.ticket.report.ReportModule = disabled acct_mgr.* = enabled }}} The first option tells Trac to disable the [TracReports report module]. The second option instructs Trac to enable all components in the `acct_mgr` package. The trailing wildcard is required for module/package matching. To view the list of active components, go to the ''Plugins'' section of ''About Trac'' (requires `CONFIG_VIEW` [TracPermissions permission]). See also: TracPlugins """) shared_plugins_dir = PathOption( 'inherit', 'plugins_dir', '', """Path to the //shared plugins directory//. Plugins in that directory are loaded in addition to those in the directory of the environment `plugins`, with this one taking precedence. Non-absolute paths are relative to the Environment `conf` directory. """) base_url = Option( 'trac', 'base_url', '', """Base URL of the Trac site. This is used to produce documents outside of the web browsing context, such as URLs in notification e-mails that point to Trac resources. """) base_url_for_redirect = BoolOption( 'trac', 'use_base_url_for_redirect', False, """Optionally use `[trac] base_url` for redirects. In some configurations, usually involving running Trac behind a HTTP proxy, Trac can't automatically reconstruct the URL that is used to access it. You may need to use this option to force Trac to use the `base_url` setting also for redirects. This introduces the obvious limitation that this environment will only be usable when accessible from that URL, as redirects are frequently used. """) secure_cookies = BoolOption( 'trac', 'secure_cookies', False, """Restrict cookies to HTTPS connections. When true, set the `secure` flag on all cookies so that they are only sent to the server on HTTPS connections. Use this if your Trac instance is only accessible through HTTPS. """) anonymous_session_lifetime = IntOption( 'trac', 'anonymous_session_lifetime', '90', """Lifetime of the anonymous session, in days. Set the option to 0 to disable purging old anonymous sessions. (''since 1.0.17'')""") project_name = Option('project', 'name', 'My Project', """Name of the project.""") project_description = Option('project', 'descr', 'My example project', """Short description of the project.""") project_url = Option( 'project', 'url', '', """URL of the project web site. This is usually the domain in which the `base_url` resides. For example, the project URL might be !https://myproject.com, with the Trac site (`base_url`) residing at either !https://trac.myproject.com or !https://myproject.com/trac. The project URL is added to the footer of notification e-mails. """) project_admin = Option( 'project', 'admin', '', """E-Mail address of the project's administrator.""") project_admin_trac_url = Option( 'project', 'admin_trac_url', '.', """Base URL of a Trac instance where errors in this Trac should be reported. This can be an absolute or relative URL, or '.' to reference this Trac instance. An empty value will disable the reporting buttons. """) project_footer = Option( 'project', 'footer', N_('Visit the Trac open source project at<br />' '<a href="https://trac.edgewall.org/">' 'https://trac.edgewall.org/</a>'), """Page footer text (right-aligned).""") project_icon = Option('project', 'icon', 'common/trac.ico', """URL of the icon of the project.""") log_type = ChoiceOption('logging', 'log_type', log.LOG_TYPES + log.LOG_TYPE_ALIASES, """Logging facility to use. Should be one of (`none`, `file`, `stderr`, `syslog`, `winlog`).""", case_sensitive=False) log_file = Option( 'logging', 'log_file', 'trac.log', """If `log_type` is `file`, this should be a path to the log-file. Relative paths are resolved relative to the `log` directory of the environment.""") log_level = ChoiceOption('logging', 'log_level', log.LOG_LEVELS + log.LOG_LEVEL_ALIASES, """Level of verbosity in log. Should be one of (`CRITICAL`, `ERROR`, `WARNING`, `INFO`, `DEBUG`). """, case_sensitive=False) log_format = Option( 'logging', 'log_format', None, """Custom logging format. If nothing is set, the following will be used: `Trac[$(module)s] $(levelname)s: $(message)s` In addition to regular key names supported by the [http://docs.python.org/library/logging.html Python logger library] one could use: - `$(path)s` the path for the current environment - `$(basename)s` the last path component of the current environment - `$(project)s` the project name Note the usage of `$(...)s` instead of `%(...)s` as the latter form would be interpreted by the !ConfigParser itself. Example: `($(thread)d) Trac[$(basename)s:$(module)s] $(levelname)s: $(message)s` """) def __init__(self, path, create=False, options=[], default_data=True): """Initialize the Trac environment. :param path: the absolute path to the Trac environment :param create: if `True`, the environment is created and otherwise, the environment is expected to already exist. :param options: A list of `(section, name, value)` tuples that define configuration options :param default_data: if `True` (the default), the environment is populated with default data when created. """ ComponentManager.__init__(self) self.path = os.path.normpath(os.path.normcase(path)) self.log = None self.config = None if create: self.create(options, default_data) for setup_participant in self.setup_participants: setup_participant.environment_created() else: self.verify() self.setup_config() def __repr__(self): return '<%s %r>' % (self.__class__.__name__, self.path) @lazy def name(self): """The environment name. :since: 1.2 """ return os.path.basename(self.path) @property def env(self): """Property returning the `Environment` object, which is often required for functions and methods that take a `Component` instance. """ # The cached decorator requires the object have an `env` attribute. return self @property def system_info(self): """List of `(name, version)` tuples describing the name and version information of external packages used by Trac and plugins. """ info = [] for provider in self.system_info_providers: info.extend(provider.get_system_info() or []) return sorted(set(info), key=lambda args: (args[0] != 'Trac', args[0].lower())) # ISystemInfoProvider methods def get_system_info(self): yield 'Trac', self.trac_version yield 'Python', sys.version yield 'setuptools', setuptools.__version__ if pytz is not None: yield 'pytz', pytz.__version__ if hasattr(self, 'webfrontend_version'): yield self.webfrontend, self.webfrontend_version def component_activated(self, component): """Initialize additional member variables for components. Every component activated through the `Environment` object gets three member variables: `env` (the environment object), `config` (the environment configuration) and `log` (a logger object).""" component.env = self component.config = self.config component.log = self.log def _component_name(self, name_or_class): name = name_or_class if not isinstance(name_or_class, str): name = name_or_class.__module__ + '.' + name_or_class.__name__ return name.lower() @lazy def _component_rules(self): _rules = {} for name, value in self.components_section.options(): name = name.rstrip('.*').lower() _rules[name] = as_bool(value) return _rules def is_component_enabled(self, cls): """Implemented to only allow activation of components that are not disabled in the configuration. This is called by the `ComponentManager` base class when a component is about to be activated. If this method returns `False`, the component does not get activated. If it returns `None`, the component only gets activated if it is located in the `plugins` directory of the environment. """ component_name = self._component_name(cls) rules = self._component_rules cname = component_name while cname: enabled = rules.get(cname) if enabled is not None: return enabled idx = cname.rfind('.') if idx < 0: break cname = cname[:idx] # By default, all components in the trac package except # in trac.test or trac.tests are enabled return component_name.startswith('trac.') and \ not component_name.startswith('trac.test.') and \ not component_name.startswith('trac.tests.') or None def enable_component(self, cls): """Enable a component or module.""" self._component_rules[self._component_name(cls)] = True super().enable_component(cls) @contextmanager def component_guard(self, component, reraise=False): """Traps any runtime exception raised when working with a component and logs the error. :param component: the component responsible for any error that could happen inside the context :param reraise: if `True`, an error is logged but not suppressed. By default, errors are suppressed. """ try: yield except TracError as e: self.log.warning("Component %s failed with %s", component, exception_to_unicode(e)) if reraise: raise except Exception as e: self.log.error("Component %s failed with %s", component, exception_to_unicode(e, traceback=True)) if reraise: raise def verify(self): """Verify that the provided path points to a valid Trac environment directory.""" try: with open(os.path.join(self.path, 'VERSION'), encoding='utf-8') as f: tag = f.readline().rstrip() except Exception as e: raise TracError( _("No Trac environment found at %(path)s\n" "%(e)s", path=self.path, e=exception_to_unicode(e))) if tag != _VERSION: raise TracError( _("Unknown Trac environment type '%(type)s'", type=tag)) @lazy def db_exc(self): """Return an object (typically a module) containing all the backend-specific exception types as attributes, named according to the Python Database API (http://www.python.org/dev/peps/pep-0249/). To catch a database exception, use the following pattern:: try: with env.db_transaction as db: ... except env.db_exc.IntegrityError as e: ... """ return DatabaseManager(self).get_exceptions() @property def db_query(self): """Return a context manager (`~trac.db.api.QueryContextManager`) which can be used to obtain a read-only database connection. Example:: with env.db_query as db: cursor = db.cursor() cursor.execute("SELECT ...") for row in cursor.fetchall(): ... Note that a connection retrieved this way can be "called" directly in order to execute a query:: with env.db_query as db: for row in db("SELECT ..."): ... :warning: after a `with env.db_query as db` block, though the `db` variable is still defined, you shouldn't use it as it might have been closed when exiting the context, if this context was the outermost context (`db_query` or `db_transaction`). If you don't need to manipulate the connection itself, this can even be simplified to:: for row in env.db_query("SELECT ..."): ... """ return QueryContextManager(self) @property def db_transaction(self): """Return a context manager (`~trac.db.api.TransactionContextManager`) which can be used to obtain a writable database connection. Example:: with env.db_transaction as db: cursor = db.cursor() cursor.execute("UPDATE ...") Upon successful exit of the context, the context manager will commit the transaction. In case of nested contexts, only the outermost context performs a commit. However, should an exception happen, any context manager will perform a rollback. You should *not* call `commit()` yourself within such block, as this will force a commit even if that transaction is part of a larger transaction. Like for its read-only counterpart, you can directly execute a DML query on the `db`:: with env.db_transaction as db: db("UPDATE ...") :warning: after a `with env.db_transaction` as db` block, though the `db` variable is still available, you shouldn't use it as it might have been closed when exiting the context, if this context was the outermost context (`db_query` or `db_transaction`). If you don't need to manipulate the connection itself, this can also be simplified to:: env.db_transaction("UPDATE ...") """ return TransactionContextManager(self) def shutdown(self, tid=None): """Close the environment.""" from trac.versioncontrol.api import RepositoryManager RepositoryManager(self).shutdown(tid) DatabaseManager(self).shutdown(tid) if tid is None: log.shutdown(self.log) def create(self, options=[], default_data=True): """Create the basic directory structure of the environment, initialize the database and populate the configuration file with default values. If options contains ('inherit', 'file'), default values will not be loaded; they are expected to be provided by that file or other options. :raises TracError: if the base directory of `path` does not exist. :raises TracError: if `path` exists and is not empty. """ base_dir = os.path.dirname(self.path) if not os.path.exists(base_dir): raise TracError( _( "Base directory '%(env)s' does not exist. Please create it " "and retry.", env=base_dir)) if os.path.exists(self.path) and os.listdir(self.path): raise TracError(_("Directory exists and is not empty.")) # Create the directory structure if not os.path.exists(self.path): os.mkdir(self.path) os.mkdir(self.htdocs_dir) os.mkdir(self.log_dir) os.mkdir(self.plugins_dir) os.mkdir(self.templates_dir) # Create a few files create_file(os.path.join(self.path, 'VERSION'), _VERSION + '\n') create_file( os.path.join(self.path, 'README'), 'This directory contains a Trac environment.\n' 'Visit https://trac.edgewall.org/ for more information.\n') # Setup the default configuration os.mkdir(self.conf_dir) config = Configuration(self.config_file_path) for section, name, value in options: config.set(section, name, value) config.save() self.setup_config() if not any((section, option) == ('inherit', 'file') for section, option, value in options): self.config.set_defaults(self) self.config.save() # Create the sample configuration create_file(self.config_file_path + '.sample') self._update_sample_config() # Create the database dbm = DatabaseManager(self) dbm.init_db() if default_data: dbm.insert_default_data() @lazy def database_version(self): """Returns the current version of the database. :since 1.0.2: """ return DatabaseManager(self) \ .get_database_version('database_version') @lazy def database_initial_version(self): """Returns the version of the database at the time of creation. In practice, for a database created before 0.11, this will return `False` which is "older" than any db version number. :since 1.0.2: """ return DatabaseManager(self) \ .get_database_version('initial_database_version') @lazy def trac_version(self): """Returns the version of Trac. :since: 1.2 """ from trac import core, __version__ return get_pkginfo(core).get('version', __version__) def setup_config(self): """Load the configuration file.""" self.config = Configuration(self.config_file_path, {'envname': self.name}) if not self.config.exists: raise TracError( _("The configuration file is not found at " "%(path)s", path=self.config_file_path)) self.setup_log() plugins_dir = self.shared_plugins_dir load_components(self, plugins_dir and (plugins_dir, )) @lazy def config_file_path(self): """Path of the trac.ini file.""" return os.path.join(self.conf_dir, 'trac.ini') @lazy def log_file_path(self): """Path to the log file.""" if not os.path.isabs(self.log_file): return os.path.join(self.log_dir, self.log_file) return self.log_file def _get_path_to_dir(self, *dirs): path = self.path for dir in dirs: path = os.path.join(path, dir) return os.path.normcase(os.path.realpath(path)) @lazy def attachments_dir(self): """Absolute path to the attachments directory. :since: 1.3.1 """ return self._get_path_to_dir('files', 'attachments') @lazy def conf_dir(self): """Absolute path to the conf directory. :since: 1.0.11 """ return self._get_path_to_dir('conf') @lazy def files_dir(self): """Absolute path to the files directory. :since: 1.3.2 """ return self._get_path_to_dir('files') @lazy def htdocs_dir(self): """Absolute path to the htdocs directory. :since: 1.0.11 """ return self._get_path_to_dir('htdocs') @lazy def log_dir(self): """Absolute path to the log directory. :since: 1.0.11 """ return self._get_path_to_dir('log') @lazy def plugins_dir(self): """Absolute path to the plugins directory. :since: 1.0.11 """ return self._get_path_to_dir('plugins') @lazy def templates_dir(self): """Absolute path to the templates directory. :since: 1.0.11 """ return self._get_path_to_dir('templates') def setup_log(self): """Initialize the logging sub-system.""" self.log, log_handler = \ self.create_logger(self.log_type, self.log_file_path, self.log_level, self.log_format) self.log.addHandler(log_handler) self.log.info('-' * 32 + ' environment startup [Trac %s] ' + '-' * 32, self.trac_version) def create_logger(self, log_type, log_file, log_level, log_format): log_id = 'Trac.%s' % \ hashlib.sha1(self.path.encode('utf-8')).hexdigest() if log_format: log_format = log_format.replace('$(', '%(') \ .replace('%(path)s', self.path) \ .replace('%(basename)s', self.name) \ .replace('%(project)s', self.project_name) return log.logger_handler_factory(log_type, log_file, log_level, log_id, format=log_format) def get_known_users(self, as_dict=False): """Returns information about all known users, i.e. users that have logged in to this Trac environment and possibly set their name and email. By default this function returns an iterator that yields one tuple for every user, of the form (username, name, email), ordered alpha-numerically by username. When `as_dict` is `True` the function returns a dictionary mapping username to a (name, email) tuple. :since 1.2: the `as_dict` parameter is available. """ return self._known_users_dict if as_dict else iter(self._known_users) @cached def _known_users(self): return self.db_query(""" SELECT DISTINCT s.sid, n.value, e.value FROM session AS s LEFT JOIN session_attribute AS n ON (n.sid=s.sid AND n.authenticated=1 AND n.name = 'name') LEFT JOIN session_attribute AS e ON (e.sid=s.sid AND e.authenticated=1 AND e.name = 'email') WHERE s.authenticated=1 ORDER BY s.sid """) @cached def _known_users_dict(self): return {u[0]: (u[1], u[2]) for u in self._known_users} def invalidate_known_users_cache(self): """Clear the known_users cache.""" del self._known_users del self._known_users_dict def backup(self, dest=None): """Create a backup of the database. :param dest: Destination file; if not specified, the backup is stored in a file called db_name.trac_version.bak """ return DatabaseManager(self).backup(dest) def needs_upgrade(self): """Return whether the environment needs to be upgraded.""" for participant in self.setup_participants: try: with self.component_guard(participant, reraise=True): if participant.environment_needs_upgrade(): self.log.warning( "Component %s requires an environment upgrade", participant) return True except Exception as e: raise TracError( _( "Unable to check for upgrade of " "%(module)s.%(name)s: %(err)s", module=participant.__class__.__module__, name=participant.__class__.__name__, err=exception_to_unicode(e))) return False def upgrade(self, backup=False, backup_dest=None): """Upgrade database. :param backup: whether or not to backup before upgrading :param backup_dest: name of the backup file :return: whether the upgrade was performed """ upgraders = [] for participant in self.setup_participants: with self.component_guard(participant, reraise=True): if participant.environment_needs_upgrade(): upgraders.append(participant) if not upgraders: return if backup: try: self.backup(backup_dest) except Exception as e: raise BackupError(e) for participant in upgraders: self.log.info("upgrading %s...", participant) with self.component_guard(participant, reraise=True): participant.upgrade_environment() # Database schema may have changed, so close all connections dbm = DatabaseManager(self) if dbm.connection_uri != 'sqlite::memory:': dbm.shutdown() self._update_sample_config() del self.database_version return True @lazy def href(self): """The application root path""" return Href(urlsplit(self.abs_href.base).path) @lazy def abs_href(self): """The application URL""" if not self.base_url: self.log.warning("[trac] base_url option not set in " "configuration, generated links may be incorrect") return Href(self.base_url) def _update_sample_config(self): filename = os.path.join(self.config_file_path + '.sample') if not os.path.isfile(filename): return config = Configuration(filename) config.set_defaults() try: config.save() except EnvironmentError as e: self.log.warning("Couldn't write sample configuration file (%s)%s", e, exception_to_unicode(e, traceback=True)) else: self.log.info( "Wrote sample configuration file with the new " "settings and their default values: %s", filename)
class CommitTicketUpdater(Component): """Update tickets based on commit messages. This component hooks into changeset notifications and searches commit messages for text in the form of: {{{ command #1 command #1, #2 command #1 & #2 command #1 and #2 }}} Instead of the short-hand syntax "#1", "ticket:1" can be used as well, e.g.: {{{ command ticket:1 command ticket:1, ticket:2 command ticket:1 & ticket:2 command ticket:1 and ticket:2 }}} Using the long-form syntax allows a comment to be included in the reference, e.g.: {{{ command ticket:1#comment:1 command ticket:1#comment:description }}} In addition, the ':' character can be omitted and issue or bug can be used instead of ticket. You can have more than one command in a message. The following commands are supported. There is more than one spelling for each command, to make this as user-friendly as possible. close, closed, closes, fix, fixed, fixes:: The specified tickets are closed, and the commit message is added to them as a comment. references, refs, addresses, re, see:: The specified tickets are left in their current status, and the commit message is added to them as a comment. A fairly complicated example of what you can do is with a commit message of: Changed blah and foo to do this or that. Fixes #10 and #12, and refs #12. This will close #10 and #12, and add a note to #12. """ implements(IRepositoryChangeListener) envelope = Option( 'ticket', 'commit_ticket_update_envelope', '', """Require commands to be enclosed in an envelope. Must be empty or contain two characters. For example, if set to `[]`, then commands must be in the form of `[closes #4]`.""") commands_close = Option( 'ticket', 'commit_ticket_update_commands.close', 'close closed closes fix fixed fixes', """Commands that close tickets, as a space-separated list.""") commands_refs = Option( 'ticket', 'commit_ticket_update_commands.refs', 'addresses re references refs see', """Commands that add a reference, as a space-separated list. If set to the special value `<ALL>`, all tickets referenced by the message will get a reference to the changeset.""") check_perms = BoolOption( 'ticket', 'commit_ticket_update_check_perms', 'true', """Check that the committer has permission to perform the requested operations on the referenced tickets. This requires that the user names be the same for Trac and repository operations.""") notify = BoolOption( 'ticket', 'commit_ticket_update_notify', 'true', """Send ticket change notification when updating a ticket.""") ticket_prefix = '(?:#|(?:ticket|issue|bug)[: ]?)' ticket_reference = ticket_prefix + \ '[0-9]+(?:#comment:([0-9]+|description))?' ticket_command = (r'(?P<action>[A-Za-z]*)\s*.?\s*' r'(?P<ticket>%s(?:(?:[, &]*|[ ]?and[ ]?)%s)*)' % (ticket_reference, ticket_reference)) @property def command_re(self): begin, end = (re.escape(self.envelope[0:1]), re.escape(self.envelope[1:2])) return re.compile(begin + self.ticket_command + end) ticket_re = re.compile(ticket_prefix + '([0-9]+)') _last_cset_id = None # IRepositoryChangeListener methods def changeset_added(self, repos, changeset): if self._is_duplicate(changeset): return tickets = self._parse_message(changeset.message) comment = self.make_ticket_comment(repos, changeset) self._update_tickets(tickets, changeset, comment, datetime.now(utc)) def changeset_modified(self, repos, changeset, old_changeset): if self._is_duplicate(changeset): return tickets = self._parse_message(changeset.message) old_tickets = {} if old_changeset is not None: old_tickets = self._parse_message(old_changeset.message) tickets = dict(each for each in tickets.iteritems() if each[0] not in old_tickets) comment = self.make_ticket_comment(repos, changeset) self._update_tickets(tickets, changeset, comment, datetime.now(utc)) def _is_duplicate(self, changeset): # Avoid duplicate changes with multiple scoped repositories cset_id = (changeset.rev, changeset.message, changeset.author, changeset.date) if cset_id != self._last_cset_id: self._last_cset_id = cset_id return False return True def _parse_message(self, message): """Parse the commit message and return the ticket references.""" cmd_groups = self.command_re.finditer(message) functions = self._get_functions() tickets = {} for m in cmd_groups: cmd, tkts = m.group('action', 'ticket') func = functions.get(cmd.lower()) if not func and self.commands_refs.strip() == '<ALL>': func = self.cmd_refs if func: for tkt_id in self.ticket_re.findall(tkts): tickets.setdefault(int(tkt_id), []).append(func) return tickets def make_ticket_comment(self, repos, changeset): """Create the ticket comment from the changeset data.""" rev = changeset.rev revstring = str(rev) drev = str(repos.display_rev(rev)) if repos.reponame: revstring += '/' + repos.reponame drev += '/' + repos.reponame return """\ In [changeset:"%s" %s]: {{{ #!CommitTicketReference repository="%s" revision="%s" %s }}}""" % (revstring, drev, repos.reponame, rev, changeset.message.strip()) def _update_tickets(self, tickets, changeset, comment, date): """Update the tickets with the given comment.""" authname = self._authname(changeset) perm = PermissionCache(self.env, authname) for tkt_id, cmds in tickets.iteritems(): try: self.log.debug("Updating ticket #%d", tkt_id) save = False with self.env.db_transaction: ticket = Ticket(self.env, tkt_id) ticket_perm = perm(ticket.resource) for cmd in cmds: if cmd(ticket, changeset, ticket_perm) is not False: save = True if save: ticket.save_changes(authname, comment, date) if save: self._notify(ticket, date, changeset.author, comment) except Exception as e: self.log.error( "Unexpected error while processing ticket " "#%s: %s", tkt_id, exception_to_unicode(e)) def _notify(self, ticket, date, author, comment): """Send a ticket update notification.""" if not self.notify: return event = TicketChangeEvent('changed', ticket, date, author, comment) try: NotificationSystem(self.env).notify(event) except Exception as e: self.log.error( "Failure sending notification on change to " "ticket #%s: %s", ticket.id, exception_to_unicode(e)) def _get_functions(self): """Create a mapping from commands to command functions.""" functions = {} for each in dir(self): if not each.startswith('cmd_'): continue func = getattr(self, each) for cmd in getattr(self, 'commands_' + each[4:], '').split(): functions[cmd] = func return functions def _authname(self, changeset): """Returns the author of the changeset, normalizing the casing if [trac] ignore_author_case is true.""" return changeset.author.lower() \ if self.env.config.getbool('trac', 'ignore_auth_case') \ else changeset.author # Command-specific behavior # The ticket isn't updated if all extracted commands return False. def cmd_close(self, ticket, changeset, perm): authname = self._authname(changeset) if self.check_perms and not 'TICKET_MODIFY' in perm: self.log.info("%s doesn't have TICKET_MODIFY permission for #%d", authname, ticket.id) return False ticket['status'] = 'closed' ticket['resolution'] = 'fixed' if not ticket['owner']: ticket['owner'] = authname def cmd_refs(self, ticket, changeset, perm): if self.check_perms and not 'TICKET_APPEND' in perm: self.log.info("%s doesn't have TICKET_APPEND permission for #%d", self._authname(changeset), ticket.id) return False
class EmailDistributor(Component): """Distributes notification events as emails.""" implements(INotificationDistributor) formatters = ExtensionPoint(INotificationFormatter) decorators = ExtensionPoint(IEmailDecorator) resolvers = OrderedExtensionsOption('notification', 'email_address_resolvers', IEmailAddressResolver, 'SessionEmailResolver', include_missing=False, doc="""Comma separated list of email resolver components in the order they will be called. If an email address is resolved, the remaining resolvers will not be called. """) default_format = Option('notification', 'default_format.email', 'text/plain', doc="Default format to distribute email notifications.") def __init__(self): self._charset = create_charset(self.config.get('notification', 'mime_encoding')) # INotificationDistributor methods def transports(self): yield 'email' def distribute(self, transport, recipients, event): if transport != 'email': return if not self.config.getbool('notification', 'smtp_enabled'): self.log.debug("%s skipped because smtp_enabled set to false", self.__class__.__name__) return formats = {} for f in self.formatters: for style, realm in f.get_supported_styles(transport): if realm == event.realm: formats[style] = f if not formats: self.log.error("%s No formats found for %s %s", self.__class__.__name__, transport, event.realm) return self.log.debug("%s has found the following formats capable of " "handling '%s' of '%s': %s", self.__class__.__name__, transport, event.realm, ', '.join(formats)) matcher = RecipientMatcher(self.env) notify_sys = NotificationSystem(self.env) always_cc = set(notify_sys.smtp_always_cc_list) addresses = {} for sid, auth, addr, fmt in recipients: if fmt not in formats: self.log.debug("%s format %s not available for %s %s", self.__class__.__name__, fmt, transport, event.realm) continue if sid and not addr: for resolver in self.resolvers: addr = resolver.get_address_for_session(sid, auth) or None if addr: self.log.debug( "%s found the address '%s' for '%s [%s]' via %s", self.__class__.__name__, addr, sid, auth, resolver.__class__.__name__) break if sid and auth and not addr: addr = sid if notify_sys.smtp_default_domain and \ not notify_sys.use_short_addr and \ addr and matcher.nodomaddr_re.match(addr): addr = '%s@%s' % (addr, notify_sys.smtp_default_domain) if not addr: self.log.debug("%s was unable to find an address for " "'%s [%s]'", self.__class__.__name__, sid, auth) elif matcher.is_email(addr) or \ notify_sys.use_short_addr and \ matcher.nodomaddr_re.match(addr): addresses.setdefault(fmt, set()).add(addr) if sid and auth and sid in always_cc: always_cc.discard(sid) always_cc.add(addr) elif notify_sys.use_public_cc: always_cc.add(addr) else: self.log.debug("%s was unable to use an address '%s' for '%s " "[%s]'", self.__class__.__name__, addr, sid, auth) outputs = {} failed = [] for fmt, formatter in formats.iteritems(): if fmt not in addresses and fmt != 'text/plain': continue try: outputs[fmt] = formatter.format(transport, fmt, event) except Exception as e: self.log.warning('%s caught exception while ' 'formatting %s to %s for %s: %s%s', self.__class__.__name__, event.realm, fmt, transport, formatter.__class__, exception_to_unicode(e, traceback=True)) failed.append(fmt) # Fallback to text/plain when formatter is broken if failed and 'text/plain' in outputs: for fmt in failed: addresses.setdefault('text/plain', set()) \ .update(addresses.pop(fmt, ())) for fmt, addrs in addresses.iteritems(): self.log.debug("%s is sending event as '%s' to: %s", self.__class__.__name__, fmt, ', '.join(addrs)) message = self._create_message(fmt, outputs) if message: addrs = set(addrs) cc_addrs = sorted(addrs & always_cc) bcc_addrs = sorted(addrs - always_cc) self._do_send(transport, event, message, cc_addrs, bcc_addrs) else: self.log.warning("%s cannot send event '%s' as '%s': %s", self.__class__.__name__, event.realm, fmt, ', '.join(addrs)) def _create_message(self, format, outputs): if format not in outputs: return None message = create_mime_multipart('related') maintype, subtype = format.split('/') preferred = create_mime_text(outputs[format], subtype, self._charset) if format != 'text/plain' and 'text/plain' in outputs: alternative = create_mime_multipart('alternative') alternative.attach(create_mime_text(outputs['text/plain'], 'plain', self._charset)) alternative.attach(preferred) preferred = alternative message.attach(preferred) return message def _do_send(self, transport, event, message, cc_addrs, bcc_addrs): notify_sys = NotificationSystem(self.env) smtp_from = notify_sys.smtp_from smtp_from_name = notify_sys.smtp_from_name or self.env.project_name smtp_replyto = notify_sys.smtp_replyto if not notify_sys.use_short_addr and notify_sys.smtp_default_domain: if smtp_from and '@' not in smtp_from: smtp_from = '%s@%s' % (smtp_from, notify_sys.smtp_default_domain) if smtp_replyto and '@' not in smtp_replyto: smtp_replyto = '%s@%s' % (smtp_replyto, notify_sys.smtp_default_domain) headers = {} headers['X-Mailer'] = 'Trac %s, by Edgewall Software'\ % self.env.trac_version headers['X-Trac-Version'] = self.env.trac_version headers['X-Trac-Project'] = self.env.project_name headers['X-URL'] = self.env.project_url headers['X-Trac-Realm'] = event.realm headers['Precedence'] = 'bulk' headers['Auto-Submitted'] = 'auto-generated' if isinstance(event.target, (list, tuple)): targetid = ','.join(map(get_target_id, event.target)) else: targetid = get_target_id(event.target) rootid = create_message_id(self.env, targetid, smtp_from, None, more=event.realm) if event.category == 'created': headers['Message-ID'] = rootid else: headers['Message-ID'] = create_message_id(self.env, targetid, smtp_from, event.time, more=event.realm) headers['In-Reply-To'] = rootid headers['References'] = rootid headers['Date'] = formatdate() headers['From'] = (smtp_from_name, smtp_from) \ if smtp_from_name else smtp_from headers['To'] = 'undisclosed-recipients: ;' if cc_addrs: headers['Cc'] = ', '.join(cc_addrs) if bcc_addrs: headers['Bcc'] = ', '.join(bcc_addrs) headers['Reply-To'] = smtp_replyto for k, v in headers.iteritems(): set_header(message, k, v, self._charset) for decorator in self.decorators: decorator.decorate_message(event, message, self._charset) from_name, from_addr = parseaddr(str(message['From'])) to_addrs = set() for name in ('To', 'Cc', 'Bcc'): values = map(str, message.get_all(name, ())) to_addrs.update(addr for name, addr in getaddresses(values) if addr) del message['Bcc'] notify_sys.send_email(from_addr, list(to_addrs), message.as_string())
class SmtpEmailSender(Component): """E-mail sender connecting to an SMTP server.""" implements(IEmailSender) smtp_server = Option('notification', 'smtp_server', 'localhost', """SMTP server hostname to use for email notifications.""") smtp_port = IntOption('notification', 'smtp_port', 25, """SMTP server port to use for email notification.""") smtp_user = Option('notification', 'smtp_user', '', """Username for authenticating with SMTP server.""") smtp_password = Option('notification', 'smtp_password', '', """Password for authenticating with SMTP server.""") use_tls = BoolOption('notification', 'use_tls', 'false', """Use SSL/TLS to send notifications over SMTP.""") def send(self, from_addr, recipients, message): global local_hostname # Ensure the message complies with RFC2822: use CRLF line endings message = fix_eol(message, CRLF) self.log.info("Sending notification through SMTP at %s:%d to %s", self.smtp_server, self.smtp_port, recipients) try: server = smtplib.SMTP(self.smtp_server, self.smtp_port, local_hostname) local_hostname = server.local_hostname except smtplib.socket.error as e: raise ConfigurationError( tag_("SMTP server connection error (%(error)s). Please " "modify %(option1)s or %(option2)s in your " "configuration.", error=to_unicode(e), option1=tag.code("[notification] smtp_server"), option2=tag.code("[notification] smtp_port"))) # server.set_debuglevel(True) if self.use_tls: server.ehlo() if 'starttls' not in server.esmtp_features: raise TracError(_("TLS enabled but server does not support" " TLS")) server.starttls() server.ehlo() if self.smtp_user: server.login(self.smtp_user.encode('utf-8'), self.smtp_password.encode('utf-8')) start = time_now() server.sendmail(from_addr, recipients, message) t = time_now() - start if t > 5: self.log.warning("Slow mail submission (%.2f s), " "check your mail setup", t) if self.use_tls: # avoid false failure detection when the server closes # the SMTP connection with TLS enabled import socket try: server.quit() except socket.sslerror: pass else: server.quit()
class HtmlContent(Component): """DEPRECATED '''Deprecated - use [http://ckeditor.com/ CKEditor]-Plugin instead! ''' Enables HTML content in description, adding Javascript editor and adding additional CSS file for manipulation CSS declarations. Options: || '''option name''' || '''values''' || '''description''' || || description_format || `wiki` | `html` || format for ticket description (default: '''wiki''') || || editor_source || valid path || Usually it should stored in project or common js folder. For ckeditor for example it could be site/js/ckeditor/ckeditor.js. || || editor_replace || valid path || Javascript, which should replace textareas. || || additional_css || valid path || Path to additional css file, which overrides css-declarations. || Sample configuration: {{{ [ticket] description_format = html editor_source = site/js/ckeditor/ckeditor.js editor_replace = <script type="text/javascript">CKEDITOR.replace('@FIELD_NAME@', {toolbar: 'custom'});</script> additional_css = site/css/add_ticket.css }}} In above sample configuration [http://ckeditor.com/ CKEditor] is used as online editor and the editor source is located in projects-folder `htdocs/js/ckeditor`, after each `textarea` with HTML-Option content of `editor_replace` will be added. The file in option `additional_css` will be added and therefore will override these declarations. '''Attention:''' To have a correct preview, file `ticket_box.html` has to be edited: {{{ 1 <py:if test="field"> 2 <py:choose test=""> 3 <py:when test="ticket[field.name] and field.format == 'html'">${wiki_to_html(context, '{{{ \n#!html \n' + ticket[field.name] + '\n}}}', escape_newlines=preserve_newlines)}</py:when> 4 <py:when test="'rendered' in field">${field.rendered}</py:when> 5 <py:otherwise>${ticket[field.name]}</py:otherwise> 6 </py:choose> 7 </py:if> }}} Line 3 has to be inserted into `py:choose` block in above template-snippet. {{{ <div py:if="ticket.description" class="searchable" xml:space="preserve"> <py:choose> <py:when test="description_format == 'html'">${wiki_to_html(context, '{{{ \n#!html \n' + ticket.description + '\n}}}', escape_newlines=preserve_newlines)}</py:when> <py:otherwise>${wiki_to_html(context, ticket.description, escape_newlines=preserve_newlines)}</py:otherwise> </py:choose> </div> }}} Also `py:choose` block has to be added into div-block near the end of file (see above template-snippet).""" implements(IRequestFilter, ITemplateStreamFilter) description_format = Option( 'ticket', 'description_format', '', """Format of description. Empty or wiki is Trac Standard; html formats description as HTML.""") additional_css_file = Option( 'ticket', 'additional_css', '', """Path to additional css file, which overrides css-declarations.""") editor_source = Option( 'ticket', 'editor_source', '', """Path to javascript editor. Usually it should stored in project or common js folder. For ckeditor for example it could be site/js/ckeditor/ckeditor.js.""") editor_replace = Option('ticket', 'editor_replace', '', """Javascript, which should replace textareas.""") def pre_process_request(self, req, handler): return handler def post_process_request(self, req, template, data, content_type): if data and template == 'ticket.html': data['description_format'] = self.description_format if self.editor_source: add_script(req, self.editor_source) if self.additional_css_file: add_stylesheet(req, self.additional_css_file) return template, data, content_type def filter_stream(self, req, method, filename, stream, data): if filename == 'ticket.html' and self.editor_source and self.editor_replace: self.log.debug( "further processing: template %s, editor-source %s, editor-replace %s" % (filename, self.editor_source, self.editor_replace)) # check if description should be in HTML if self.description_format == "html": add_editor = self.editor_replace.replace( "@FIELD_NAME@", "field_description") html = HTML(add_editor) self.log.debug("add_editor is %s" % add_editor) stream |= Transformer( './/textarea[@name="field_description"]').after(html) fields = data['fields'] for f in fields: if f['skip'] or not f['type'] == 'textarea' or not f.has_key( 'format') or not f['format'] == 'html': continue # only textarea-fields with format HTML should be processed at this point field_name = 'field_%s' % f['name'] add_editor = self.editor_replace.replace( "@FIELD_NAME@", field_name) html = HTML(add_editor) tr_str = './/textarea[@name="%s"]' % field_name self.log.debug("add_editor for field %s is %s; tr_str is %s" % (field_name, add_editor, tr_str)) stream |= Transformer(tr_str).after(html) return stream #=============================================================================== # UNUSED COMPONENTS # These components are not used yet, because they are not working and # are only made because in a research manner. #=============================================================================== #=============================================================================== # class DisplayDate(Component): # """DEPRECATED # # '''Deprecated - use trac itself with revision 10629 or later! ''' # (see Trac-Ticket [http://trac.edgewall.org/ticket/9777 #9777]) # Displays date next to summary as absolute date instead of timedelta. # Timedelta will be displayed only as title. # # '''Attention''': This works partly fine in German and English. Other languages / locales haven't been tested. # It doesn't set the language correct in changes (like comments / attachments). # You also have to edit your `template.htm` and place it under your project folder. # # Sample change: # {{{ # <div class="date"> # <py:choose> # <py:when test="date_format == 'absolute'"> # <p i18n:msg="created" py:if="ticket.exists">Creation date: ${dateinfo_abs(ticket.time)}</p> # <p i18n:msg="modified" py:if="ticket.changetime != ticket.time">Modify date: ${dateinfo_abs(ticket.changetime)}</p> # </py:when> # <py:otherwise> # <p i18n:msg="created" py:if="ticket.exists">Opened ${dateinfo(ticket.time)} ago</p> # <p i18n:msg="modified" py:if="ticket.changetime != ticket.time">Last modified ${dateinfo(ticket.changetime)} ago</p> # </py:otherwise> # </py:choose> # <p py:if="not ticket.exists"><i>(ticket not yet created)</i></p> # </div> # }}} # # Options: # || '''option name''' || '''values''' || '''description''' || # || date_format || `relative` | `absolute` || format of date in ticket view (default: '''relative''') || # # Sample configuration: # {{{ # [ticket] # date_format = absolute # }}} # # You might restart your webserver after installing this plugin, since it doesn't display the date correctly. # """ # implements (IRequestFilter, ITemplateStreamFilter) # # date_format = Option('ticket', 'date_format', 'relative', # """Format of date in ticket view. # Empty or relative is Trac Standard; absolute formats date as date/time.""") # # def __init__(self): # locale_dir = pkg_resources.resource_filename(__name__, 'locale') # add_domain(self.env.path, locale_dir) # # def pre_process_request(self, req, handler): # return handler # # def post_process_request(self, req, template, data, content_type): # if data and template == 'ticket.html': # self.log.info("[post_process_request] called this method with data with template: %s" % template) # def dateinfo_abs(date): # self.log.info("[post_process_request] called method 'dateinfo'") # return tag.span(format_datetime(date), # title=pretty_timedelta(date)) # data['date_format'] = self.date_format # if self.date_format == "absolute": # data['dateinfo_abs'] = dateinfo_abs # # self.log.debug("data %r" % data) # return template, data, content_type # # def filter_stream(self, req, method, filename, stream, data): # # if not self.date_format == "absolute": # # return stream # # # # if filename == 'ticket.html': # # self.log.debug( "replacing relative datetime to absolute datetime" ) # # # just a hack (works at least well in German and English) # # def replace_descr(stream): # # for mark, (kind, data, pos) in stream: # # if mark and kind is TEXT: # # test = data.upper() # # if re.match(r'[0-9]{2}', test): # # yield mark, (kind, data, pos) # # else: # # yield mark, (kind, '', pos) # # else: # # yield mark, (kind, data, pos) # # # # stream = stream | Transformer('.//div[@id="ticket"]/div[@class="date"]/p[1]') \ # # .apply( replace_descr ).prepend( _('Creation date') + ': ' ) # # stream = stream | Transformer('.//div[@id="ticket"]/div[@class="date"]/p[2]') \ # # .apply( replace_descr ).prepend( _('Modify date') + ': ' ) # # # # just a hack, which works in German, but not in English! # # def replace_comment_descr(stream): # # do_replace_text = 0 # # for mark, (kind, data, pos) in stream: # # if mark and kind is END: # # try: # # if data.localname == "h3": # # do_replace_text = 0 # # elif data.localname == "span": # # do_replace_text += 1 # # except Exception, e: # # print "*** EXEPTION *** %s" % e # # yield mark, (kind, data, pos) # # elif mark and kind is TEXT: # # test = data.upper() # # if re.match(r'[0-9]{2}', test): # # yield mark, (kind, data, pos) # # elif do_replace_text == 2: # # yield mark, (kind, '', pos) # # else: # # yield mark, (kind, data, pos) # # else: # # yield mark, (kind, data, pos) # # # # stream = stream | Transformer('.//h3 [@class="change"]') \ # # .apply( replace_comment_descr ).prepend( _('Modify date') + ': ' ) # return stream #=============================================================================== #=============================================================================== #class FileUploader(Component): # """Test for uploading images by CKEditor""" # implements (IAttachmentChangeListener, IAttachmentManipulator, IRequestHandler) # # def match_request(self, req): # """Return whether the handler wants to process the given request.""" # return re.match(r'/image_upload', req.path_info) # # def process_request(self, req): # """Process the request. # # For ClearSilver, return a `(template_name, content_type)` tuple, # where `template` is the ClearSilver template to use (either a # `neo_cs.CS` object, or the file name of the template), and # `content_type` is the MIME type of the content. # # For Genshi, return a `(template_name, data, content_type)` tuple, # where `data` is a dictionary of substitutions for the template. # # For either templating systems, "text/html" is assumed if `content_type` # is `None`. # # Note that if template processing should not occur, this method can # simply send the response itself and not return anything. # """ # print "========= ____ [process_request]" # if ( req.path_info == "/image_upload" ): # add_stylesheet(req, 'hw/css/style.css') # print "========= ____ [process_request] correct path ... doing more" # data = {} # return 'ticketnav/image-uploader.html', data, None # # def prepare_attachment(self, req, attachment, fields): # """Not currently called, but should be provided for future # compatibility.""" # print "--------------.... [prepare_attachment] try to add attachment" # # def validate_attachment(self, req, attachment): # """Validate an attachment after upload but before being stored in Trac # environment. # # Must return a list of `(field, message)` tuples, one for each problem # detected. `field` can be any of `description`, `username`, `filename`, # `content`, or `None` to indicate an overall problem with the # attachment. Therefore, a return value of `[]` means everything is # OK.""" # print "_______________.... [validate_attachment] try to add attachment" # return [] # # def attachment_added(self, attachment): # """Called when an attachment is added.""" # print "_______________ [attachment_added] try to add attachment" # print "_______________ [attachment_added] add attachment: %s" % attachment ## return attachment # # def attachment_deleted(self, attachment): # """Called when an attachment is deleted.""" # print "_______________ [attachment_deleted] try to delete attachment" ## return attachment # # def attachment_reparented(self, attachment, old_parent_realm, old_parent_id): # """Called when an attachment is reparented.""" # print "_______________ [attachment_reparented] add attachment: %s" % attachment # return attachment, old_parent_realm, old_parent_id #=============================================================================== #=============================================================================== # Tested if copying ticket-box.html is working, but it is not! #=============================================================================== #class HtmlContent(Component): # implements (IRequestFilter) # """Allow description and other textarea-fields having HTML-content""" # # # IRequestHandler methods ## def match_request(self, req): ## print "===== HtmlContent, path_info: %s" % req.path_info ## return re.match(r'/(ticket|newticket)(?:_trac)?(?:/.*)?$', req.path_info) # # def pre_process_request(self, req, handler): # return handler # # # IRequestHandler methods # def post_process_request(self, req, template, data, content_type): # print "template: %s" % template # self._check_init() ## data = {} ## return handler ## return 'ticket.html', data, None # return template, data, content_type # # # def _check_init(self): # """First check if Plugin has already been initialized. # """ # # print "====== _check_init" # # template_path = self.env.path # # if template_path and template_path.endswith('/'): # template_path += 'templates' # else: # template_path += '/templates' # # if os.access(template_path, os.W_OK): # print "can write to path %s" % template_path # from pkg_resources import resource_filename ## src_name = resource_filename(__name__, 'templates/ticket-box.html') # src_name = resource_filename(__name__, 'templates') # print "src_name: %s" % src_name # shutil.copy(src_name + '/ticket-box.html', template_path + '/ticket-box.html') # elif os.access(template_path, os.R_OK): # print "can read to path %s" % template_path # # print "template_path: %s" % template_path
class TextAreaDescription(Component): """Shows next to a text area (like description itself or any custom description) an description for what the field is for. Default values for options: {{{ [ticket] description_descr = descr_template = <div style="white-space: normal; height: 250px; overflow:scroll;" class="system-message">%s<div> }}} """ implements(ITemplateStreamFilter, IRequestFilter, ITemplateProvider) description_descr = Option('ticket', 'description_descr', '', """Explaination of description.""") descr_template = Option( 'ticket', 'descr_template', '<div style="white-space: normal; width: 250px; height: 250px; overflow:scroll;" class="%s">%s<div>', """Explaination of description.""") def filter_stream(self, req, method, filename, stream, data): if filename == 'ticket.html': # print "_____________ am in TextAreaDescription" fields = data['fields'] if self.description_descr: # print "having description_descr: %s" % self.description_descr # print "having description_template: %s" % self.descr_template html_d = self.descr_template % ('ticket-rndescr', self.description_descr) stream |= Transformer( './/th/label[@for="field-description"]').after( HTML(html_d)) for f in fields: if f['skip'] or not f[ 'type'] == 'textarea': # or not f.has_key('descr'): continue descr = self.config.get('ticket-custom', '%s.descr' % f['name']) if descr: # print "processing field %s" % f css_class = self.config.get('ticket-custom', '%s.css_class' % f['name']) # print css_class field_name = 'field-%s' % f['name'] tr_str = './/label[@for="%s"]' % field_name html = self.descr_template % (css_class, descr) stream |= Transformer(tr_str).after(HTML(html)) return stream # IRequestHandler methods def pre_process_request(self, req, handler): return handler #=========================================================================== # Add JavaScript an an additional css to ticket template #=========================================================================== # def post_process_request(self, req, template, data, content_type): # if req.path_info .startswith('/newticket')or \ # req.path_info .startswith('/ticket'): # add_stylesheet(req, 'ticketnav/css/ticket_descr.css') # return template, data, content_type def post_process_request(self, req, template, data, content_type): if req.path_info .startswith('/newticket') or \ req.path_info .startswith('/ticket'): add_stylesheet(req, 'hw/css/ticket_descr.css') return template, data, content_type def get_templates_dirs(self): return #[resource_filename(__name__, 'templates')] def get_htdocs_dirs(self): # self.log.debug('TextAreaDescription. get_htdocs_dirs: %s' % resource_filename(__name__, 'htdocs') ) return [('hw', resource_filename(__name__, 'htdocs'))]
class LoginModule(auth.LoginModule, CommonTemplateProvider): """Custom login form and processing. This is woven with the trac.auth.LoginModule it inherits and overwrites. But both can't co-exist, so Trac's built-in authentication module must be disabled to use this one. """ # Trac core options, replicated here to not make them disappear by # disabling auth.LoginModule. check_ip = BoolOption( 'trac', 'check_auth_ip', 'false', """Whether the IP address of the user should be checked for authentication (''since 0.9'').""") ignore_case = BoolOption( 'trac', 'ignore_auth_case', 'false', """Whether login names should be converted to lower case (''since 0.9'').""") auth_cookie_lifetime = IntOption( 'trac', 'auth_cookie_lifetime', 0, """Lifetime of the authentication cookie, in seconds. This value determines how long the browser will cache authentication information, and therefore, after how much inactivity a user will have to log in again. The default value of 0 makes the cookie expire at the end of the browsing session. (''since 0.12'')""") auth_cookie_path = Option( 'trac', 'auth_cookie_path', '', """Path for the authentication cookie. Set this to the common base path of several Trac instances if you want them to share the cookie. (''since 0.12'')""") # Options dedicated to acct_mgr.web_ui.LoginModule. login_opt_list = BoolOption( 'account-manager', 'login_opt_list', False, """Set to True, to switch login page style showing alternative actions in a single listing together.""") cookie_refresh_pct = IntOption( 'account-manager', 'cookie_refresh_pct', 10, """Persistent sessions randomly get a new session cookie ID with likelihood in percent per work hour given here (zero equals to never) to decrease vulnerability of long-lasting sessions.""") environ_auth_overwrite = BoolOption( 'account-manager', 'environ_auth_overwrite', True, """Whether environment variable REMOTE_USER should get overwritten after processing login form input. Otherwise it will only be set, if unset at the time of authentication.""") # Update cookies for persistant sessions only 1/day. # hex_entropy returns 32 chars per call equal to 128 bit of entropy, # so it should be technically impossible to explore the hash even within # a year by just throwing forged HTTP requests at the server. # I.e. it would require 1.000.000 machines, each at 5*10^24 requests/s, # equal to a full-scale DDoS attack - an entirely different issue. UPDATE_INTERVAL = 86400 def __init__(self): cfg = self.config if is_enabled(self.env, self.__class__) and \ is_enabled(self.env, auth.LoginModule): # Disable auth.LoginModule to handle login requests alone. self.env.log.info("Concurrent enabled login modules found, " "fixing configuration ...") cfg.set('components', 'trac.web.auth.loginmodule', 'disabled') # Changes are intentionally not written to file for persistence. # This could cause the environment to reload a bit too early, even # interrupting a rewrite in progress by another thread and causing # a DoS condition by truncating the configuration file. self.env.log.info("trac.web.auth.LoginModule disabled, " "giving preference to %s." % self.__class__) self.cookie_lifetime = self.auth_cookie_lifetime if not self.cookie_lifetime > 0: # Set the session to expire after some time and not # when the browser is closed - what is Trac core default). self.cookie_lifetime = 86400 * 30 # AcctMgr default = 30 days def authenticate(self, req): if req.method == 'POST' and req.path_info.startswith('/login') and \ req.args.get('user_locked') is None: username = self._remote_user(req) acctmgr = AccountManager(self.env) guard = AccountGuard(self.env) if guard.login_attempt_max_count > 0: if username is None: # Get user for failed authentication attempt. f_user = req.args.get('username') req.args['user_locked'] = False # Log current failed login attempt. guard.failed_count(f_user, req.remote_addr) if guard.user_locked(f_user): # Step up lock time prolongation only while locked. guard.lock_count(f_user, 'up') req.args['user_locked'] = True elif guard.user_locked(username): req.args['user_locked'] = True # Void successful login as long as user is locked. username = None else: req.args['user_locked'] = False if req.args.get('failed_logins') is None: # Reset failed login attempts counter. req.args['failed_logins'] = guard.failed_count( username, reset=True) else: req.args['user_locked'] = False if not 'REMOTE_USER' in req.environ or self.environ_auth_overwrite: if 'REMOTE_USER' in req.environ: # Complain about another component setting environment # variable for authenticated user. self.env.log.warn("LoginModule.authenticate: " "'REMOTE_USER' was set to '%s'" % req.environ['REMOTE_USER']) self.env.log.debug("LoginModule.authenticate: Set " "'REMOTE_USER' = '%s'" % username) req.environ['REMOTE_USER'] = username return auth.LoginModule.authenticate(self, req) authenticate = if_enabled(authenticate) match_request = if_enabled(auth.LoginModule.match_request) def process_request(self, req): if req.path_info.startswith('/login') and req.authname == 'anonymous': try: referer = self._referer(req) except AttributeError: # Fallback for Trac 0.11 compatibility. referer = req.get_header('Referer') # Steer clear of requests going nowhere or loop to self. if referer is None or \ referer.startswith(str(req.abs_href()) + '/login'): referer = req.abs_href() data = { '_dgettext': dgettext, 'login_opt_list': self.login_opt_list, 'persistent_sessions': AccountManager(self.env).persistent_sessions, 'referer': referer, 'registration_enabled': RegistrationModule(self.env).enabled, 'reset_password_enabled': AccountModule(self.env).reset_password_enabled } if req.method == 'POST': self.log.debug( "LoginModule.process_request: 'user_locked' = %s" % req.args.get('user_locked')) if not req.args.get('user_locked'): # TRANSLATOR: Intentionally obfuscated login error data['login_error'] = _("Invalid username or password") else: f_user = req.args.get('username') release_time = AccountGuard(self.env).pretty_release_time( req, f_user) if not release_time is None: data['login_error'] = _( """Account locked, please try again after %(release_time)s """, release_time=release_time) else: data['login_error'] = _("Account locked") return 'login.html', data, None else: n_plural = req.args.get('failed_logins') if n_plural > 0: add_warning( req, Markup( tag.span( tag( ngettext( "Login after %(attempts)s failed attempt", "Login after %(attempts)s failed attempts", n_plural, attempts=n_plural))))) return auth.LoginModule.process_request(self, req) # overrides def _get_name_for_cookie(self, req, cookie): """Returns the username for the current Trac session. It's called by authenticate() when the cookie 'trac_auth' is sent by the browser. """ acctmgr = AccountManager(self.env) name = None # Replicate _get_name_for_cookie() or _cookie_to_name() since Trac 1.0 # adding special handling of persistent sessions, as the user may have # a dynamic IP adress and this would lead to the user being logged out # due to an IP address conflict. if 'trac_auth_session' in req.incookie or True: db = self.env.get_db_cnx() cursor = db.cursor() sql = "SELECT name FROM auth_cookie WHERE cookie=%s AND ipnr=%s" args = (cookie.value, req.remote_addr) if acctmgr.persistent_sessions or not self.check_ip: sql = "SELECT name FROM auth_cookie WHERE cookie=%s" args = (cookie.value, ) cursor.execute(sql, args) name = cursor.fetchone() name = name and name[0] or None if name is None: self._expire_cookie(req) if acctmgr.persistent_sessions and name and \ 'trac_auth_session' in req.incookie and \ int(req.incookie['trac_auth_session'].value) < \ int(time.time()) - self.UPDATE_INTERVAL: # Persistent sessions enabled, the user is logged in # ('name' exists) and has actually decided to use this feature # (indicated by the 'trac_auth_session' cookie existing). # # NOTE: This method is called on every request. # Refresh session cookie # Update the timestamp of the session so that it doesn't expire. self.env.log.debug('Updating session %s for user %s' % (cookie.value, name)) # Refresh in database db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute( """ UPDATE auth_cookie SET time=%s WHERE cookie=%s """, (int(time.time()), cookie.value)) db.commit() # Change session ID (cookie.value) now and then as it otherwise # never would change at all (i.e. stay the same indefinitely and # therefore is more vulnerable to be hacked). if random.random() + self.cookie_refresh_pct / 100.0 > 1: old_cookie = cookie.value # Update auth cookie value cookie.value = hex_entropy() self.env.log.debug('Changing session id for user %s to %s' % (name, cookie.value)) db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute( """ UPDATE auth_cookie SET cookie=%s WHERE cookie=%s """, (cookie.value, old_cookie)) db.commit() if self.auth_cookie_path: self._distribute_auth(req, cookie.value, name) cookie_lifetime = self.cookie_lifetime cookie_path = self._get_cookie_path(req) req.outcookie['trac_auth'] = cookie.value req.outcookie['trac_auth']['path'] = cookie_path req.outcookie['trac_auth']['expires'] = cookie_lifetime req.outcookie['trac_auth_session'] = int(time.time()) req.outcookie['trac_auth_session']['path'] = cookie_path req.outcookie['trac_auth_session']['expires'] = cookie_lifetime try: if self.env.secure_cookies: req.outcookie['trac_auth']['secure'] = True req.outcookie['trac_auth_session']['secure'] = True except AttributeError: # Report details about Trac compatibility for the feature. self.env.log.debug( """Restricting cookies to HTTPS connections is requested, but is supported only by Trac 0.11.2 or later version. """) return name # overrides def _do_login(self, req): if not req.remote_user: if req.method == 'GET': # Trac before 0.12 has known weak redirect loop protection. # Adding redirect fix from Trac 0.12, and especially avert # from 'self._redirect_back', when we see a 'GET' here. referer = req.get_header('Referer') # Steer clear of requests going nowhere or loop to self if referer is None or \ referer.startswith(str(req.abs_href()) + '/login'): referer = req.abs_href() req.redirect(referer) self._redirect_back(req) res = auth.LoginModule._do_login(self, req) cookie_path = self._get_cookie_path(req) # Fix for Trac 0.11, that always sets path to `req.href()`. req.outcookie['trac_auth']['path'] = cookie_path # Inspect current cookie and try auth data distribution for SSO. cookie = req.outcookie.get('trac_auth') if cookie and self.auth_cookie_path: self._distribute_auth(req, cookie.value, req.remote_user) if req.args.get('rememberme', '0') == '1': # Check for properties to be set in auth cookie. cookie_lifetime = self.cookie_lifetime req.outcookie['trac_auth']['expires'] = cookie_lifetime # This cookie is used to indicate that the user is actually using # the "Remember me" feature. This is necessary for # '_get_name_for_cookie()'. req.outcookie['trac_auth_session'] = 1 req.outcookie['trac_auth_session']['path'] = cookie_path req.outcookie['trac_auth_session']['expires'] = cookie_lifetime try: if self.env.secure_cookies: req.outcookie['trac_auth_session']['secure'] = True except AttributeError: # Report details about Trac compatibility for the feature. self.env.log.debug( """Restricting cookies to HTTPS connections is requested, but is supported only by Trac 0.11.2 or later version. """) else: # In Trac 0.12 the built-in authentication module may have already # set cookie's expires attribute, so because the user did not # check 'remember me' we need to delete it here to ensure that the # cookie will still expire at the end of the session. try: del req.outcookie['trac_auth']['expires'] except KeyError: pass # If there is a left-over session cookie from a previous # authentication session, expire it now. if 'trac_auth_session' in req.incookie: self._expire_session_cookie(req) return res def _distribute_auth(self, req, trac_auth, name=None): # Single Sign On authentication distribution between multiple # Trac environments managed by AccountManager. local_environ = req.environ.get('SCRIPT_NAME', '').lstrip('/') for environ, path in get_environments(req.environ).iteritems(): if environ != local_environ: try: # Cache environment for subsequent invocations. env = open_environment(path, use_cache=True) auth_cookie_path = env.config.get('trac', 'auth_cookie_path') # Consider only Trac environments with equal, non-default # 'auth_cookie_path', which enables cookies to be shared. if auth_cookie_path == self.auth_cookie_path: db = env.get_db_cnx() cursor = db.cursor() # Authentication cookie values must be unique. Ensure, # there is no other session (or worst: session ID) # associated to it. cursor.execute( """ DELETE FROM auth_cookie WHERE cookie=%s """, (trac_auth, )) if not name: db.commit() env.log.debug('Auth data revoked from: %s' % local_environ) continue cursor.execute( """ INSERT INTO auth_cookie (cookie,name,ipnr,time) VALUES (%s,%s,%s,%s) """, (trac_auth, name, req.remote_addr, int( time.time()))) db.commit() env.log.debug('Auth data received from: %s' % local_environ) self.log.debug('Auth distribution success: %s' % environ) except Exception, e: self.log.debug( 'Auth distribution skipped for env %s: %s' % (environ, exception_to_unicode(e, traceback=True)))
class GitolitePermissionManager(Component): implements(IAdminPanelProvider, ITemplateProvider) gitolite_admin_reponame = Option('trac-gitolite', 'admin_reponame', default="gitolite-admin") gitolite_admin_ssh_path = Option( 'trac-gitolite', 'admin_ssh_path', default="git@localhost:gitolite-admin.git") gitolite_admin_real_reponame = Option('trac-gitolite', 'admin_real_reponame', default="gitolite-admin") gitolite_admin_system_user = Option('trac-gitolite', 'admin_system_user', default="trac") def get_users(self): node = utils.get_repo_node(self.env, self.gitolite_admin_reponame, "keydir") assert node.isdir, "Node %s at /keydir/ is not a directory" % node for child in node.get_entries(): name = child.get_name() assert name.endswith(".pub"), "Node %s" % name name = name[:-4] yield name def read_config(self): node = utils.get_repo_node(self.env, self.gitolite_admin_reponame, "conf/gitolite.conf") fp = node.get_content() return utils.read_config(fp) ## IAdminPanelProvider methods def get_admin_panels(self, req): if 'VERSIONCONTROL_ADMIN' in req.perm: yield ('versioncontrol', _('Version Control'), 'permissions', _('Permissions')) def render_admin_panel(self, req, category, page, path_info): req.perm.require('VERSIONCONTROL_ADMIN') if req.method == 'POST': perms = {} for setting in req.args: try: setting = json.loads(setting) except ValueError: continue if not isinstance( setting, dict ) or 'perm' not in setting or 'user' not in setting or 'repo' not in setting: continue repo = setting['repo'] perm = setting['perm'] user = setting['user'] if repo not in perms: perms[repo] = {} if perm not in perms[repo]: perms[repo][perm] = [] if user not in perms[repo][perm]: perms[repo][perm].append(user) gitolite_admin_perms = perms.get(self.gitolite_admin_real_reponame, {}) if (self.gitolite_admin_system_user not in gitolite_admin_perms.get('R', []) or self.gitolite_admin_system_user not in gitolite_admin_perms.get('W', [])): add_warning( req, _('Read and write permissions on the gitolite admin repo must not be revoked for user %s -- otherwise this plugin will no longer work!' % self.gitolite_admin_system_user)) req.redirect(req.href.admin(category, page)) utils.save_file(self.gitolite_admin_ssh_path, 'conf/gitolite.conf', utils.to_string(perms), _('Updating repository permissions')) add_notice(req, _('The permissions have been updated.')) req.redirect(req.href.admin(category, page)) perms = self.read_config() users_listed_in_perms = set() flattened_perms = set() for p in perms.values(): for perm in p: flattened_perms.add(perm) users_listed_in_perms.update(p[perm]) flattened_perms = list(flattened_perms) def sort_perms(perms): tail = [] ## Ensure the + goes last if '+' in perms: perms.remove("+") tail.append("+") perms = sorted(perms) perms.extend(tail) return perms flattened_perms = sort_perms(flattened_perms) users = sorted( list(set(list(self.get_users()) + list(users_listed_in_perms)))) data = { 'repositories': perms, 'permissions': flattened_perms, 'users': users, 'sort_perms': sort_perms } return 'admin_repository_permissions.html', data # ITemplateProvider methods def get_htdocs_dirs(self): return [] def get_templates_dirs(self): return [pkg_resources.resource_filename('trac_gitolite', 'templates')]
class AuthzPolicy(Component): """Permission policy using an authz-like configuration file. Refer to SVN documentation for syntax of the authz file. Groups are supported. As the fine-grained permissions brought by this permission policy are often used in complement of the other pemission policies (like the `DefaultPermissionPolicy`), there's no need to redefine all the permissions here. Only additional rights or restrictions should be added. === Installation === Note that this plugin requires the `configobj` package: http://www.voidspace.org.uk/python/configobj.html You should be able to install it by doing a simple `easy_install configobj` Enabling this policy requires listing it in `trac.ini: {{{ [trac] permission_policies = AuthzPolicy, DefaultPermissionPolicy [authz_policy] authz_file = conf/authzpolicy.conf }}} This means that the `AuthzPolicy` permissions will be checked first, and only if no rule is found will the `DefaultPermissionPolicy` be used. === Configuration === The `authzpolicy.conf` file is a `.ini` style configuration file. - Each section of the config is a glob pattern used to match against a Trac resource descriptor. These descriptors are in the form: {{{ <realm>:<id>@<version>[/<realm>:<id>@<version> ...] }}} Resources are ordered left to right, from parent to child. If any component is inapplicable, `*` is substituted. If the version pattern is not specified explicitely, all versions (`@*`) is added implicitly Example: Match the WikiStart page {{{ [wiki:*] [wiki:WikiStart*] [wiki:WikiStart@*] [wiki:WikiStart] }}} Example: Match the attachment `wiki:WikiStart@117/attachment/FOO.JPG@*` on WikiStart {{{ [wiki:*] [wiki:WikiStart*] [wiki:WikiStart@*] [wiki:WikiStart@*/attachment/*] [wiki:WikiStart@117/attachment/FOO.JPG] }}} - Sections are checked against the current Trac resource '''IN ORDER''' of appearance in the configuration file. '''ORDER IS CRITICAL'''. - Once a section matches, the current username is matched, '''IN ORDER''', against the keys of the section. If a key is prefixed with a `@`, it is treated as a group. If a key is prefixed with a `!`, the permission is denied rather than granted. The username will match any of 'anonymous', 'authenticated', <username> or '*', using normal Trac permission rules. Example configuration: {{{ [groups] administrators = athomas [*/attachment:*] * = WIKI_VIEW, TICKET_VIEW [wiki:WikiStart@*] @administrators = WIKI_ADMIN anonymous = WIKI_VIEW * = WIKI_VIEW # Deny access to page templates [wiki:PageTemplates/*] * = # Match everything else [*] @administrators = TRAC_ADMIN anonymous = BROWSER_VIEW, CHANGESET_VIEW, FILE_VIEW, LOG_VIEW, MILESTONE_VIEW, POLL_VIEW, REPORT_SQL_VIEW, REPORT_VIEW, ROADMAP_VIEW, SEARCH_VIEW, TICKET_CREATE, TICKET_MODIFY, TICKET_VIEW, TIMELINE_VIEW, WIKI_CREATE, WIKI_MODIFY, WIKI_VIEW # Give authenticated users some extra permissions authenticated = REPO_SEARCH, XML_RPC }}} """ implements(IPermissionPolicy) authz_file = Option('authz_policy', 'authz_file', '', 'Location of authz policy configuration file.') authz = None authz_mtime = None # IPermissionPolicy methods def check_permission(self, action, username, resource, perm): if ConfigObj is None: self.log.error('configobj package not found') return None if self.authz_file and not self.authz_mtime or \ os.path.getmtime(self.get_authz_file()) > self.authz_mtime: self.parse_authz() resource_key = self.normalise_resource(resource) self.log.debug('Checking %s on %s', action, resource_key) permissions = self.authz_permissions(resource_key, username) if permissions is None: return None # no match, can't decide elif permissions == ['']: return False # all actions are denied # FIXME: expand all permissions once for all ps = PermissionSystem(self.env) for deny, perms in groupby(permissions, key=lambda p: p.startswith('!')): if deny and action in ps.expand_actions([p[1:] for p in perms]): return False # action is explicitly denied elif action in ps.expand_actions(perms): return True # action is explicitly granted return None # no match for action, can't decide # Internal methods def get_authz_file(self): f = self.authz_file return f if os.path.isabs(f) else os.path.join(self.env.path, f) def parse_authz(self): self.log.debug('Parsing authz security policy %s', self.get_authz_file()) self.authz = ConfigObj(self.get_authz_file(), encoding='utf8') groups = {} for group, users in self.authz.get('groups', {}).iteritems(): if isinstance(users, basestring): users = [users] groups[group] = users self.groups_by_user = {} def add_items(group, items): for item in items: if item.startswith('@'): add_items(group, groups[item[1:]]) else: self.groups_by_user.setdefault(item, set()).add(group) for group, users in groups.iteritems(): add_items('@' + group, users) self.authz_mtime = os.path.getmtime(self.get_authz_file()) def normalise_resource(self, resource): def flatten(resource): if not resource: return ['*:*@*'] if not (resource.realm or resource.id): return [ '%s:%s@%s' % (resource.realm or '*', resource.id or '*', resource.version or '*') ] # XXX Due to the mixed functionality in resource we can end up with # ticket, ticket:1, ticket:1@10. This code naively collapses all # subsets of the parent resource into one. eg. ticket:1@10 parent = resource.parent while parent and (resource.realm == parent.realm or (resource.realm == parent.realm and resource.id == parent.id)): parent = parent.parent if parent: parent = flatten(parent) else: parent = [] return parent + [ '%s:%s@%s' % (resource.realm or '*', resource.id or '*', resource.version or '*') ] return '/'.join(flatten(resource)) def authz_permissions(self, resource_key, username): # TODO: Handle permission negation in sections. eg. "if in this # ticket, remove TICKET_MODIFY" if username and username != 'anonymous': valid_users = ['*', 'authenticated', username] else: valid_users = ['*', 'anonymous'] for resource_section in [ a for a in self.authz.sections if a != 'groups' ]: resource_glob = resource_section if '@' not in resource_glob: resource_glob += '@*' if fnmatch(resource_key, resource_glob): section = self.authz[resource_section] for who, permissions in section.iteritems(): if who in valid_users or \ who in self.groups_by_user.get(username, []): self.log.debug('%s matched section %s for user %s', resource_key, resource_glob, username) if isinstance(permissions, basestring): return [permissions] else: return permissions return None
class RepositoryManager(Component): """Version control system manager.""" implements(IRequestFilter, IResourceManager, IRepositoryProvider) connectors = ExtensionPoint(IRepositoryConnector) providers = ExtensionPoint(IRepositoryProvider) change_listeners = ExtensionPoint(IRepositoryChangeListener) repositories_section = ConfigSection( 'repositories', """One of the alternatives for registering new repositories is to populate the `[repositories]` section of the `trac.ini`. This is especially suited for setting up convenience aliases, short-lived repositories, or during the initial phases of an installation. See [TracRepositoryAdmin#Intrac.ini TracRepositoryAdmin] for details about the format adopted for this section and the rest of that page for the other alternatives. (''since 0.12'')""") repository_type = Option( 'trac', 'repository_type', 'svn', """Default repository connector type. (''since 0.10'') This is also used as the default repository type for repositories defined in [[TracIni#repositories-section repositories]] or using the "Repositories" admin panel. (''since 0.12'') """) repository_dir = Option( 'trac', 'repository_dir', '', """Path to the default repository. This can also be a relative path (''since 0.11''). This option is deprecated, and repositories should be defined in the [TracIni#repositories-section repositories] section, or using the "Repositories" admin panel. (''since 0.12'')""") repository_sync_per_request = ListOption( 'trac', 'repository_sync_per_request', '(default)', doc="""List of repositories that should be synchronized on every page request. Leave this option empty if you have set up post-commit hooks calling `trac-admin $ENV changeset added` on all your repositories (recommended). Otherwise, set it to a comma-separated list of repository names. Note that this will negatively affect performance, and will prevent changeset listeners from receiving events from the repositories specified here. (''since 0.12'')""") def __init__(self): self._cache = {} self._lock = threading.Lock() self._connectors = None self._all_repositories = None # IRequestFilter methods def pre_process_request(self, req, handler): from trac.web.chrome import Chrome, add_warning if handler is not Chrome(self.env): for reponame in self.repository_sync_per_request: start = time.time() if is_default(reponame): reponame = '' try: repo = self.get_repository(reponame) if repo: repo.sync() else: self.log.warning( "Unable to find repository '%s' for " "synchronization", reponame or '(default)') continue except TracError as e: add_warning( req, _( "Can't synchronize with repository \"%(name)s\" " "(%(error)s). Look in the Trac log for more " "information.", name=reponame or '(default)', error=to_unicode(e))) except Exception as e: add_warning( req, _( "Failed to sync with repository \"%(name)s\": " "%(error)s; repository information may be out of " "date. Look in the Trac log for more information " "including mitigation strategies.", name=reponame or '(default)', error=to_unicode(e))) self.log.error( "Failed to sync with repository \"%s\"; You may be " "able to reduce the impact of this issue by " "configuring [trac] repository_sync_per_request; see " "http://trac.edgewall.org/wiki/TracRepositoryAdmin" "#ExplicitSync for more detail: %s", reponame or '(default)', exception_to_unicode(e, traceback=True)) self.log.info("Synchronized '%s' repository in %0.2f seconds", reponame or '(default)', time.time() - start) return handler def post_process_request(self, req, template, data, content_type): return (template, data, content_type) # IResourceManager methods def get_resource_realms(self): yield 'changeset' yield 'source' yield 'repository' def get_resource_description(self, resource, format=None, **kwargs): if resource.realm == 'changeset': parent = resource.parent reponame = parent and parent.id id = resource.id if reponame: return _("Changeset %(rev)s in %(repo)s", rev=id, repo=reponame) else: return _("Changeset %(rev)s", rev=id) elif resource.realm == 'source': parent = resource.parent reponame = parent and parent.id id = resource.id version = '' if format == 'summary': repos = self.get_repository(reponame) node = repos.get_node(resource.id, resource.version) if node.isdir: kind = _("directory") elif node.isfile: kind = _("file") if resource.version: version = _(" at version %(rev)s", rev=resource.version) else: kind = _("path") if resource.version: version = '@%s' % resource.version in_repo = _(" in %(repo)s", repo=reponame) if reponame else '' # TRANSLATOR: file /path/to/file.py at version 13 in reponame return _('%(kind)s %(id)s%(at_version)s%(in_repo)s', kind=kind, id=id, at_version=version, in_repo=in_repo) elif resource.realm == 'repository': if not resource.id: return _("Default repository") return _("Repository %(repo)s", repo=resource.id) def get_resource_url(self, resource, href, **kwargs): if resource.realm == 'changeset': parent = resource.parent return href.changeset(resource.id, parent and parent.id or None) elif resource.realm == 'source': parent = resource.parent return href.browser(parent and parent.id or None, resource.id, rev=resource.version or None) elif resource.realm == 'repository': return href.browser(resource.id or None) def resource_exists(self, resource): if resource.realm == 'repository': reponame = resource.id else: reponame = resource.parent.id repos = self.env.get_repository(reponame) if not repos: return False if resource.realm == 'changeset': try: repos.get_changeset(resource.id) return True except NoSuchChangeset: return False elif resource.realm == 'source': try: repos.get_node(resource.id, resource.version) return True except NoSuchNode: return False elif resource.realm == 'repository': return True # IRepositoryProvider methods def get_repositories(self): """Retrieve repositories specified in TracIni. The `[repositories]` section can be used to specify a list of repositories. """ repositories = self.repositories_section reponames = {} # eventually add pre-0.12 default repository if self.repository_dir: reponames[''] = {'dir': self.repository_dir} # first pass to gather the <name>.dir entries for option in repositories: if option.endswith('.dir'): reponames[option[:-4]] = {} # second pass to gather aliases for option in repositories: alias = repositories.get(option) if '.' not in option: # Support <alias> = <repo> syntax option += '.alias' if option.endswith('.alias') and alias in reponames: reponames.setdefault(option[:-6], {})['alias'] = alias # third pass to gather the <name>.<detail> entries for option in repositories: if '.' in option: name, detail = option.rsplit('.', 1) if name in reponames and detail != 'alias': reponames[name][detail] = repositories.get(option) for reponame, info in reponames.iteritems(): yield (reponame, info) # Public API methods def get_supported_types(self): """Return the list of supported repository types.""" types = set(type_ for connector in self.connectors for (type_, prio) in connector.get_supported_types() or [] if prio >= 0) return list(types) def get_repositories_by_dir(self, directory): """Retrieve the repositories based on the given directory. :param directory: the key for identifying the repositories. :return: list of `Repository` instances. """ directory = os.path.join(os.path.normcase(directory), '') repositories = [] for reponame, repoinfo in self.get_all_repositories().iteritems(): dir = repoinfo.get('dir') if dir: dir = os.path.join(os.path.normcase(dir), '') if dir.startswith(directory): repos = self.get_repository(reponame) if repos: repositories.append(repos) return repositories def get_repository_id(self, reponame): """Return a unique id for the given repository name. This will create and save a new id if none is found. Note: this should probably be renamed as we're dealing exclusively with *db* repository ids here. """ with self.env.db_transaction as db: for id, in db( "SELECT id FROM repository WHERE name='name' AND value=%s", (reponame, )): return id id = db("SELECT COALESCE(MAX(id), 0) FROM repository")[0][0] + 1 db("INSERT INTO repository (id, name, value) VALUES (%s, %s, %s)", (id, 'name', reponame)) return id def get_repository(self, reponame): """Retrieve the appropriate `Repository` for the given repository name. :param reponame: the key for specifying the repository. If no name is given, take the default repository. :return: if no corresponding repository was defined, simply return `None`. """ reponame = reponame or '' repoinfo = self.get_all_repositories().get(reponame, {}) if 'alias' in repoinfo: reponame = repoinfo['alias'] repoinfo = self.get_all_repositories().get(reponame, {}) rdir = repoinfo.get('dir') if not rdir: return None rtype = repoinfo.get('type') or self.repository_type # get a Repository for the reponame (use a thread-level cache) with self.env.db_transaction: # prevent possible deadlock, see #4465 with self._lock: tid = threading._get_ident() if tid in self._cache: repositories = self._cache[tid] else: repositories = self._cache[tid] = {} repos = repositories.get(reponame) if not repos: if not os.path.isabs(rdir): rdir = os.path.join(self.env.path, rdir) connector = self._get_connector(rtype) repos = connector.get_repository(rtype, rdir, repoinfo.copy()) repositories[reponame] = repos return repos def get_repository_by_path(self, path): """Retrieve a matching `Repository` for the given `path`. :param path: the eventually scoped repository-scoped path :return: a `(reponame, repos, path)` triple, where `path` is the remaining part of `path` once the `reponame` has been truncated, if needed. """ matches = [] path = path.strip('/') + '/' if path else '/' for reponame in self.get_all_repositories().keys(): stripped_reponame = reponame.strip('/') + '/' if path.startswith(stripped_reponame): matches.append((len(stripped_reponame), reponame)) if matches: matches.sort() length, reponame = matches[-1] path = path[length:] else: reponame = '' return (reponame, self.get_repository(reponame), path.rstrip('/') or '/') def get_default_repository(self, context): """Recover the appropriate repository from the current context. Lookup the closest source or changeset resource in the context hierarchy and return the name of its associated repository. """ while context: if context.resource.realm in ('source', 'changeset'): return context.resource.parent.id context = context.parent def get_all_repositories(self): """Return a dictionary of repository information, indexed by name.""" if not self._all_repositories: all_repositories = {} for provider in self.providers: for reponame, info in provider.get_repositories() or []: if reponame in all_repositories: self.log.warn("Discarding duplicate repository '%s'", reponame) else: info['name'] = reponame if 'id' not in info: info['id'] = self.get_repository_id(reponame) all_repositories[reponame] = info self._all_repositories = all_repositories return self._all_repositories def get_real_repositories(self): """Return a set of all real repositories (i.e. excluding aliases).""" repositories = set() for reponame in self.get_all_repositories(): try: repos = self.get_repository(reponame) if repos is not None: repositories.add(repos) except TracError: pass # Skip invalid repositories return repositories def reload_repositories(self): """Reload the repositories from the providers.""" with self._lock: # FIXME: trac-admin doesn't reload the environment self._cache = {} self._all_repositories = None self.config.touch() # Force environment reload def notify(self, event, reponame, revs): """Notify repositories and change listeners about repository events. The supported events are the names of the methods defined in the `IRepositoryChangeListener` interface. """ self.log.debug("Event %s on repository '%s' for changesets %r", event, reponame or '(default)', revs) # Notify a repository by name, and all repositories with the same # base, or all repositories by base or by repository dir repos = self.get_repository(reponame) repositories = [] if repos: base = repos.get_base() else: dir = os.path.abspath(reponame) repositories = self.get_repositories_by_dir(dir) if repositories: base = None else: base = reponame if base: repositories = [ r for r in self.get_real_repositories() if r.get_base() == base ] if not repositories: self.log.warn("Found no repositories matching '%s' base.", base or reponame) return for repos in sorted(repositories, key=lambda r: r.reponame): repos.sync() for rev in revs: args = [] if event == 'changeset_modified': args.append(repos.sync_changeset(rev)) try: changeset = repos.get_changeset(rev) except NoSuchChangeset: try: repos.sync_changeset(rev) changeset = repos.get_changeset(rev) except NoSuchChangeset: self.log.debug( "No changeset '%s' found in repository '%s'. " "Skipping subscribers for event %s", rev, repos.reponame or '(default)', event) continue self.log.debug("Event %s on repository '%s' for revision '%s'", event, repos.reponame or '(default)', rev) for listener in self.change_listeners: getattr(listener, event)(repos, changeset, *args) def shutdown(self, tid=None): """Free `Repository` instances bound to a given thread identifier""" if tid: assert tid == threading._get_ident() with self._lock: repositories = self._cache.pop(tid, {}) for reponame, repos in repositories.iteritems(): repos.close() # private methods def _get_connector(self, rtype): """Retrieve the appropriate connector for the given repository type. Note that the self._lock must be held when calling this method. """ if self._connectors is None: # build an environment-level cache for the preferred connectors self._connectors = {} for connector in self.connectors: for type_, prio in connector.get_supported_types() or []: keep = (connector, prio) if type_ in self._connectors and \ prio <= self._connectors[type_][1]: keep = None if keep: self._connectors[type_] = keep if rtype in self._connectors: connector, prio = self._connectors[rtype] if prio >= 0: # no error condition return connector else: raise TracError( _( 'Unsupported version control system "%(name)s"' ': %(error)s', name=rtype, error=to_unicode(connector.error))) else: raise TracError( _( 'Unsupported version control system "%(name)s": ' 'Can\'t find an appropriate component, maybe the ' 'corresponding plugin was not enabled? ', name=rtype))
class TasklistPlugin(QueryModule): implements(ITemplateProvider, ITemplateStreamFilter) ticket_manipulators = ExtensionPoint(ITicketManipulator) field_name = Option('tasklist', 'tasklist_field', default='action_item') default_query = Option( 'tasklist', 'default_query', default='status!=closed&owner=$USER', doc='The default tasklist query for authenticated users.') default_anonymous_query = Option( 'tasklist', 'default_anonymous_query', default='status!=closed&cc~=$USER', doc='The default tasklist query for anonymous users.') default_cols = ListOption( 'tasklist', 'default_cols', default=['id', 'summary', 'priority'], doc='The default list of columns to show in the tasklist.') # INavigationContributor methods def get_active_navigation_item(self, req): return 'tasklist' def get_navigation_items(self, req): if req.perm.has_permission('TICKET_VIEW'): yield ('mainnav', 'tasklist', tag.a('Tasklist', href=req.href.tasklist())) # IRequestHandler methods def match_request(self, req): return req.path_info.startswith('/tasklist') def process_request(self, req): req.perm.assert_permission('TICKET_VIEW') if not self.env.config.has_option('ticket-custom', self.field_name): raise TracError( 'Configuration error: the custom ticket field "%s" has not been defined ' 'in the [ticket-custom] section of trac.ini. See the documentation ' 'for more info on configuring the TaskListPlugin.' % self.field_name) constraints = self._get_constraints(req) if not constraints and not 'order' in req.args: # If no constraints are given in the URL, use the default ones. if req.authname and req.authname != 'anonymous': qstring = self.default_query user = req.authname else: email = req.session.get('email') name = req.session.get('name') qstring = self.default_anonymous_query user = email or name or None if user: qstring = qstring.replace('$USER', user) self.log.debug('TasklistPlugin: Using default query: %s', qstring) constraints = Query.from_string(self.env, qstring).constraints # Ensure no field constraints that depend on $USER are used # if we have no username. for field, vals in constraints.items(): for val in vals: if val.endswith('$USER'): del constraints[field] cols = req.args.get('col') if not cols: cols = self.default_cols cols.append(self.field_name) if isinstance(cols, basestring): cols = [cols] form_cols = copy.copy(cols) # Since we don't show 'id' or the tasklist_field as an option # to the user, we need to re-insert it here. if cols and 'id' not in cols: cols.insert(0, 'id') if cols and self.field_name not in cols: cols.insert(0, self.field_name) rows = req.args.get('row', []) if isinstance(rows, basestring): rows = [rows] q = Query(self.env, constraints=constraints, cols=cols) query = Query(self.env, req.args.get('report'), constraints, cols, req.args.get('order'), 'desc' in req.args, req.args.get('group'), 'groupdesc' in req.args, 'verbose' in req.args, rows, req.args.get('limit')) if 'update' in req.args: # Reset session vars for var in ('query_constraints', 'query_time', 'query_tickets'): if var in req.session: del req.session[var] req.redirect(q.get_href(req.href).replace('/query', '/tasklist')) if 'add' in req.args: req.perm.require('TICKET_CREATE') t = Ticket(self.env) if req.method == 'POST' and 'field_owner' in req.args and \ 'TICKET_MODIFY' not in req.perm: del req.args['field_owner'] self._populate(req, t) reporter_id = req.args.get('field_reporter') or \ get_reporter_id(req, 'author') t.values['reporter'] = reporter_id valid = None valid = self._validate_ticket(req, t) if valid: t.insert() # 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)) req.redirect(q.get_href(req.href).replace('/query', '/tasklist')) template, data, mime_type = self.display_html(req, query) # We overlap the query session href var so that if a ticket is # entered from the tasklist the "Back to Query" link will # come back to the tasklist instead of the query module. query_href = req.session['query_href'] req.session['query_href'] = query_href.replace('/query', '/tasklist') data['title'] = 'Task List' data['all_columns'].remove(self.field_name) #_pprint(data['tickets']) for ticket in data['tickets']: summary = ticket['summary'] action = ticket[self.field_name] ticket['title'] = summary ticket['summary'] = action != '--' and action or summary continue for i, header in enumerate(data['headers']): header['href'] = header['href'].replace('/query', '/tasklist') if header['name'] == self.field_name: del_index = i continue del data['headers'][del_index] data['ticket_fields'] = self._get_ticket_fields(data) add_stylesheet(req, 'tasklist/css/tasklist.css') return 'tasklist.html', data, mime_type
class MasterTicketsModule(Component): """Provides support for ticket dependencies.""" implements(IRequestHandler, IRequestFilter, ITemplateStreamFilter, ITemplateProvider, ITicketManipulator) dot_path = Option('mastertickets', 'dot_path', default='dot', doc='Path to the dot executable.') gs_path = Option('mastertickets', 'gs_path', default='gs', doc='Path to the ghostscript executable.') use_gs = BoolOption( 'mastertickets', 'use_gs', default=False, doc='If enabled, use ghostscript to produce nicer output.') closed_color = Option('mastertickets', 'closed_color', default='green', doc='Color of closed tickets') opened_color = Option('mastertickets', 'opened_color', default='red', doc='Color of opened tickets') show_key = Option('mastertickets', 'show_key', default=False, doc='Show a key for open/closed nodes') closed_text = Option('mastertickets', 'closed_text', default='Done', doc='Text for key showing closed tickets') opened_text = Option('mastertickets', 'opened_text', default='ToDo', doc='Text for key showing opened tickets') highlight_target = Option('mastertickets', 'highlight_target', default=False, doc='Highlight target tickets in graph') full_graph = Option( 'mastertickets', 'full_graph', default=False, doc='Show full dep. graph, not just direct blocking links') graph_direction = ChoiceOption( 'mastertickets', 'graph_direction', choices=['TD', 'LR', 'DT', 'RL'], doc= 'Direction of the dependency graph (TD = Top Down, DT = Down Top, LR = Left Right, RL = Right Left)' ) fields = set(['blocking', 'blockedby']) # IRequestFilter methods def pre_process_request(self, req, handler): return handler def post_process_request(self, req, template, data, content_type): if req.path_info.startswith('/ticket/'): # In case of an invalid ticket, the data is invalid if not data: return template, data, content_type tkt = data['ticket'] links = TicketLinks(self.env, tkt) for i in links.blocked_by: if Ticket(self.env, i)['status'] != 'closed': add_script(req, 'mastertickets/disable_resolve.js') break # Add link to depgraph if needed if links: add_ctxtnav(req, 'Depgraph', req.href.depgraph(tkt.id)) for change in data.get('changes', {}): if not change.has_key('fields'): continue for field, field_data in change['fields'].iteritems(): if field in self.fields: if field_data['new'].strip(): new = set( [int(n) for n in field_data['new'].split(',')]) else: new = set() if field_data['old'].strip(): old = set( [int(n) for n in field_data['old'].split(',')]) else: old = set() add = new - old sub = old - new elms = tag() if add: elms.append( tag.em(u', '.join( [unicode(n) for n in sorted(add)]))) elms.append(u' added') if add and sub: elms.append(u'; ') if sub: elms.append( tag.em(u', '.join( [unicode(n) for n in sorted(sub)]))) elms.append(u' removed') field_data['rendered'] = elms #add a link to generate a dependency graph for all the tickets in the milestone if req.path_info.startswith('/milestone/'): if not data: return template, data, content_type milestone = data['milestone'] add_ctxtnav(req, 'Depgraph', req.href.depgraph('milestone', milestone.name)) return template, data, content_type # ITemplateStreamFilter methods def filter_stream(self, req, method, filename, stream, data): if not data: return stream # We try all at the same time to maybe catch also changed or processed templates if filename in [ "report_view.html", "query_results.html", "ticket.html", "query.html" ]: # For ticket.html if 'fields' in data and isinstance(data['fields'], list): for field in data['fields']: for f in self.fields: if field['name'] == f and data['ticket'][f]: field['rendered'] = self._link_tickets( req, data['ticket'][f]) # For query_results.html and query.html if 'groups' in data and isinstance(data['groups'], list): for group, tickets in data['groups']: for ticket in tickets: for f in self.fields: if f in ticket: ticket[f] = self._link_tickets(req, ticket[f]) # For report_view.html if 'row_groups' in data and isinstance(data['row_groups'], list): for group, rows in data['row_groups']: for row in rows: if 'cell_groups' in row and isinstance( row['cell_groups'], list): for cells in row['cell_groups']: for cell in cells: # If the user names column in the report differently (blockedby AS "blocked by") then this will not find it if cell.get('header', {}).get('col') in self.fields: cell['value'] = self._link_tickets( req, cell['value']) return stream # ITicketManipulator methods def prepare_ticket(self, req, ticket, fields, actions): pass def validate_ticket(self, req, ticket): if req.args.get('action') == 'resolve' and req.args.get( 'action_resolve_resolve_resolution') == 'fixed': links = TicketLinks(self.env, ticket) for i in links.blocked_by: if Ticket(self.env, i)['status'] != 'closed': yield None, 'Ticket #%s is blocking this ticket' % i # ITemplateProvider methods def get_htdocs_dirs(self): """Return the absolute path of a directory containing additional static resources (such as images, style sheets, etc). """ return [('mastertickets', resource_filename(__name__, 'htdocs'))] def get_templates_dirs(self): """Return the absolute path of the directory containing the provided ClearSilver templates. """ return [resource_filename(__name__, 'templates')] # IRequestHandler methods def match_request(self, req): return req.path_info.startswith('/depgraph') def process_request(self, req): path_info = req.path_info[10:] if not path_info: raise TracError('No ticket specified') #list of tickets to generate the depgraph for tkt_ids = [] milestone = None split_path = path_info.split('/', 2) #Urls to generate the depgraph for a ticket is /depgraph/ticketnum #Urls to generate the depgraph for a milestone is /depgraph/milestone/milestone_name if split_path[0] == 'milestone': #we need to query the list of tickets in the milestone milestone = split_path[1] query = Query(self.env, constraints={'milestone': [milestone]}, max=0) tkt_ids = [fields['id'] for fields in query.execute()] else: #the list is a single ticket tkt_ids = [int(split_path[0])] #the summary argument defines whether we place the ticket id or #it's summary in the node's label label_summary = 0 if 'summary' in req.args: label_summary = int(req.args.get('summary')) g = self._build_graph(req, tkt_ids, label_summary=label_summary) if path_info.endswith('/depgraph.png') or 'format' in req.args: format = req.args.get('format') if format == 'text': #in case g.__str__ returns unicode, we need to convert it in ascii req.send( to_unicode(g).encode('ascii', 'replace'), 'text/plain') elif format == 'debug': import pprint req.send( pprint.pformat( [TicketLinks(self.env, tkt_id) for tkt_id in tkt_ids]), 'text/plain') elif format is not None: req.send(g.render(self.dot_path, format), 'text/plain') if self.use_gs: ps = g.render(self.dot_path, 'ps2') gs = subprocess.Popen([ self.gs_path, '-q', '-dTextAlphaBits=4', '-dGraphicsAlphaBits=4', '-sDEVICE=png16m', '-sOutputFile=%stdout%', '-' ], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) img, err = gs.communicate(ps) if err: self.log.debug('MasterTickets: Error from gs: %s', err) else: img = g.render(self.dot_path) req.send(img, 'image/png') else: data = {} #add a context link to enable/disable labels in nodes if label_summary: add_ctxtnav(req, 'Without labels', req.href(req.path_info, summary=0)) else: add_ctxtnav(req, 'With labels', req.href(req.path_info, summary=1)) if milestone is None: tkt = Ticket(self.env, tkt_ids[0]) data['tkt'] = tkt add_ctxtnav(req, 'Back to Ticket #%s' % tkt.id, req.href.ticket(tkt.id)) else: add_ctxtnav(req, 'Back to Milestone %s' % milestone, req.href.milestone(milestone)) data['milestone'] = milestone data['graph'] = g data['graph_render'] = partial(g.render, self.dot_path) data['use_gs'] = self.use_gs return 'depgraph.html', data, None def _build_graph(self, req, tkt_ids, label_summary=0): g = graphviz.Graph() g.label_summary = label_summary g.attributes['rankdir'] = self.graph_direction node_default = g['node'] node_default['style'] = 'filled' edge_default = g['edge'] edge_default['style'] = '' # Force this to the top of the graph for id in tkt_ids: g[id] if self.show_key: g[-1]['label'] = self.closed_text g[-1]['fillcolor'] = self.closed_color g[-1]['shape'] = 'box' g[-2]['label'] = self.opened_text g[-2]['fillcolor'] = self.opened_color g[-2]['shape'] = 'box' links = TicketLinks.walk_tickets(self.env, tkt_ids, full=self.full_graph) links = sorted(links, key=lambda link: link.tkt.id) for link in links: tkt = link.tkt node = g[tkt.id] if label_summary: node['label'] = u'#%s %s' % (tkt.id, tkt['summary']) else: node['label'] = u'#%s' % tkt.id node['fillcolor'] = tkt[ 'status'] == 'closed' and self.closed_color or self.opened_color node['URL'] = req.href.ticket(tkt.id) node['alt'] = u'Ticket #%s' % tkt.id node['tooltip'] = tkt['summary'] if self.highlight_target and tkt.id in tkt_ids: node['penwidth'] = 3 for n in link.blocking: node > g[n] return g def _link_tickets(self, req, tickets): items = [] for i, word in enumerate(re.split(r'([;,\s]+)', tickets)): if i % 2: items.append(word) elif word: ticketid = word word = '#%s' % word try: ticket = Ticket(self.env, ticketid) if 'TICKET_VIEW' in req.perm(ticket.resource): word = \ tag.a( '#%s' % ticket.id, class_=ticket['status'], href=req.href.ticket(int(ticket.id)), title=shorten_line(ticket['summary']) ) except ResourceNotFound: pass items.append(word) if items: return tag(items) else: return None
class ODTExportPlugin(Component): """Convert Wiki pages to ODT.""" implements(IContentConverter) img_width = Option('odtexport', 'img_default_width', '8cm') img_height = Option('odtexport', 'img_default_height', '6cm') img_dpi = IntOption('odtexport', 'dpi', '96') get_remote_images = BoolOption('odtexport', 'get_remote_images', True) replace_keyword = Option('odtexport', 'replace_keyword', 'TRAC-ODT-INSERT') wikiversion_keyword = Option('odtexport', 'wikiversion_keyword', 'TRAC-ODT-WIKIVERSION') wikiname_keyword = Option('odtexport', 'wikiname_keyword', 'TRAC-ODT-WIKINAME') timestamp_keyword = Option('odtexport', 'timestamp_keyword', 'TRAC-ODT-TIMESTAMP') cut_start_keyword = Option('odtexport', 'cut_start_keyword', 'TRAC-ODT-CUT-START') cut_stop_keyword = Option('odtexport', 'cut_stop_keyword', 'TRAC-ODT-CUT-STOP') remove_macros = ListOption( 'odtexport', 'remove_macros', "PageOutline, TracGuideToc, TOC, TranslatedPages") # IContentConverter methods def get_supported_conversions(self): yield ('odt', 'OpenDocument', 'odt', 'text/x-trac-wiki', 'application/vnd.oasis.opendocument.text', 5) def convert_content(self, req, input_type, content, output_type): # pylint: disable-msg=W0613 self.page_name = req.args.get('page', 'WikiStart') #wikipage = WikiPage(self.env, self.page_name) template = self.get_template_name(content) html = self.wiki_to_html(content, req) #return (html, "text/plain") odtfile = ODTFile( self.page_name, req.args.get('version', 'latest'), template, self.env, # pylint: disable-msg=E1101 options={ "img_width": self.img_width, "img_height": self.img_height, "img_dpi": self.img_dpi, "get_remote_images": self.get_remote_images, "replace_keyword": self.replace_keyword, "wikiversion_keyword": self.wikiversion_keyword, "wikiname_keyword": self.wikiname_keyword, "timestamp_keyword": self.timestamp_keyword, "cut_start_keyword": self.cut_start_keyword, "cut_stop_keyword": self.cut_stop_keyword, }) odtfile.open() #return (odtfile.import_xhtml(html), "text/plain") odtfile.import_xhtml(html) newdoc = odtfile.save() return (newdoc, "application/vnd.oasis.opendocument.text") def get_template_name(self, wikitext): template_macro = re.search('\[\[OdtTemplate\(([^)]+)\)\]\]', wikitext) if template_macro: tpl = template_macro.group(1) if tpl.endswith(".odt"): return tpl else: return "%s.odt" % tpl return "wikipage.odt" def wiki_to_html(self, wikitext, req): self.env.log.debug( 'start function wiki_to_html') # pylint: disable-msg=E1101 # Remove some macros (TOC is better handled in ODT itself) for macro in self.remove_macros: wikitext = re.sub('\[\[%s(\([^)]*\))?\]\]' % macro, "", wikitext) # Now convert wiki to HTML out = StringIO() context = Context.from_request(req, absurls=True) Formatter( self.env, # pylint: disable-msg=E1101 context('wiki', self.page_name)).format(wikitext, out) html = Markup(out.getvalue()) html = html.encode("utf-8", 'replace') # Clean up the HTML html = re.sub('<span class="icon">.</span>', '', html) # Remove external link icon tidy_options = dict(output_xhtml=1, add_xml_decl=1, indent=1, tidy_mark=0, input_encoding='utf8', output_encoding='utf8', doctype='auto', wrap=0, char_encoding='utf8') html = tidy.parseString(html, **tidy_options) # Replace nbsp with entity: # http://www.mail-archive.com/[email protected]/msg03670.html html = str(html).replace(" ", " ") # Tidy creates newlines after <pre> (by indenting) html = re.sub('<pre([^>]*)>\n', '<pre\\1>', html) return html
class DateFieldModule(Component): """A module providing a JS date picker for custom fields.""" date_format = Option( 'datefield', 'format', default='dmy', doc='The format to use for dates. Valid values are dmy, mdy, and ymd.') date_sep = Option('datefield', 'separator', default='/', doc='The separator character to use for dates.') implements(IRequestFilter, IRequestHandler, ITemplateProvider, ITicketManipulator) # IRequestHandler methods def match_request(self, req): return req.path_info.startswith('/datefield') def process_request(self, req): req.hdf['datefield.ids'] = list(self._date_fields()) req.hdf['datefield.calendar'] = req.href.chrome( 'datefield', 'calendar.png') req.hdf['datefield.format'] = self.date_format req.hdf['datefield.sep'] = self.date_sep return 'datefield.cs', 'text/javascript' # IRequestFilter methods def pre_process_request(self, req, handler): return handler def post_process_request(self, req, template, content_type): if req.path_info.startswith('/newticket') or req.path_info.startswith( '/ticket'): add_script(req, 'datefield/jquery.pack.js') add_script(req, 'datefield/jquery.datePicker.js') add_stylesheet(req, 'datefield/datePicker.css') # Add my dynamic JS junk idx = 0 while req.hdf.get('chrome.scripts.%i.href' % idx): idx += 1 req.hdf['chrome.scripts.%s' % idx] = { 'href': req.href.datefield('datefield.js'), 'type': 'text/javascript' } return template, content_type # ITemplateProvider methods def get_htdocs_dirs(self): from pkg_resources import resource_filename return [('datefield', resource_filename(__name__, 'htdocs'))] def get_templates_dirs(self): from pkg_resources import resource_filename return [resource_filename(__name__, 'templates')] # ITicketManipulator methods def prepare_ticket(self, req, ticket, fields, actions): pass def validate_ticket(self, req, ticket): # dmy mdy ymd for field in self._date_fields(): try: val = ticket[field].strip() if not val and self.config['ticket-custom'].getbool( field + '.date_empty', default=False): continue if len(val.split(self.date_sep)) != 3: raise Exception # Token exception to force failure format = self.date_sep.join( ['%' + c for c in self.date_format]) try: time.strptime(val, format) except ValueError: time.strptime(val, format.replace('y', 'Y')) except Exception: self.log.debug( 'DateFieldModule: Got an exception, assuming it is a validation failure.\n' + traceback.format_exc()) yield field, 'Field %s does not seem to look like a date. The correct format is %s.' % \ (field, self.date_sep.join([c.upper()*(c=='y' and 4 or 2) for c in self.date_format])) # Internal methods def _date_fields(self): # XXX: Will this work when there is no ticket-custom section? <NPK> for key, value in self.config['ticket-custom'].options(): if key.endswith('.date'): yield key.split('.', 1)[0]
class PygmentsRenderer(Component): """HTML renderer for syntax highlighting based on Pygments.""" implements(ISystemInfoProvider, IHTMLPreviewRenderer, IPreferencePanelProvider, IRequestHandler, ITemplateProvider) is_valid_default_handler = False pygments_lexer_options = ConfigSection( 'pygments-lexer', """Configure Pygments [%(url)s lexer] options. For example, to set the [%(url)s#lexers-for-php-and-related-languages PhpLexer] options `startinline` and `funcnamehighlighting`: {{{#!ini [pygments-lexer] php.startinline = True php.funcnamehighlighting = True }}} The lexer name is derived from the class name, with `Lexer` stripped from the end. The lexer //short names// can also be used in place of the lexer name. """, doc_args={'url': 'http://pygments.org/docs/lexers/'}) default_style = Option( 'mimeviewer', 'pygments_default_style', 'trac', """The default style to use for Pygments syntax highlighting.""") pygments_modes = ListOption( 'mimeviewer', 'pygments_modes', '', doc="""List of additional MIME types known by Pygments. For each, a tuple `mimetype:mode:quality` has to be specified, where `mimetype` is the MIME type, `mode` is the corresponding Pygments mode to be used for the conversion and `quality` is the quality ratio associated to this conversion. That can also be used to override the default quality ratio used by the Pygments render.""") expand_tabs = True returns_source = True QUALITY_RATIO = 7 EXAMPLE = """<!DOCTYPE html> <html lang="en"> <head> <title>Hello, world!</title> <script> jQuery(document).ready(function($) { $("h1").fadeIn("slow"); }); </script> </head> <body> <h1>Hello, world!</h1> </body> </html>""" # ISystemInfoProvider methods def get_system_info(self): version = get_pkginfo(pygments).get('version') # if installed from source, fallback to the hardcoded version info if not version and hasattr(pygments, '__version__'): version = pygments.__version__ yield 'Pygments', version # IHTMLPreviewRenderer methods def get_extra_mimetypes(self): for _, aliases, _, mimetypes in get_all_lexers(): for mimetype in mimetypes: yield mimetype, aliases def get_quality_ratio(self, mimetype): # Extend default MIME type to mode mappings with configured ones try: return self._types[mimetype][1] except KeyError: return 0 def render(self, context, mimetype, content, filename=None, rev=None): req = context.req style = req.session.get('pygments_style', self.default_style) add_stylesheet(req, '/pygments/%s.css' % style) try: if len(content) > 0: mimetype = mimetype.split(';', 1)[0] language = self._types[mimetype][0] return self._generate(language, content, context) except (KeyError, ValueError): raise Exception("No Pygments lexer found for mime-type '%s'." % mimetype) # IPreferencePanelProvider methods def get_preference_panels(self, req): yield 'pygments', _('Syntax Highlighting') def render_preference_panel(self, req, panel): styles = list(get_all_styles()) if req.method == 'POST': style = req.args.get('style') if style and style in styles: req.session['pygments_style'] = style add_notice(req, _("Your preferences have been saved.")) req.redirect(req.href.prefs(panel or None)) for style in sorted(styles): add_stylesheet(req, '/pygments/%s.css' % style, title=style.title()) output = self._generate('html', self.EXAMPLE) return 'prefs_pygments.html', { 'output': output, 'selection': req.session.get('pygments_style', self.default_style), 'styles': styles } # IRequestHandler methods def match_request(self, req): match = re.match(r'/pygments/([-\w]+)\.css', req.path_info) if match: req.args['style'] = match.group(1) return True def process_request(self, req): style = req.args['style'] try: style_cls = get_style_by_name(style) except ValueError as e: raise HTTPNotFound(e) parts = style_cls.__module__.split('.') filename = resource_filename('.'.join(parts[:-1]), parts[-1] + '.py') mtime = datetime.fromtimestamp(os.path.getmtime(filename), localtz) last_modified = http_date(mtime) if last_modified == req.get_header('If-Modified-Since'): req.send_response(304) req.end_headers() return formatter = HtmlFormatter(style=style_cls) content = u'\n\n'.join([ formatter.get_style_defs('div.code pre'), formatter.get_style_defs('table.code td') ]).encode('utf-8') req.send_response(200) req.send_header('Content-Type', 'text/css; charset=utf-8') req.send_header('Last-Modified', last_modified) req.send_header('Content-Length', len(content)) req.write(content) # ITemplateProvider methods def get_htdocs_dirs(self): return [] def get_templates_dirs(self): return [resource_filename('trac.mimeview', 'templates')] # Internal methods @lazy def _lexer_alias_name_map(self): lexer_alias_name_map = {} for lexer_name, aliases, _, _ in get_all_lexers(): name = aliases[0] if aliases else lexer_name for alias in aliases: lexer_alias_name_map[alias] = name return lexer_alias_name_map @lazy def _lexer_options(self): lexer_options = {} for key, lexer_option_value in self.pygments_lexer_options.options(): try: lexer_name_or_alias, lexer_option_name = key.split('.') except ValueError: pass else: lexer_name = self._lexer_alias_to_name(lexer_name_or_alias) lexer_option = {lexer_option_name: lexer_option_value} lexer_options.setdefault(lexer_name, {}).update(lexer_option) return lexer_options @lazy def _types(self): types = {} for lexer_name, aliases, _, mimetypes in get_all_lexers(): name = aliases[0] if aliases else lexer_name for mimetype in mimetypes: types[mimetype] = (name, self.QUALITY_RATIO) # Pygments < 1.4 doesn't know application/javascript if 'application/javascript' not in types: js_entry = types.get('text/javascript') if js_entry: types['application/javascript'] = js_entry types.update(Mimeview(self.env).configured_modes_mapping('pygments')) return types def _generate(self, language, content, context=None): lexer_name = self._lexer_alias_to_name(language) lexer_options = {'stripnl': False} lexer_options.update(self._lexer_options.get(lexer_name, {})) if context: lexer_options.update(context.get_hint('lexer_options', {})) lexer = get_lexer_by_name(lexer_name, **lexer_options) return GenshiHtmlFormatter().generate(lexer.get_tokens(content)) def _lexer_alias_to_name(self, alias): return self._lexer_alias_name_map.get(alias, alias)
class TagRequestHandler(TagTemplateProvider): """[main] Implements the /tags handler.""" implements(INavigationContributor, IRequestHandler) cloud_mincount = Option( 'tags', 'cloud_mincount', 1, doc="""Integer threshold to hide tags with smaller count.""") default_cols = Option( 'tags', 'default_table_cols', 'id|description|tags', doc="""Select columns and order for table format using a "|"-separated list of column names. Supported columns: realm, id, description, tags """) default_format = Option( 'tags', 'default_format', 'oldlist', doc="""Set the default format for the handler of the `/tags` domain. || `oldlist` (default value) || The original format with a bulleted-list of "linked-id description (tags)" || || `compact` || bulleted-list of "linked-description" || || `table` || table... (see corresponding column option) || """) exclude_realms = ListOption( 'tags', 'exclude_realms', [], doc="""Comma-separated list of realms to exclude from tags queries by default, unless specifically included using "realm:realm-name" in a query.""") # INavigationContributor methods def get_active_navigation_item(self, req): if 'TAGS_VIEW' in req.perm: return 'tags' def get_navigation_items(self, req): if 'TAGS_VIEW' in req.perm: label = tag_("Tags") yield ('mainnav', 'tags', builder.a(label, href=req.href.tags(), accesskey='T')) # IRequestHandler methods def match_request(self, req): return req.path_info.startswith('/tags') def process_request(self, req): req.perm.require('TAGS_VIEW') match = re.match(r'/tags/?(.*)', req.path_info) tag_id = match.group(1) and match.group(1) or None query = req.args.get('q', '') # Consider only providers, that are permitted for display. tag_system = TagSystem(self.env) all_realms = tag_system.get_taggable_realms(req.perm) if not (tag_id or query) or [r for r in all_realms if r in req.args ] == []: for realm in all_realms: if realm not in self.exclude_realms: req.args[realm] = 'on' checked_realms = [r for r in all_realms if r in req.args] if query: # Add permitted realms from query expression. checked_realms.extend(query_realms(query, all_realms)) realm_args = dict( zip([r for r in checked_realms], ['on' for r in checked_realms])) # Switch between single tag and tag query expression mode. if tag_id and not re.match(r"""(['"]?)(\S+)\1$""", tag_id, re.UNICODE): # Convert complex, invalid tag ID's --> query expression. req.redirect(req.href.tags(realm_args, q=tag_id)) elif query: single_page = re.match(r"""(['"]?)(\S+)\1$""", query, re.UNICODE) if single_page: # Convert simple query --> single tag. req.redirect(req.href.tags(single_page.group(2), realm_args)) data = dict(page_title=_("Tags"), checked_realms=checked_realms) # Populate the TagsQuery form field. data['tag_query'] = tag_id and tag_id or query data['tag_realms'] = list( dict(name=realm, checked=realm in checked_realms) for realm in all_realms) if tag_id: data['tag_page'] = WikiPage(self.env, tag_system.wiki_page_prefix + tag_id) if query or tag_id: macro = 'ListTagged' # TRANSLATOR: The meta-nav link label. add_ctxtnav(req, _("Back to Cloud"), req.href.tags()) args = "%s,format=%s,cols=%s" % \ (tag_id and tag_id or query, self.default_format, self.default_cols) data['mincount'] = None else: macro = 'TagCloud' mincount = as_int(req.args.get('mincount', None), self.cloud_mincount) args = mincount and "mincount=%s" % mincount or None data['mincount'] = mincount formatter = Formatter(self.env, web_context(req, Resource('tag'))) self.env.log.debug("%s macro arguments: %s", macro, args and args or '(none)') macros = TagWikiMacros(self.env) try: # Query string without realm throws 'NotImplementedError'. data['tag_body'] = checked_realms and \ macros.expand_macro(formatter, macro, args, realms=checked_realms) \ or '' except InvalidQuery, e: data['tag_query_error'] = to_unicode(e) data['tag_body'] = macros.expand_macro(formatter, 'TagCloud', '') add_stylesheet(req, 'tags/css/tractags.css') return 'tag_view.html', data, None
class BrowserModule(Component): implements(INavigationContributor, IPermissionRequestor, IRequestHandler, IWikiSyntaxProvider, IHTMLPreviewAnnotator, IWikiMacroProvider) property_renderers = ExtensionPoint(IPropertyRenderer) realm = RepositoryManager.source_realm downloadable_paths = ListOption('browser', 'downloadable_paths', '/trunk, /branches/*, /tags/*', doc="""List of repository paths that can be downloaded. Leave this option empty if you want to disable all downloads, otherwise set it to a comma-separated list of authorized paths (those paths are glob patterns, i.e. "*" can be used as a wild card). In a multi-repository environment, the path must be qualified with the repository name if the path does not point to the default repository (e.g. /reponame/trunk). Note that a simple prefix matching is performed on the paths, so aliases won't get automatically resolved. """) color_scale = BoolOption('browser', 'color_scale', True, doc="""Enable colorization of the ''age'' column. This uses the same color scale as the source code annotation: blue is older, red is newer. """) NEWEST_COLOR = (255, 136, 136) newest_color = Option('browser', 'newest_color', repr(NEWEST_COLOR), doc="""(r,g,b) color triple to use for the color corresponding to the newest color, for the color scale used in ''blame'' or the browser ''age'' column if `color_scale` is enabled. """) OLDEST_COLOR = (136, 136, 255) oldest_color = Option('browser', 'oldest_color', repr(OLDEST_COLOR), doc="""(r,g,b) color triple to use for the color corresponding to the oldest color, for the color scale used in ''blame'' or the browser ''age'' column if `color_scale` is enabled. """) intermediate_point = Option('browser', 'intermediate_point', '', doc="""If set to a value between 0 and 1 (exclusive), this will be the point chosen to set the `intermediate_color` for interpolating the color value. """) intermediate_color = Option('browser', 'intermediate_color', '', doc="""(r,g,b) color triple to use for the color corresponding to the intermediate color, if two linear interpolations are used for the color scale (see `intermediate_point`). If not set, the intermediate color between `oldest_color` and `newest_color` will be used. """) render_unsafe_content = BoolOption('browser', 'render_unsafe_content', 'false', """Whether raw files should be rendered in the browser, or only made downloadable. Pretty much any file may be interpreted as HTML by the browser, which allows a malicious user to create a file containing cross-site scripting attacks. For open repositories where anyone can check-in a file, it is recommended to leave this option disabled.""") hidden_properties = ListOption('browser', 'hide_properties', 'svk:merge', doc="""Comma-separated list of version control properties to hide from the repository browser. """) # public methods def get_custom_colorizer(self): """Returns a converter for values from [0.0, 1.0] to a RGB triple.""" def interpolate(old, new, value): # Provides a linearly interpolated color triple for `value` # which must be a floating point value between 0.0 and 1.0 return tuple([int(b + (a - b) * value) for a, b in zip(new, old)]) def parse_color(rgb, default): # Get three ints out of a `rgb` string or return `default` try: t = tuple([int(v) for v in re.split(r'(\d+)', rgb)[1::2]]) return t if len(t) == 3 else default except ValueError: return default newest_color = parse_color(self.newest_color, self.NEWEST_COLOR) oldest_color = parse_color(self.oldest_color, self.OLDEST_COLOR) try: intermediate = float(self.intermediate_point) except ValueError: intermediate = None if intermediate: intermediate_color = parse_color(self.intermediate_color, None) if not intermediate_color: intermediate_color = tuple([(a + b) / 2 for a, b in zip(newest_color, oldest_color)]) def colorizer(value): if value <= intermediate: value = value / intermediate return interpolate(oldest_color, intermediate_color, value) else: value = (value - intermediate) / (1.0 - intermediate) return interpolate(intermediate_color, newest_color, value) else: def colorizer(value): return interpolate(oldest_color, newest_color, value) return colorizer # INavigationContributor methods def get_active_navigation_item(self, req): return 'browser' def get_navigation_items(self, req): rm = RepositoryManager(self.env) if any(repos.is_viewable(req.perm) for repos in rm.get_real_repositories()): yield ('mainnav', 'browser', tag.a(_('Browse Source'), href=req.href.browser())) # IPermissionRequestor methods def get_permission_actions(self): return ['BROWSER_VIEW', 'FILE_VIEW'] # IRequestHandler methods def match_request(self, req): match = re.match(r'/(export|browser|file)(/.*)?$', req.path_info) if match: mode, path = match.groups() if mode == 'export': if path and '/' in path: path_elts = path.split('/', 2) if len(path_elts) != 3: return False path = path_elts[2] req.args['rev'] = path_elts[1] req.args['format'] = 'raw' elif mode == 'file': req.redirect(req.href.browser(path, rev=req.args.get('rev'), format=req.args.get('format')), permanent=True) req.args['path'] = path or '/' return True def process_request(self, req): presel = req.args.get('preselected') if presel and (presel + '/').startswith(req.href.browser() + '/'): req.redirect(presel) path = req.args.get('path', '/') rev = req.args.get('rev', '') if rev.lower() in ('', 'head'): rev = None format = req.args.get('format') order = req.args.get('order', 'name').lower() desc = 'desc' in req.args rm = RepositoryManager(self.env) all_repositories = rm.get_all_repositories() reponame, repos, path = rm.get_repository_by_path(path) # Repository index show_index = not reponame and path == '/' if show_index: if repos and (as_bool(all_repositories[''].get('hidden')) or not repos.is_viewable(req.perm)): repos = None if not repos and reponame: raise ResourceNotFound(_("Repository '%(repo)s' not found", repo=reponame)) if reponame and reponame != repos.reponame: # Redirect alias qs = req.query_string req.redirect(req.href.browser(repos.reponame or None, path) + ('?' + qs if qs else '')) reponame = repos.reponame if repos else None # Find node for the requested path/rev context = web_context(req) node = None changeset = None display_rev = lambda rev: rev if repos: try: if rev: rev = repos.normalize_rev(rev) # If `rev` is `None`, we'll try to reuse `None` consistently, # as a special shortcut to the latest revision. rev_or_latest = rev or repos.youngest_rev node = get_existing_node(req, repos, path, rev_or_latest) except NoSuchChangeset as e: raise ResourceNotFound(e, _('Invalid changeset number')) if node: try: # use changeset instance to retrieve branches and tags changeset = repos.get_changeset(node.rev) except NoSuchChangeset: pass context = context.child(repos.resource.child(self.realm, path, version=rev_or_latest)) display_rev = repos.display_rev # Prepare template data path_links = get_path_links(req.href, reponame, path, rev, order, desc) repo_data = dir_data = file_data = None if show_index: repo_data = self._render_repository_index( context, all_repositories, order, desc) if node: if not node.is_viewable(req.perm): raise PermissionError('BROWSER_VIEW' if node.isdir else 'FILE_VIEW', node.resource, self.env) if node.isdir: if format in ('zip',): # extension point here... self._render_zip(req, context, repos, node, rev) # not reached dir_data = self._render_dir(req, repos, node, rev, order, desc) elif node.isfile: file_data = self._render_file(req, context, repos, node, rev) if not repos and not (repo_data and repo_data['repositories']): # If no viewable repositories, check permission instead of # repos.is_viewable() req.perm.require('BROWSER_VIEW') if show_index: raise ResourceNotFound(_("No viewable repositories")) else: raise ResourceNotFound(_("No node %(path)s", path=path)) quickjump_data = properties_data = None if node and not req.is_xhr: properties_data = self.render_properties( 'browser', context, node.get_properties()) quickjump_data = list(repos.get_quickjump_entries(rev)) data = { 'context': context, 'reponame': reponame, 'repos': repos, 'repoinfo': all_repositories.get(reponame or ''), 'path': path, 'rev': node and node.rev, 'stickyrev': rev, 'display_rev': display_rev, 'changeset': changeset, 'created_path': node and node.created_path, 'created_rev': node and node.created_rev, 'properties': properties_data, 'path_links': path_links, 'order': order, 'desc': 1 if desc else None, 'repo': repo_data, 'dir': dir_data, 'file': file_data, 'quickjump_entries': quickjump_data, 'wiki_format_messages': self.config['changeset'].getbool('wiki_format_messages'), } if req.is_xhr: # render and return the content only return 'dir_entries.html', data if dir_data or repo_data: add_script(req, 'common/js/expand_dir.js') add_script(req, 'common/js/keyboard_nav.js') # Links for contextual navigation if node: if node.isfile: prev_rev = repos.previous_rev(rev=node.created_rev, path=node.created_path) if prev_rev: href = req.href.browser(reponame, node.created_path, rev=prev_rev) add_link(req, 'prev', href, _('Revision %(num)s', num=display_rev(prev_rev))) if rev is not None: add_link(req, 'up', req.href.browser(reponame, node.created_path)) next_rev = repos.next_rev(rev=node.created_rev, path=node.created_path) if next_rev: href = req.href.browser(reponame, node.created_path, rev=next_rev) add_link(req, 'next', href, _('Revision %(num)s', num=display_rev(next_rev))) prevnext_nav(req, _('Previous Revision'), _('Next Revision'), _('Latest Revision')) else: if path != '/': add_link(req, 'up', path_links[-2]['href'], _('Parent directory')) add_ctxtnav(req, tag.a(_('Last Change'), href=req.href.changeset(node.created_rev, reponame, node.created_path))) if node.isfile: annotate = data['file']['annotate'] if annotate: add_ctxtnav(req, _('Normal'), title=_('View file without annotations'), href=req.href.browser(reponame, node.created_path, rev=rev)) if annotate != 'blame': add_ctxtnav(req, _('Blame'), title=_('Annotate each line with the last ' 'changed revision ' '(this can be time consuming...)'), href=req.href.browser(reponame, node.created_path, rev=rev, annotate='blame')) add_ctxtnav(req, _('Revision Log'), href=req.href.log(reponame, path, rev=rev)) path_url = repos.get_path_url(path, rev) if path_url: if path_url.startswith('//'): path_url = req.scheme + ':' + path_url add_ctxtnav(req, _('Repository URL'), href=path_url) add_stylesheet(req, 'common/css/browser.css') return 'browser.html', data # Internal methods def _render_repository_index(self, context, all_repositories, order, desc): # Color scale for the age column timerange = custom_colorizer = None if self.color_scale: custom_colorizer = self.get_custom_colorizer() rm = RepositoryManager(self.env) repositories = [] for reponame, repoinfo in all_repositories.iteritems(): if not reponame or as_bool(repoinfo.get('hidden')): continue try: repos = rm.get_repository(reponame) except TracError as err: entry = (reponame, repoinfo, None, None, exception_to_unicode(err), None) else: if repos: if not repos.is_viewable(context.perm): continue try: youngest = repos.get_changeset(repos.youngest_rev) except NoSuchChangeset: youngest = None if self.color_scale and youngest: if not timerange: timerange = TimeRange(youngest.date) else: timerange.insert(youngest.date) raw_href = self._get_download_href(context.href, repos, None, None) entry = (reponame, repoinfo, repos, youngest, None, raw_href) else: entry = (reponame, repoinfo, None, None, u"\u2013", None) if entry[4] is not None: # Check permission in case of error root = Resource('repository', reponame).child(self.realm, '/') if 'BROWSER_VIEW' not in context.perm(root): continue repositories.append(entry) # Ordering of repositories if order == 'date': def repo_order(args): reponame, repoinfo, repos, youngest, err, href = args return (youngest.date if youngest else to_datetime(0), embedded_numbers(reponame.lower())) elif order == 'author': def repo_order(args): reponame, repoinfo, repos, youngest, err, href = args return (youngest.author.lower() if youngest else '', embedded_numbers(reponame.lower())) else: def repo_order(args): reponame, repoinfo, repos, youngest, err, href = args return embedded_numbers(reponame.lower()) repositories = sorted(repositories, key=repo_order, reverse=desc) return {'repositories' : repositories, 'timerange': timerange, 'colorize_age': custom_colorizer} def _render_dir(self, req, repos, node, rev, order, desc): req.perm(node.resource).require('BROWSER_VIEW') download_href = self._get_download_href # Entries metadata class entry(object): _copy = 'name rev created_rev kind isdir path content_length' \ .split() __slots__ = _copy + ['raw_href'] def __init__(self, node): for f in entry._copy: setattr(self, f, getattr(node, f)) self.raw_href = download_href(req.href, repos, node, rev) entries = [entry(n) for n in node.get_entries() if n.is_viewable(req.perm)] changes = get_changes(repos, [i.created_rev for i in entries], self.log) if rev: newest = repos.get_changeset(rev).date else: newest = datetime_now(req.tz) # Color scale for the age column timerange = custom_colorizer = None if self.color_scale: timerange = TimeRange(newest) max_s = req.args.get('range_max_secs') min_s = req.args.get('range_min_secs') parent_range = [timerange.from_seconds(int(s)) for s in [max_s, min_s] if s] this_range = [c.date for c in changes.values() if c] for dt in this_range + parent_range: timerange.insert(dt) custom_colorizer = self.get_custom_colorizer() # Ordering of entries if order == 'date': def file_order(a): return (changes[a.created_rev].date, embedded_numbers(a.name.lower())) elif order == 'size': def file_order(a): return (a.content_length, embedded_numbers(a.name.lower())) elif order == 'author': def file_order(a): return (changes[a.created_rev].author.lower(), embedded_numbers(a.name.lower())) else: def file_order(a): return embedded_numbers(a.name.lower()) dir_order = 1 if desc else -1 def browse_order(a): return dir_order if a.isdir else 0, file_order(a) entries = sorted(entries, key=browse_order, reverse=desc) # ''Zip Archive'' alternate link zip_href = self._get_download_href(req.href, repos, node, rev) if zip_href: add_link(req, 'alternate', zip_href, _('Zip Archive'), 'application/zip', 'zip') return {'entries': entries, 'changes': changes, 'timerange': timerange, 'colorize_age': custom_colorizer, 'range_max_secs': (timerange and timerange.to_seconds(timerange.newest)), 'range_min_secs': (timerange and timerange.to_seconds(timerange.oldest)), } def _iter_nodes(self, node): stack = [node] while stack: node = stack.pop() yield node if node.isdir: stack.extend(sorted(node.get_entries(), key=lambda x: x.name, reverse=True)) def _render_zip(self, req, context, repos, root_node, rev=None): if not self.is_path_downloadable(repos, root_node.path): raise TracError(_("Path not available for download")) req.perm(context.resource).require('FILE_VIEW') root_path = root_node.path.rstrip('/') if root_path: archive_name = root_node.name else: archive_name = repos.reponame or 'repository' filename = '%s-%s.zip' % (archive_name, root_node.rev) render_zip(req, filename, repos, root_node, self._iter_nodes) def _render_file(self, req, context, repos, node, rev=None): req.perm(node.resource).require('FILE_VIEW') mimeview = Mimeview(self.env) # MIME type detection with content_closing(node.get_processed_content()) as content: chunk = content.read(CHUNK_SIZE) mime_type = node.content_type if not mime_type or mime_type == 'application/octet-stream': mime_type = mimeview.get_mimetype(node.name, chunk) or \ mime_type or 'text/plain' # Eventually send the file directly format = req.args.get('format') if format in ('raw', 'txt'): req.send_response(200) req.send_header('Content-Type', 'text/plain' if format == 'txt' else mime_type) req.send_header('Last-Modified', http_date(node.last_modified)) if rev is None: req.send_header('Pragma', 'no-cache') req.send_header('Cache-Control', 'no-cache') req.send_header('Expires', 'Fri, 01 Jan 1999 00:00:00 GMT') if not self.render_unsafe_content: # Force browser to download files instead of rendering # them, since they might contain malicious code enabling # XSS attacks req.send_header('Content-Disposition', 'attachment') req.end_headers() # Note: don't pass an iterable instance to RequestDone, instead # call req.write() with each chunk here to avoid SEGVs (#11805) while chunk: req.write(chunk) chunk = content.read(CHUNK_SIZE) raise RequestDone # The changeset corresponding to the last change on `node` # is more interesting than the `rev` changeset. changeset = repos.get_changeset(node.created_rev) # add ''Plain Text'' alternate link if needed if not is_binary(chunk) and mime_type != 'text/plain': plain_href = req.href.browser(repos.reponame or None, node.path, rev=rev, format='txt') add_link(req, 'alternate', plain_href, _('Plain Text'), 'text/plain') # add ''Original Format'' alternate link (always) raw_href = req.href.export(rev or repos.youngest_rev, repos.reponame or None, node.path) add_link(req, 'alternate', raw_href, _('Original Format'), mime_type) self.log.debug("Rendering preview of node %s@%s with " "mime-type %s", node.name, rev, mime_type) add_stylesheet(req, 'common/css/code.css') annotations = ['lineno'] annotate = req.args.get('annotate') if annotate: annotations.insert(0, annotate) with content_closing(node.get_processed_content()) as content: preview_data = mimeview.preview_data(context, content, node.get_content_length(), mime_type, node.created_path, raw_href, annotations=annotations, force_source=bool(annotate)) return { 'changeset': changeset, 'size': node.content_length, 'preview': preview_data, 'annotate': annotate, } def _get_download_href(self, href, repos, node, rev): """Return the URL for downloading a file, or a directory as a ZIP.""" if node is not None and node.isfile: return href.export(rev or 'HEAD', repos.reponame or None, node.path) path = '' if node is None else node.path.strip('/') if self.is_path_downloadable(repos, path): return href.browser(repos.reponame or None, path, rev=rev or repos.youngest_rev, format='zip') # public methods def is_path_downloadable(self, repos, path): if repos.reponame: path = repos.reponame + '/' + path return any(fnmatchcase(path, dp.strip('/')) for dp in self.downloadable_paths) def render_properties(self, mode, context, props): """Prepare rendering of a collection of properties.""" return filter(None, [self.render_property(name, mode, context, props) for name in sorted(props)]) def render_property(self, name, mode, context, props): """Renders a node property to HTML.""" if name in self.hidden_properties: return candidates = [] for renderer in self.property_renderers: quality = renderer.match_property(name, mode) if quality > 0: candidates.append((quality, renderer)) candidates.sort(reverse=True) for (quality, renderer) in candidates: try: rendered = renderer.render_property(name, mode, context, props) if not rendered: return rendered if isinstance(rendered, RenderedProperty): value = rendered.content else: value = rendered rendered = None prop = {'name': name, 'value': value, 'rendered': rendered} return prop except Exception as e: self.log.warning('Rendering failed for property %s with ' 'renderer %s: %s', name, renderer.__class__.__name__, exception_to_unicode(e, traceback=True)) # IWikiSyntaxProvider methods def get_wiki_syntax(self): return [] def get_link_resolvers(self): """TracBrowser link resolvers. `source:` and `browser:` * simple paths (/dir/file) * paths at a given revision (/dir/file@234) * paths with line number marks (/dir/file@234:10,20-30) * paths with line number anchor (/dir/file@234#L100) Marks and anchor can be combined. The revision must be present when specifying line numbers. In the few cases where it would be redundant (e.g. for tags), the revision number itself can be omitted: /tags/v10/file@100-110#L99 """ return [('repos', self._format_browser_link), ('export', self._format_export_link), ('source', self._format_browser_link), ('browser', self._format_browser_link)] def _format_export_link(self, formatter, ns, export, label): export, query, fragment = formatter.split_link(export) if ':' in export: rev, path = export.split(':', 1) elif '@' in export: path, rev = export.split('@', 1) else: rev, path = None, export node, raw_href, title = self._get_link_info(path, rev, formatter.href, formatter.perm) if raw_href: return tag.a(label, class_='export', href=raw_href + fragment, title=title) return tag.a(label, class_='missing export') def _format_browser_link(self, formatter, ns, path, label): path, query, fragment = formatter.split_link(path) rev = marks = None match = self.PATH_LINK_RE.match(path) if match: path, rev, marks = match.groups() href = formatter.href src_href = href.browser(path, rev=rev, marks=marks) + query + fragment node, raw_href, title = self._get_link_info(path, rev, formatter.href, formatter.perm) if not node: return tag.a(label, class_='missing source') link = tag.a(label, class_='source', href=src_href) if raw_href: link = tag(link, tag.a(u'\u200b', href=raw_href + fragment, title=title, class_='trac-rawlink' if node.isfile else 'trac-ziplink')) return link PATH_LINK_RE = re.compile(r"([^@#:]*)" # path r"[@:]([^#:]+)?" # rev r"(?::(\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*))?" # marks ) def _get_link_info(self, path, rev, href, perm): rm = RepositoryManager(self.env) node = raw_href = title = None try: reponame, repos, npath = rm.get_repository_by_path(path) node = get_allowed_node(repos, npath, rev, perm) if node is not None: raw_href = self._get_download_href(href, repos, node, rev) title = _("Download") if node.isfile \ else _("Download as Zip archive") except TracError: pass return node, raw_href, title # IHTMLPreviewAnnotator methods def get_annotation_type(self): return 'blame', _('Rev'), _('Revision in which the line changed') def get_annotation_data(self, context): """Cache the annotation data corresponding to each revision.""" return BlameAnnotator(self.env, context) def annotate_row(self, context, row, lineno, line, blame_annotator): blame_annotator.annotate(row, lineno) # IWikiMacroProvider methods def get_macros(self): yield "RepositoryIndex" def get_macro_description(self, name): description = cleandoc_(""" Display the list of available repositories. Can be given the following named arguments: ''format'':: Select the rendering format: - ''compact'' produces a comma-separated list of repository prefix names (default) - ''list'' produces a description list of repository prefix names - ''table'' produces a table view, similar to the one visible in the ''Browse View'' page ''glob'':: Do a glob-style filtering on the repository names (defaults to '*') ''order'':: Order repositories by the given column (one of "name", "date" or "author") ''desc'':: When set to 1, order by descending order """) return 'messages', description def expand_macro(self, formatter, name, content): args, kwargs = parse_args(content) format = kwargs.get('format', 'compact') glob = kwargs.get('glob', '*') order = kwargs.get('order') desc = as_bool(kwargs.get('desc', 0)) rm = RepositoryManager(self.env) all_repos = dict(rdata for rdata in rm.get_all_repositories().items() if fnmatchcase(rdata[0], glob)) if format == 'table': repo = self._render_repository_index(formatter.context, all_repos, order, desc) add_stylesheet(formatter.req, 'common/css/browser.css') wiki_format_messages = self.config['changeset'] \ .getbool('wiki_format_messages') data = {'repo': repo, 'order': order, 'desc': 1 if desc else None, 'reponame': None, 'path': '/', 'stickyrev': None, 'wiki_format_messages': wiki_format_messages} return Chrome(self.env).render_fragment(formatter.context.req, 'repository_index.html', data) def get_repository(reponame): try: return rm.get_repository(reponame) except TracError: return all_repos = [(reponame, get_repository(reponame)) for reponame in all_repos] all_repos = sorted(((reponame, repos) for reponame, repos in all_repos if repos and not as_bool(repos.params.get('hidden')) and repos.is_viewable(formatter.perm)), reverse=desc) def repolink(reponame, repos): label = reponame or _('(default)') return Markup(tag.a(label, title=_('View repository %(repo)s', repo=label), href=formatter.href.browser(repos.reponame or None))) if format == 'list': return tag.dl([ tag(tag.dt(repolink(reponame, repos)), tag.dd(repos.params.get('description'))) for reponame, repos in all_repos]) else: # compact return Markup(', ').join(repolink(reponame, repos) for reponame, repos in all_repos)
class TicketSystem(Component): implements(IPermissionRequestor, IWikiSyntaxProvider, IResourceManager, ITicketManipulator) change_listeners = ExtensionPoint(ITicketChangeListener) milestone_change_listeners = ExtensionPoint(IMilestoneChangeListener) realm = 'ticket' ticket_custom_section = ConfigSection( 'ticket-custom', """In this section, you can define additional fields for tickets. See TracTicketsCustomFields for more details.""") action_controllers = OrderedExtensionsOption( 'ticket', 'workflow', ITicketActionController, default='ConfigurableTicketWorkflow', include_missing=False, doc="""Ordered list of workflow controllers to use for ticket actions. """) restrict_owner = BoolOption( 'ticket', 'restrict_owner', 'false', """Make the owner field of tickets use a drop-down menu. Be sure to understand the performance implications before activating this option. See [TracTickets#Assign-toasDrop-DownList Assign-to as Drop-Down List]. Please note that e-mail addresses are '''not''' obfuscated in the resulting drop-down menu, so this option should not be used if e-mail addresses must remain protected. """) default_version = Option('ticket', 'default_version', '', """Default version for newly created tickets.""") default_type = Option('ticket', 'default_type', 'defect', """Default type for newly created tickets.""") default_priority = Option( 'ticket', 'default_priority', 'major', """Default priority for newly created tickets.""") default_milestone = Option( 'ticket', 'default_milestone', '', """Default milestone for newly created tickets.""") default_component = Option( 'ticket', 'default_component', '', """Default component for newly created tickets.""") default_severity = Option( 'ticket', 'default_severity', '', """Default severity for newly created tickets.""") default_summary = Option( 'ticket', 'default_summary', '', """Default summary (title) for newly created tickets.""") default_description = Option( 'ticket', 'default_description', '', """Default description for newly created tickets.""") default_keywords = Option( 'ticket', 'default_keywords', '', """Default keywords for newly created tickets.""") default_owner = Option( 'ticket', 'default_owner', '< default >', """Default owner for newly created tickets. The component owner is used when set to the value `< default >`. """) default_cc = Option('ticket', 'default_cc', '', """Default cc: list for newly created tickets.""") default_resolution = Option( 'ticket', 'default_resolution', 'fixed', """Default resolution for resolving (closing) tickets.""") allowed_empty_fields = ListOption( 'ticket', 'allowed_empty_fields', 'milestone, version', doc="""Comma-separated list of `select` fields that can have an empty value. (//since 1.1.2//)""") max_comment_size = IntOption( 'ticket', 'max_comment_size', 262144, """Maximum allowed comment size in characters.""") max_description_size = IntOption( 'ticket', 'max_description_size', 262144, """Maximum allowed description size in characters.""") max_summary_size = IntOption( 'ticket', 'max_summary_size', 262144, """Maximum allowed summary size in characters. (//since 1.0.2//)""") def __init__(self): self.log.debug('action controllers for ticket workflow: %r', [c.__class__.__name__ for c in self.action_controllers]) # Public API def get_available_actions(self, req, ticket): """Returns a sorted list of available actions""" # The list should not have duplicates. actions = {} for controller in self.action_controllers: weighted_actions = controller.get_ticket_actions(req, ticket) or [] for weight, action in weighted_actions: if action in actions: actions[action] = max(actions[action], weight) else: actions[action] = weight all_weighted_actions = [(weight, action) for action, weight in actions.items()] return [x[1] for x in sorted(all_weighted_actions, reverse=True)] def get_all_status(self): """Returns a sorted list of all the states all of the action controllers know about.""" valid_states = set() for controller in self.action_controllers: valid_states.update(controller.get_all_status() or []) return sorted(valid_states) def get_ticket_field_labels(self): """Produce a (name,label) mapping from `get_ticket_fields`.""" labels = {f['name']: f['label'] for f in self.get_ticket_fields()} labels['attachment'] = _("Attachment") return labels def get_ticket_fields(self): """Returns list of fields available for tickets. Each field is a dict with at least the 'name', 'label' (localized) and 'type' keys. It may in addition contain the 'custom' key, the 'optional' and the 'options' keys. When present 'custom' and 'optional' are always `True`. """ fields = copy.deepcopy(self.fields) label = 'label' # workaround gettext extraction bug for f in fields: if not f.get('custom'): f[label] = gettext(f[label]) return fields def reset_ticket_fields(self): """Invalidate ticket field cache.""" del self.fields @cached def fields(self): """Return the list of fields available for tickets.""" from trac.ticket import model fields = TicketFieldList() # Basic text fields fields.append({ 'name': 'summary', 'type': 'text', 'label': N_('Summary') }) fields.append({ 'name': 'reporter', 'type': 'text', 'label': N_('Reporter') }) # Owner field, by default text but can be changed dynamically # into a drop-down depending on configuration (restrict_owner=true) fields.append({'name': 'owner', 'type': 'text', 'label': N_('Owner')}) # Description fields.append({ 'name': 'description', 'type': 'textarea', 'format': 'wiki', 'label': N_('Description') }) # Default select and radio fields selects = [('type', N_('Type'), model.Type), ('status', N_('Status'), model.Status), ('priority', N_('Priority'), model.Priority), ('milestone', N_('Milestone'), model.Milestone), ('component', N_('Component'), model.Component), ('version', N_('Version'), model.Version), ('severity', N_('Severity'), model.Severity), ('resolution', N_('Resolution'), model.Resolution)] for name, label, cls in selects: options = [val.name for val in cls.select(self.env)] if not options: # Fields without possible values are treated as if they didn't # exist continue field = { 'name': name, 'type': 'select', 'label': label, 'value': getattr(self, 'default_' + name, ''), 'options': options } if name in ('status', 'resolution'): field['type'] = 'radio' field['optional'] = True elif name in self.allowed_empty_fields: field['optional'] = True fields.append(field) # Advanced text fields fields.append({ 'name': 'keywords', 'type': 'text', 'format': 'list', 'label': N_('Keywords') }) fields.append({ 'name': 'cc', 'type': 'text', 'format': 'list', 'label': N_('Cc') }) # Date/time fields fields.append({ 'name': 'time', 'type': 'time', 'format': 'relative', 'label': N_('Created') }) fields.append({ 'name': 'changetime', 'type': 'time', 'format': 'relative', 'label': N_('Modified') }) for field in self.custom_fields: if field['name'] in [f['name'] for f in fields]: self.log.warning('Duplicate field name "%s" (ignoring)', field['name']) continue fields.append(field) return fields reserved_field_names = [ 'report', 'order', 'desc', 'group', 'groupdesc', 'col', 'row', 'format', 'max', 'page', 'verbose', 'comment', 'or', 'id', 'time', 'changetime', 'owner', 'reporter', 'cc', 'summary', 'description', 'keywords' ] def get_custom_fields(self): return copy.deepcopy(self.custom_fields) @cached def custom_fields(self): """Return the list of custom ticket fields available for tickets.""" fields = TicketFieldList() config = self.ticket_custom_section for name in [ option for option, value in config.options() if '.' not in option ]: field = { 'name': name, 'custom': True, 'type': config.get(name), 'order': config.getint(name + '.order', 0), 'label': config.get(name + '.label') or name.replace("_", " ").strip().capitalize(), 'value': config.get(name + '.value', '') } if field['type'] == 'select' or field['type'] == 'radio': field['options'] = config.getlist(name + '.options', sep='|') if not field['options']: continue if '' in field['options'] or \ field['name'] in self.allowed_empty_fields: field['optional'] = True if '' in field['options']: field['options'].remove('') elif field['type'] == 'checkbox': field['value'] = '1' if as_bool(field['value']) else '0' elif field['type'] == 'text': field['format'] = config.get(name + '.format', 'plain') field['max_size'] = config.getint(name + '.max_size', 0) elif field['type'] == 'textarea': field['format'] = config.get(name + '.format', 'plain') field['max_size'] = config.getint(name + '.max_size', 0) field['height'] = config.getint(name + '.rows') elif field['type'] == 'time': field['format'] = config.get(name + '.format', 'datetime') if field['name'] in self.reserved_field_names: self.log.warning( 'Field name "%s" is a reserved name ' '(ignoring)', field['name']) continue if not re.match('^[a-zA-Z][a-zA-Z0-9_]+$', field['name']): self.log.warning( 'Invalid name for custom field: "%s" ' '(ignoring)', field['name']) continue fields.append(field) fields.sort(key=lambda f: (f['order'], f['name'])) return fields def get_field_synonyms(self): """Return a mapping from field name synonyms to field names. The synonyms are supposed to be more intuitive for custom queries.""" # i18n TODO - translated keys return {'created': 'time', 'modified': 'changetime'} def eventually_restrict_owner(self, field, ticket=None): """Restrict given owner field to be a list of users having the TICKET_MODIFY permission (for the given ticket) """ if self.restrict_owner: field['type'] = 'select' field['options'] = self.get_allowed_owners(ticket) field['optional'] = True def get_allowed_owners(self, ticket=None): """Returns a list of permitted ticket owners (those possessing the TICKET_MODIFY permission). Returns `None` if the option `[ticket]` `restrict_owner` is `False`. If `ticket` is not `None`, fine-grained permission checks are used to determine the allowed owners for the specified resource. :since: 1.0.3 """ if self.restrict_owner: allowed_owners = [] for user in PermissionSystem(self.env) \ .get_users_with_permission('TICKET_MODIFY'): if not ticket or \ 'TICKET_MODIFY' in PermissionCache(self.env, user, ticket.resource): allowed_owners.append(user) allowed_owners.sort() return allowed_owners # ITicketManipulator methods def prepare_ticket(self, req, ticket, fields, actions): pass def validate_ticket(self, req, ticket): # Validate select fields for known values. for field in ticket.fields: if 'options' not in field: continue name = field['name'] if name == 'status': continue if name in ticket and name in ticket._old: value = ticket[name] if value: if value not in field['options']: yield name, _('"%(value)s" is not a valid value', value=value) elif not field.get('optional', False): yield name, _("field cannot be empty") # Validate description length. if len(ticket['description'] or '') > self.max_description_size: yield 'description', _( "Must be less than or equal to %(num)s " "characters", num=self.max_description_size) # Validate summary length. if not ticket['summary']: yield 'summary', _("Tickets must contain a summary.") elif len(ticket['summary'] or '') > self.max_summary_size: yield 'summary', _( "Must be less than or equal to %(num)s " "characters", num=self.max_summary_size) # Validate custom field length. for field in ticket.custom_fields: field_attrs = ticket.fields.by_name(field) max_size = field_attrs.get('max_size', 0) if 0 < max_size < len(ticket[field] or ''): label = field_attrs.get('label') yield label or field, _( "Must be less than or equal to " "%(num)s characters", num=max_size) # Validate time field content. for field in ticket.time_fields: value = ticket[field] if field in ticket.custom_fields and \ field in ticket._old and \ not isinstance(value, datetime): field_attrs = ticket.fields.by_name(field) format = field_attrs.get('format') try: ticket[field] = user_time(req, parse_date, value, hint=format) \ if value else None except TracError as e: # Degrade TracError to warning. ticket[field] = value label = field_attrs.get('label') yield label or field, to_unicode(e) def validate_comment(self, req, comment): # Validate comment length if len(comment or '') > self.max_comment_size: yield _("Must be less than or equal to %(num)s characters", num=self.max_comment_size) # IPermissionRequestor methods def get_permission_actions(self): return [ 'TICKET_APPEND', 'TICKET_CREATE', 'TICKET_CHGPROP', 'TICKET_VIEW', 'TICKET_EDIT_CC', 'TICKET_EDIT_DESCRIPTION', 'TICKET_EDIT_COMMENT', ('TICKET_MODIFY', ['TICKET_APPEND', 'TICKET_CHGPROP']), ('TICKET_ADMIN', [ 'TICKET_CREATE', 'TICKET_MODIFY', 'TICKET_VIEW', 'TICKET_EDIT_CC', 'TICKET_EDIT_DESCRIPTION', 'TICKET_EDIT_COMMENT' ]) ] # IWikiSyntaxProvider methods def get_link_resolvers(self): return [('bug', self._format_link), ('issue', self._format_link), ('ticket', self._format_link), ('comment', self._format_comment_link)] def get_wiki_syntax(self): yield ( # matches #... but not &#... (HTML entity) r"!?(?<!&)#" # optional intertrac shorthand #T... + digits r"(?P<it_ticket>%s)%s" % (WikiParser.INTERTRAC_SCHEME, Ranges.RE_STR), lambda x, y, z: self._format_link(x, 'ticket', y[1:], y, z)) def _format_link(self, formatter, ns, target, label, fullmatch=None): intertrac = formatter.shorthand_intertrac_helper( ns, target, label, fullmatch) if intertrac: return intertrac try: link, params, fragment = formatter.split_link(target) r = Ranges(link) if len(r) == 1: num = r.a ticket = formatter.resource(self.realm, num) from trac.ticket.model import Ticket if Ticket.id_is_valid(num) and \ 'TICKET_VIEW' in formatter.perm(ticket): # TODO: attempt to retrieve ticket view directly, # something like: t = Ticket.view(num) for type, summary, status, resolution in \ self.env.db_query(""" SELECT type, summary, status, resolution FROM ticket WHERE id=%s """, (str(num),)): description = self.format_summary( summary, status, resolution, type) title = '#%s: %s' % (num, description) href = formatter.href.ticket(num) + params + fragment return tag.a(label, title=title, href=href, class_='%s ticket' % status) else: ranges = str(r) if params: params = '&' + params[1:] label_wrap = label.replace(',', u',\u200b') ranges_wrap = ranges.replace(',', u', ') return tag.a(label_wrap, title=_("Tickets %(ranges)s", ranges=ranges_wrap), href=formatter.href.query(id=ranges) + params) except ValueError: pass return tag.a(label, class_='missing ticket') def _format_comment_link(self, formatter, ns, target, label): resource = None if ':' in target: elts = target.split(':') if len(elts) == 3: cnum, realm, id = elts if cnum != 'description' and cnum and not cnum[0].isdigit(): realm, id, cnum = elts # support old comment: style id = as_int(id, None) if realm in ('bug', 'issue'): realm = 'ticket' resource = formatter.resource(realm, id) else: resource = formatter.resource cnum = target if resource and resource.id and resource.realm == self.realm and \ cnum and (cnum.isdigit() or cnum == 'description'): href = title = class_ = None if self.resource_exists(resource): from trac.ticket.model import Ticket ticket = Ticket(self.env, resource.id) if cnum != 'description' and not ticket.get_change(cnum): title = _("ticket comment does not exist") class_ = 'missing ticket' elif 'TICKET_VIEW' in formatter.perm(resource): href = formatter.href.ticket(resource.id) + \ "#comment:%s" % cnum if resource.id != formatter.resource.id: summary = self.format_summary(ticket['summary'], ticket['status'], ticket['resolution'], ticket['type']) if cnum == 'description': title = _("Description for #%(id)s: %(summary)s", id=resource.id, summary=summary) else: title = _( "Comment %(cnum)s for #%(id)s: " "%(summary)s", cnum=cnum, id=resource.id, summary=summary) class_ = ticket['status'] + ' ticket' else: title = _("Description") if cnum == 'description' \ else _("Comment %(cnum)s", cnum=cnum) class_ = 'ticket' else: title = _("no permission to view ticket") class_ = 'forbidden ticket' else: title = _("ticket does not exist") class_ = 'missing ticket' return tag.a(label, class_=class_, href=href, title=title) return label # IResourceManager methods def get_resource_realms(self): yield self.realm def get_resource_description(self, resource, format=None, context=None, **kwargs): if format == 'compact': return '#%s' % resource.id elif format == 'summary': from trac.ticket.model import Ticket ticket = Ticket(self.env, resource.id) args = [ ticket[f] for f in ('summary', 'status', 'resolution', 'type') ] return self.format_summary(*args) return _("Ticket #%(shortname)s", shortname=resource.id) def format_summary(self, summary, status=None, resolution=None, type=None): summary = shorten_line(summary) if type: summary = type + ': ' + summary if status: if status == 'closed' and resolution: status += ': ' + resolution return "%s (%s)" % (summary, status) else: return summary def resource_exists(self, resource): """ >>> from trac.test import EnvironmentStub >>> from trac.resource import Resource, resource_exists >>> env = EnvironmentStub() >>> resource_exists(env, Resource('ticket', 123456)) False >>> from trac.ticket.model import Ticket >>> t = Ticket(env) >>> int(t.insert()) 1 >>> resource_exists(env, t.resource) True """ try: id_ = int(resource.id) except (TypeError, ValueError): return False if self.env.db_query("SELECT id FROM ticket WHERE id=%s", (id_, )): if resource.version is None: return True revcount = self.env.db_query( """ SELECT count(DISTINCT time) FROM ticket_change WHERE ticket=%s """, (id_, )) return revcount[0][0] >= resource.version else: return False
class MilestoneModule(Component): """View and edit individual milestones.""" implements(INavigationContributor, IPermissionRequestor, IRequestHandler, IResourceManager, ISearchSource, ITimelineEventProvider, IWikiSyntaxProvider) realm = 'milestone' stats_provider = ExtensionOption( 'milestone', 'stats_provider', ITicketGroupStatsProvider, 'DefaultTicketGroupStatsProvider', """Name of the component implementing `ITicketGroupStatsProvider`, which is used to collect statistics on groups of tickets for display in the milestone views.""") default_retarget_to = Option( 'milestone', 'default_retarget_to', doc="""Default milestone to which tickets are retargeted when closing or deleting a milestone. (''since 1.1.2'')""") default_group_by = Option( 'milestone', 'default_group_by', 'component', """Default field to use for grouping tickets in the grouped progress bar. (''since 1.2'')""") # INavigationContributor methods def get_active_navigation_item(self, req): return 'roadmap' def get_navigation_items(self, req): return [] # IPermissionRequestor methods def get_permission_actions(self): actions = [ 'MILESTONE_CREATE', 'MILESTONE_DELETE', 'MILESTONE_MODIFY', 'MILESTONE_VIEW' ] return actions + [('MILESTONE_ADMIN', actions)] # ITimelineEventProvider methods def get_timeline_filters(self, req): if 'MILESTONE_VIEW' in req.perm: yield ('milestone', _("Milestones completed")) def get_timeline_events(self, req, start, stop, filters): if 'milestone' in filters: milestone_realm = Resource(self.realm) for name, due, completed, description \ in MilestoneCache(self.env).milestones.itervalues(): if completed and start <= completed <= stop: # TODO: creation and (later) modifications should also be # reported milestone = milestone_realm(id=name) if 'MILESTONE_VIEW' in req.perm(milestone): yield ( 'milestone', completed, '', # FIXME: author? (milestone, description)) # Attachments for event in AttachmentModule(self.env).get_timeline_events( req, milestone_realm, start, stop): yield event def render_timeline_event(self, context, field, event): milestone, description = event[3] if field == 'url': return context.href.milestone(milestone.id) elif field == 'title': return tag_("Milestone %(name)s completed", name=tag.em(milestone.id)) elif field == 'description': child_resource = context.child(resource=milestone) return format_to(self.env, None, child_resource, description) # IRequestHandler methods def match_request(self, req): match = re.match(r'/milestone(?:/(.+))?$', req.path_info) if match: if match.group(1): req.args['id'] = match.group(1) return True def process_request(self, req): milestone_id = req.args.get('id') req.perm(self.realm, milestone_id).require('MILESTONE_VIEW') add_link(req, 'up', req.href.roadmap(), _("Roadmap")) action = req.args.get('action', 'view') try: milestone = Milestone(self.env, milestone_id) except ResourceNotFound: if 'MILESTONE_CREATE' not in req.perm(self.realm, milestone_id): raise milestone = Milestone(self.env) milestone.name = milestone_id action = 'edit' # rather than 'new', so it works for POST/save if req.method == 'POST': if 'cancel' in req.args: if milestone.exists: req.redirect(req.href.milestone(milestone.name)) else: req.redirect(req.href.roadmap()) elif action == 'edit': return self._do_save(req, milestone) elif action == 'delete': self._do_delete(req, milestone) else: raise HTTPBadRequest(_("Invalid request arguments.")) elif action in ('new', 'edit'): return self._render_editor(req, milestone) elif action == 'delete': return self._render_confirm(req, milestone) if not milestone.name: req.redirect(req.href.roadmap()) return self._render_view(req, milestone) # Public methods def get_default_due(self, req): """Returns a `datetime` object representing the default due date in the user's timezone. The default due time is 18:00 in the user's time zone. """ now = datetime_now(req.tz) default_due = datetime(now.year, now.month, now.day, 18) if now.hour > 18: default_due += timedelta(days=1) return to_datetime(default_due, req.tz) def save_milestone(self, req, milestone): # Instead of raising one single error, check all the constraints # and let the user fix them by going back to edit mode and showing # the warnings warnings = [] def warn(msg): add_warning(req, msg) warnings.append(msg) milestone.description = req.args.get('description', '') if 'due' in req.args: duedate = req.args.get('duedate') milestone.due = user_time(req, parse_date, duedate, hint='datetime') \ if duedate else None else: milestone.due = None # -- check completed date if 'completed' in req.args: completed = req.args.get('completeddate', '') completed = user_time(req, parse_date, completed, hint='datetime') if completed else None if completed and completed > datetime_now(utc): warn(_("Completion date may not be in the future")) else: completed = None milestone.completed = completed # -- check the name # If the name has changed, check that the milestone doesn't already # exist # FIXME: the whole .exists business needs to be clarified # (#4130) and should behave like a WikiPage does in # this respect. new_name = req.args.get('name') try: new_milestone = Milestone(self.env, new_name) except ResourceNotFound: milestone.name = new_name else: if new_milestone.name != milestone.name: if new_milestone.name: warn( _( 'Milestone "%(name)s" already exists, please ' 'choose another name.', name=new_milestone.name)) else: warn(_("You must provide a name for the milestone.")) if warnings: return False # -- actually save changes if milestone.exists: milestone.update(author=req.authname) if completed and 'retarget' in req.args: comment = req.args.get('comment', '') retarget_to = req.args.get('target') or None retargeted_tickets = \ milestone.move_tickets(retarget_to, req.authname, comment, exclude_closed=True) add_notice( req, _( 'The open tickets associated with ' 'milestone "%(name)s" have been retargeted ' 'to milestone "%(retarget)s".', name=milestone.name, retarget=retarget_to)) new_values = {'milestone': retarget_to} comment = comment or \ _("Open tickets retargeted after milestone closed") event = BatchTicketChangeEvent(retargeted_tickets, None, req.authname, comment, new_values, None) try: NotificationSystem(self.env).notify(event) except Exception as e: self.log.error( "Failure sending notification on ticket " "batch change: %s", exception_to_unicode(e)) add_warning( req, tag_( "The changes have been saved, but " "an error occurred while sending " "notifications: %(message)s", message=to_unicode(e))) add_notice(req, _("Your changes have been saved.")) else: milestone.insert() add_notice( req, _('The milestone "%(name)s" has been added.', name=milestone.name)) return True # Internal methods _default_retarget_to = default_retarget_to @property def default_retarget_to(self): if self._default_retarget_to and \ not any(self._default_retarget_to == m.name for m in Milestone.select(self.env)): self.log.warn( 'Milestone "%s" does not exist. Update the ' '"default_retarget_to" option in the [milestone] ' 'section of trac.ini', self._default_retarget_to) return self._default_retarget_to def _do_delete(self, req, milestone): req.perm(milestone.resource).require('MILESTONE_DELETE') retarget_to = req.args.get('target') or None # Don't translate ticket comment (comment:40:ticket:5658) retargeted_tickets = \ milestone.move_tickets(retarget_to, req.authname, "Ticket retargeted after milestone deleted") milestone.delete() add_notice( req, _('The milestone "%(name)s" has been deleted.', name=milestone.name)) if retargeted_tickets: add_notice( req, _( 'The tickets associated with milestone ' '"%(name)s" have been retargeted to milestone ' '"%(retarget)s".', name=milestone.name, retarget=retarget_to)) new_values = {'milestone': retarget_to} comment = _("Tickets retargeted after milestone deleted") event = BatchTicketChangeEvent(retargeted_tickets, None, req.authname, comment, new_values, None) try: NotificationSystem(self.env).notify(event) except Exception as e: self.log.error( "Failure sending notification on ticket batch " "change: %s", exception_to_unicode(e)) add_warning( req, tag_( "The changes have been saved, but an " "error occurred while sending " "notifications: %(message)s", message=to_unicode(e))) req.redirect(req.href.roadmap()) def _do_save(self, req, milestone): if milestone.exists: req.perm(milestone.resource).require('MILESTONE_MODIFY') else: req.perm(milestone.resource).require('MILESTONE_CREATE') if self.save_milestone(req, milestone): req.redirect(req.href.milestone(milestone.name)) return self._render_editor(req, milestone) def _render_confirm(self, req, milestone): req.perm(milestone.resource).require('MILESTONE_DELETE') milestones = [ m for m in Milestone.select(self.env) if m.name != milestone.name and 'MILESTONE_VIEW' in req.perm(m.resource) ] attachments = Attachment.select(self.env, self.realm, milestone.name) data = { 'milestone': milestone, 'milestone_groups': group_milestones(milestones, 'TICKET_ADMIN' in req.perm), 'num_tickets': get_num_tickets_for_milestone(self.env, milestone), 'retarget_to': self.default_retarget_to, 'attachments': list(attachments) } add_stylesheet(req, 'common/css/roadmap.css') return 'milestone_delete.html', data, None def _render_editor(self, req, milestone): data = { 'milestone': milestone, 'datetime_hint': get_datetime_format_hint(req.lc_time), 'default_due': self.get_default_due(req), 'milestone_groups': [], } if milestone.exists: req.perm(milestone.resource).require('MILESTONE_MODIFY') milestones = [ m for m in Milestone.select(self.env) if m.name != milestone.name and 'MILESTONE_VIEW' in req.perm(m.resource) ] data['milestone_groups'] = \ group_milestones(milestones, 'TICKET_ADMIN' in req.perm) data['num_open_tickets'] = \ get_num_tickets_for_milestone(self.env, milestone, exclude_closed=True) data['retarget_to'] = self.default_retarget_to else: req.perm(milestone.resource).require('MILESTONE_CREATE') if milestone.name: add_notice( req, _( "Milestone %(name)s does not exist. You " "can create it here.", name=milestone.name)) chrome = Chrome(self.env) chrome.add_jquery_ui(req) chrome.add_wiki_toolbars(req) add_stylesheet(req, 'common/css/roadmap.css') return 'milestone_edit.html', data, None def _render_view(self, req, milestone): milestone_groups = [] available_groups = [] default_group_by_available = False ticket_fields = TicketSystem(self.env).get_ticket_fields() # collect fields that can be used for grouping for field in ticket_fields: if field['type'] == 'select' and field['name'] != 'milestone' \ or field['name'] in ('owner', 'reporter'): available_groups.append({ 'name': field['name'], 'label': field['label'] }) if field['name'] == self.default_group_by: default_group_by_available = True # determine the field currently used for grouping by = None if default_group_by_available: by = self.default_group_by elif available_groups: by = available_groups[0]['name'] by = req.args.get('by', by) tickets = get_tickets_for_milestone(self.env, milestone=milestone.name, field=by) tickets = apply_ticket_permissions(self.env, req, tickets) stat = get_ticket_stats(self.stats_provider, tickets) context = web_context(req, milestone.resource) data = { 'context': context, 'milestone': milestone, 'attachments': AttachmentModule(self.env).attachment_data(context), 'available_groups': available_groups, 'grouped_by': by, 'groups': milestone_groups } data.update(milestone_stats_data(self.env, req, stat, milestone.name)) if by: def per_group_stats_data(gstat, group_name): return milestone_stats_data(self.env, req, gstat, milestone.name, by, group_name) milestone_groups.extend( grouped_stats_data(self.env, self.stats_provider, tickets, by, per_group_stats_data)) add_stylesheet(req, 'common/css/roadmap.css') add_script(req, 'common/js/folding.js') def add_milestone_link(rel, milestone): href = req.href.milestone(milestone.name, by=req.args.get('by')) add_link(req, rel, href, _('Milestone "%(name)s"', name=milestone.name)) milestones = [ m for m in Milestone.select(self.env) if 'MILESTONE_VIEW' in req.perm(m.resource) ] idx = [i for i, m in enumerate(milestones) if m.name == milestone.name] if idx: idx = idx[0] if idx > 0: add_milestone_link('first', milestones[0]) add_milestone_link('prev', milestones[idx - 1]) if idx < len(milestones) - 1: add_milestone_link('next', milestones[idx + 1]) add_milestone_link('last', milestones[-1]) prevnext_nav(req, _("Previous Milestone"), _("Next Milestone"), _("Back to Roadmap")) return 'milestone_view.html', data, None # IWikiSyntaxProvider methods def get_wiki_syntax(self): return [] def get_link_resolvers(self): yield ('milestone', self._format_link) def _format_link(self, formatter, ns, name, label): name, query, fragment = formatter.split_link(name) return self._render_link(formatter.context, name, label, query + fragment) def _render_link(self, context, name, label, extra=''): if not (name or extra): return tag() try: milestone = Milestone(self.env, name) except ResourceNotFound: milestone = None # Note: the above should really not be needed, `Milestone.exists` # should simply be false if the milestone doesn't exist in the db # (related to #4130) href = context.href.milestone(name) if milestone and milestone.exists: if 'MILESTONE_VIEW' in context.perm(milestone.resource): title = None if hasattr(context, 'req'): if milestone.is_completed: title = _("Completed %(duration)s ago (%(date)s)", duration=pretty_timedelta( milestone.completed), date=user_time(context.req, format_datetime, milestone.completed)) elif milestone.is_late: title = _("%(duration)s late (%(date)s)", duration=pretty_timedelta(milestone.due), date=user_time(context.req, format_datetime, milestone.due)) elif milestone.due: title = _("Due in %(duration)s (%(date)s)", duration=pretty_timedelta(milestone.due), date=user_time(context.req, format_datetime, milestone.due)) else: title = _("No date set") closed = 'closed ' if milestone.is_completed else '' return tag.a(label, class_='%smilestone' % closed, href=href + extra, title=title) elif 'MILESTONE_CREATE' in context.perm(self.realm, name): return tag.a(label, class_='missing milestone', href=href + extra, rel='nofollow') return tag.a(label, class_='missing milestone') # IResourceManager methods def get_resource_realms(self): yield self.realm def get_resource_description(self, resource, format=None, context=None, **kwargs): desc = resource.id if format != 'compact': desc = _("Milestone %(name)s", name=resource.id) if context: return self._render_link(context, resource.id, desc) else: return desc def resource_exists(self, resource): """ >>> from trac.test import EnvironmentStub >>> env = EnvironmentStub() >>> m1 = Milestone(env) >>> m1.name = 'M1' >>> m1.insert() >>> MilestoneModule(env).resource_exists(Resource('milestone', 'M1')) True >>> MilestoneModule(env).resource_exists(Resource('milestone', 'M2')) False """ return resource.id in MilestoneCache(self.env).milestones # ISearchSource methods def get_search_filters(self, req): if 'MILESTONE_VIEW' in req.perm: yield ('milestone', _("Milestones")) def get_search_results(self, req, terms, filters): if 'milestone' not in filters: return term_regexps = search_to_regexps(terms) milestone_realm = Resource(self.realm) for name, due, completed, description \ in MilestoneCache(self.env).milestones.itervalues(): if all( r.search(description) or r.search(name) for r in term_regexps): milestone = milestone_realm(id=name) if 'MILESTONE_VIEW' in req.perm(milestone): dt = (completed if completed else due if due else datetime_now(utc)) yield (get_resource_url(self.env, milestone, req.href), get_resource_name(self.env, milestone), dt, '', shorten_result(description, terms)) # Attachments for result in AttachmentModule(self.env).get_search_results( req, milestone_realm, terms): yield result
def get_estimation_field(): return Option('estimation-tools', 'estimation_field', 'estimatedhours', doc="""Defines what custom field should be used to calculate estimation charts. Defaults to 'estimatedhours'""")
class Environment(Component, ComponentManager): """Trac environment manager. Trac stores project information in a Trac environment. It consists of a directory structure containing among other things: * a configuration file, * project-specific templates and plugins, * the wiki and ticket attachments files, * the SQLite database file (stores tickets, wiki pages...) in case the database backend is sqlite """ implements(ISystemInfoProvider) required = True system_info_providers = ExtensionPoint(ISystemInfoProvider) setup_participants = ExtensionPoint(IEnvironmentSetupParticipant) components_section = ConfigSection('components', """This section is used to enable or disable components provided by plugins, as well as by Trac itself. The component to enable/disable is specified via the name of the option. Whether its enabled is determined by the option value; setting the value to `enabled` or `on` will enable the component, any other value (typically `disabled` or `off`) will disable the component. The option name is either the fully qualified name of the components or the module/package prefix of the component. The former enables/disables a specific component, while the latter enables/disables any component in the specified package/module. Consider the following configuration snippet: {{{ [components] trac.ticket.report.ReportModule = disabled webadmin.* = enabled }}} The first option tells Trac to disable the [wiki:TracReports report module]. The second option instructs Trac to enable all components in the `webadmin` package. Note that the trailing wildcard is required for module/package matching. To view the list of active components, go to the ''Plugins'' page on ''About Trac'' (requires `CONFIG_VIEW` [wiki:TracPermissions permissions]). See also: TracPlugins """) shared_plugins_dir = PathOption('inherit', 'plugins_dir', '', """Path to the //shared plugins directory//. Plugins in that directory are loaded in addition to those in the directory of the environment `plugins`, with this one taking precedence. (''since 0.11'')""") base_url = Option('trac', 'base_url', '', """Reference URL for the Trac deployment. This is the base URL that will be used when producing documents that will be used outside of the web browsing context, like for example when inserting URLs pointing to Trac resources in notification e-mails.""") base_url_for_redirect = BoolOption('trac', 'use_base_url_for_redirect', False, """Optionally use `[trac] base_url` for redirects. In some configurations, usually involving running Trac behind a HTTP proxy, Trac can't automatically reconstruct the URL that is used to access it. You may need to use this option to force Trac to use the `base_url` setting also for redirects. This introduces the obvious limitation that this environment will only be usable when accessible from that URL, as redirects are frequently used. (''since 0.10.5'')""") secure_cookies = BoolOption('trac', 'secure_cookies', False, """Restrict cookies to HTTPS connections. When true, set the `secure` flag on all cookies so that they are only sent to the server on HTTPS connections. Use this if your Trac instance is only accessible through HTTPS. (''since 0.11.2'')""") project_name = Option('project', 'name', 'My Project', """Name of the project.""") project_description = Option('project', 'descr', 'My example project', """Short description of the project.""") project_url = Option('project', 'url', '', """URL of the main project web site, usually the website in which the `base_url` resides. This is used in notification e-mails.""") project_admin = Option('project', 'admin', '', """E-Mail address of the project's administrator.""") project_admin_trac_url = Option('project', 'admin_trac_url', '.', """Base URL of a Trac instance where errors in this Trac should be reported. This can be an absolute or relative URL, or '.' to reference this Trac instance. An empty value will disable the reporting buttons. (''since 0.11.3'')""") project_footer = Option('project', 'footer', N_('Visit the Trac open source project at<br />' '<a href="http://trac.edgewall.org/">' 'http://trac.edgewall.org/</a>'), """Page footer text (right-aligned).""") project_icon = Option('project', 'icon', 'common/trac.ico', """URL of the icon of the project.""") log_type = Option('logging', 'log_type', 'none', """Logging facility to use. Should be one of (`none`, `file`, `stderr`, `syslog`, `winlog`).""") log_file = Option('logging', 'log_file', 'trac.log', """If `log_type` is `file`, this should be a path to the log-file. Relative paths are resolved relative to the `log` directory of the environment.""") log_level = Option('logging', 'log_level', 'DEBUG', """Level of verbosity in log. Should be one of (`CRITICAL`, `ERROR`, `WARN`, `INFO`, `DEBUG`).""") log_format = Option('logging', 'log_format', None, """Custom logging format. If nothing is set, the following will be used: Trac[$(module)s] $(levelname)s: $(message)s In addition to regular key names supported by the Python logger library (see http://docs.python.org/library/logging.html), one could use: - $(path)s the path for the current environment - $(basename)s the last path component of the current environment - $(project)s the project name Note the usage of `$(...)s` instead of `%(...)s` as the latter form would be interpreted by the ConfigParser itself. Example: `($(thread)d) Trac[$(basename)s:$(module)s] $(levelname)s: $(message)s` (''since 0.10.5'')""") def __init__(self, path, create=False, options=[]): """Initialize the Trac environment. :param path: the absolute path to the Trac environment :param create: if `True`, the environment is created and populated with default data; otherwise, the environment is expected to already exist. :param options: A list of `(section, name, value)` tuples that define configuration options """ ComponentManager.__init__(self) self.path = path self.log = None self.config = None # System info should be provided through ISystemInfoProvider rather # than appending to systeminfo, which may be a private in a future # release. self.systeminfo = [] if create: self.create(options) else: self.verify() self.setup_config() if create: for setup_participant in self.setup_participants: setup_participant.environment_created() def get_systeminfo(self): """Return a list of `(name, version)` tuples describing the name and version information of external packages used by Trac and plugins. """ info = self.systeminfo[:] for provider in self.system_info_providers: info.extend(provider.get_system_info() or []) info.sort(key=lambda (name, version): (name != 'Trac', name.lower())) return info def get_configinfo(self): """Returns a list of dictionaries containing the `name` and `options` of each configuration section. The value of `options` is a list of dictionaries containing the `name`, `value` and `modified` state of each configuration option. The `modified` value is True if the value differs from its default. :since: version 1.1.2 """ defaults = self.config.defaults(self.compmgr) sections = [] for section in self.config.sections(self.compmgr): options = [] default_options = defaults.get(section, {}) for name, value in self.config.options(section, self.compmgr): default = default_options.get(name) or '' options.append({ 'name': name, 'value': value, 'modified': unicode(value) != unicode(default) }) options.sort(key=lambda o: o['name']) sections.append({'name': section, 'options': options}) sections.sort(key=lambda s: s['name']) return sections # ISystemInfoProvider methods def get_system_info(self): from trac import core, __version__ as VERSION yield 'Trac', get_pkginfo(core).get('version', VERSION) yield 'Python', sys.version yield 'setuptools', setuptools.__version__ from trac.util.datefmt import pytz if pytz is not None: yield 'pytz', pytz.__version__ if hasattr(self, 'webfrontend_version'): yield self.webfrontend, self.webfrontend_version def component_activated(self, component): """Initialize additional member variables for components. Every component activated through the `Environment` object gets three member variables: `env` (the environment object), `config` (the environment configuration) and `log` (a logger object).""" component.env = self component.config = self.config component.log = self.log def _component_name(self, name_or_class): name = name_or_class if not isinstance(name_or_class, basestring): name = name_or_class.__module__ + '.' + name_or_class.__name__ return name.lower() @lazy def _component_rules(self): _rules = {} for name, value in self.components_section.options(): if name.endswith('.*'): name = name[:-2] _rules[name.lower()] = value.lower() in ('enabled', 'on') return _rules def is_component_enabled(self, cls): """Implemented to only allow activation of components that are not disabled in the configuration. This is called by the `ComponentManager` base class when a component is about to be activated. If this method returns `False`, the component does not get activated. If it returns `None`, the component only gets activated if it is located in the `plugins` directory of the environment. """ component_name = self._component_name(cls) # Disable the pre-0.11 WebAdmin plugin # Please note that there's no recommendation to uninstall the # plugin because doing so would obviously break the backwards # compatibility that the new integration administration # interface tries to provide for old WebAdmin extensions if component_name.startswith('webadmin.'): self.log.info("The legacy TracWebAdmin plugin has been " "automatically disabled, and the integrated " "administration interface will be used " "instead.") return False rules = self._component_rules cname = component_name while cname: enabled = rules.get(cname) if enabled is not None: return enabled idx = cname.rfind('.') if idx < 0: break cname = cname[:idx] # By default, all components in the trac package except # trac.test are enabled return component_name.startswith('trac.') and \ not component_name.startswith('trac.test.') or None def enable_component(self, cls): """Enable a component or module.""" self._component_rules[self._component_name(cls)] = True def verify(self): """Verify that the provided path points to a valid Trac environment directory.""" try: tag = read_file(os.path.join(self.path, 'VERSION')).splitlines()[0] if tag != _VERSION: raise Exception("Unknown Trac environment type '%s'" % tag) except Exception as e: raise TracError("No Trac environment found at %s\n%s" % (self.path, e)) def get_db_cnx(self): """Return a database connection from the connection pool :deprecated: Use :meth:`db_transaction` or :meth:`db_query` instead `db_transaction` for obtaining the `db` database connection which can be used for performing any query (SELECT/INSERT/UPDATE/DELETE):: with env.db_transaction as db: ... Note that within the block, you don't need to (and shouldn't) call ``commit()`` yourself, the context manager will take care of it (if it's the outermost such context manager on the stack). `db_query` for obtaining a `db` database connection which can be used for performing SELECT queries only:: with env.db_query as db: ... """ return DatabaseManager(self).get_connection() @lazy def db_exc(self): """Return an object (typically a module) containing all the backend-specific exception types as attributes, named according to the Python Database API (http://www.python.org/dev/peps/pep-0249/). To catch a database exception, use the following pattern:: try: with env.db_transaction as db: ... except env.db_exc.IntegrityError as e: ... """ return DatabaseManager(self).get_exceptions() def with_transaction(self, db=None): """Decorator for transaction functions :deprecated:""" return with_transaction(self, db) def get_read_db(self): """Return a database connection for read purposes :deprecated: See `trac.db.api.get_read_db` for detailed documentation.""" return DatabaseManager(self).get_connection(readonly=True) @property def db_query(self): """Return a context manager (`~trac.db.api.QueryContextManager`) which can be used to obtain a read-only database connection. Example:: with env.db_query as db: cursor = db.cursor() cursor.execute("SELECT ...") for row in cursor.fetchall(): ... Note that a connection retrieved this way can be "called" directly in order to execute a query:: with env.db_query as db: for row in db("SELECT ..."): ... :warning: after a `with env.db_query as db` block, though the `db` variable is still defined, you shouldn't use it as it might have been closed when exiting the context, if this context was the outermost context (`db_query` or `db_transaction`). If you don't need to manipulate the connection itself, this can even be simplified to:: for row in env.db_query("SELECT ..."): ... """ return QueryContextManager(self) @property def db_transaction(self): """Return a context manager (`~trac.db.api.TransactionContextManager`) which can be used to obtain a writable database connection. Example:: with env.db_transaction as db: cursor = db.cursor() cursor.execute("UPDATE ...") Upon successful exit of the context, the context manager will commit the transaction. In case of nested contexts, only the outermost context performs a commit. However, should an exception happen, any context manager will perform a rollback. You should *not* call `commit()` yourself within such block, as this will force a commit even if that transaction is part of a larger transaction. Like for its read-only counterpart, you can directly execute a DML query on the `db`:: with env.db_transaction as db: db("UPDATE ...") :warning: after a `with env.db_transaction` as db` block, though the `db` variable is still available, you shouldn't use it as it might have been closed when exiting the context, if this context was the outermost context (`db_query` or `db_transaction`). If you don't need to manipulate the connection itself, this can also be simplified to:: env.db_transaction("UPDATE ...") """ return TransactionContextManager(self) def shutdown(self, tid=None): """Close the environment.""" RepositoryManager(self).shutdown(tid) DatabaseManager(self).shutdown(tid) if tid is None: self.log.removeHandler(self._log_handler) self._log_handler.flush() self._log_handler.close() del self._log_handler def get_repository(self, reponame=None, authname=None): """Return the version control repository with the given name, or the default repository if `None`. The standard way of retrieving repositories is to use the methods of `RepositoryManager`. This method is retained here for backward compatibility. :param reponame: the name of the repository :param authname: the user name for authorization (not used anymore, left here for compatibility with 0.11) """ return RepositoryManager(self).get_repository(reponame) def create(self, options=[]): """Create the basic directory structure of the environment, initialize the database and populate the configuration file with default values. If options contains ('inherit', 'file'), default values will not be loaded; they are expected to be provided by that file or other options. """ # Create the directory structure if not os.path.exists(self.path): os.mkdir(self.path) os.mkdir(self.get_log_dir()) os.mkdir(self.get_htdocs_dir()) os.mkdir(os.path.join(self.path, 'plugins')) # Create a few files create_file(os.path.join(self.path, 'VERSION'), _VERSION + '\n') create_file(os.path.join(self.path, 'README'), 'This directory contains a Trac environment.\n' 'Visit http://trac.edgewall.org/ for more information.\n') # Setup the default configuration os.mkdir(os.path.join(self.path, 'conf')) create_file(os.path.join(self.path, 'conf', 'trac.ini.sample')) config = Configuration(os.path.join(self.path, 'conf', 'trac.ini')) for section, name, value in options: config.set(section, name, value) config.save() self.setup_config() if not any((section, option) == ('inherit', 'file') for section, option, value in options): self.config.set_defaults(self) self.config.save() # Create the database DatabaseManager(self).init_db() def get_version(self, initial=False): """Return the current version of the database. If the optional argument `initial` is set to `True`, the version of the database used at the time of creation will be returned. In practice, for database created before 0.11, this will return `False` which is "older" than any db version number. :since: 0.11 """ rows = self.db_query(""" SELECT value FROM system WHERE name='%sdatabase_version' """ % ('initial_' if initial else '')) return int(rows[0][0]) if rows else False def setup_config(self): """Load the configuration file.""" self.config = Configuration(os.path.join(self.path, 'conf', 'trac.ini'), {'envname': os.path.basename(self.path)}) self.setup_log() from trac.loader import load_components plugins_dir = self.shared_plugins_dir load_components(self, plugins_dir and (plugins_dir,)) def get_templates_dir(self): """Return absolute path to the templates directory.""" return os.path.join(self.path, 'templates') def get_htdocs_dir(self): """Return absolute path to the htdocs directory.""" return os.path.join(self.path, 'htdocs') def get_log_dir(self): """Return absolute path to the log directory.""" return os.path.join(self.path, 'log') def setup_log(self): """Initialize the logging sub-system.""" from trac.log import logger_handler_factory logtype = self.log_type logfile = self.log_file if logtype == 'file' and not os.path.isabs(logfile): logfile = os.path.join(self.get_log_dir(), logfile) format = self.log_format logid = 'Trac.%s' % sha1(self.path).hexdigest() if format: format = format.replace('$(', '%(') \ .replace('%(path)s', self.path) \ .replace('%(basename)s', os.path.basename(self.path)) \ .replace('%(project)s', self.project_name) self.log, self._log_handler = logger_handler_factory( logtype, logfile, self.log_level, logid, format=format) from trac import core, __version__ as VERSION self.log.info('-' * 32 + ' environment startup [Trac %s] ' + '-' * 32, get_pkginfo(core).get('version', VERSION)) def get_known_users(self): """Generator that yields information about all known users, i.e. users that have logged in to this Trac environment and possibly set their name and email. This function generates one tuple for every user, of the form (username, name, email) ordered alpha-numerically by username. """ for username, name, email in self.db_query(""" SELECT DISTINCT s.sid, n.value, e.value FROM session AS s LEFT JOIN session_attribute AS n ON (n.sid=s.sid and n.authenticated=1 AND n.name = 'name') LEFT JOIN session_attribute AS e ON (e.sid=s.sid AND e.authenticated=1 AND e.name = 'email') WHERE s.authenticated=1 ORDER BY s.sid """): yield username, name, email def backup(self, dest=None): """Create a backup of the database. :param dest: Destination file; if not specified, the backup is stored in a file called db_name.trac_version.bak """ return DatabaseManager(self).backup(dest) def needs_upgrade(self): """Return whether the environment needs to be upgraded.""" for participant in self.setup_participants: args = () with self.db_query as db: if arity(participant.environment_needs_upgrade) == 1: args = (db,) if participant.environment_needs_upgrade(*args): self.log.warn("Component %s requires environment upgrade", participant) return True return False def upgrade(self, backup=False, backup_dest=None): """Upgrade database. :param backup: whether or not to backup before upgrading :param backup_dest: name of the backup file :return: whether the upgrade was performed """ upgraders = [] for participant in self.setup_participants: args = () with self.db_query as db: if arity(participant.environment_needs_upgrade) == 1: args = (db,) if participant.environment_needs_upgrade(*args): upgraders.append(participant) if not upgraders: return if backup: try: self.backup(backup_dest) except Exception as e: raise BackupError(e) for participant in upgraders: self.log.info("%s.%s upgrading...", participant.__module__, participant.__class__.__name__) args = () with self.db_transaction as db: if arity(participant.upgrade_environment) == 1: args = (db,) participant.upgrade_environment(*args) # Database schema may have changed, so close all connections DatabaseManager(self).shutdown() return True @lazy def href(self): """The application root path""" return Href(urlsplit(self.abs_href.base).path) @lazy def abs_href(self): """The application URL""" if not self.base_url: self.log.warn("base_url option not set in configuration, " "generated links may be incorrect") _abs_href = Href('') else: _abs_href = Href(self.base_url) return _abs_href
class AuthzSourcePolicy(Component): """Permission policy for `source:` and `changeset:` resources using a Subversion authz file. `FILE_VIEW` and `BROWSER_VIEW` permissions are granted as specified in the authz file. `CHANGESET_VIEW` permission is granted for changesets where `FILE_VIEW` is granted on at least one modified file, as well as for empty changesets. """ implements(IPermissionPolicy) authz_file = PathOption( 'trac', 'authz_file', '', """The path to the Subversion [http://svnbook.red-bean.com/en/1.5/svn.serverconfig.pathbasedauthz.html authorization (authz) file]. To enable authz permission checking, the `AuthzSourcePolicy` permission policy must be added to `[trac] permission_policies`. """) authz_module_name = Option( 'trac', 'authz_module_name', '', """The module prefix used in the `authz_file` for the default repository. If left empty, the global section is used. """) _mtime = 0 _authz = {} _users = set() _handled_perms = frozenset([(None, 'BROWSER_VIEW'), (None, 'CHANGESET_VIEW'), (None, 'FILE_VIEW'), (None, 'LOG_VIEW'), ('source', 'BROWSER_VIEW'), ('source', 'FILE_VIEW'), ('source', 'LOG_VIEW'), ('changeset', 'CHANGESET_VIEW')]) # IPermissionPolicy methods def check_permission(self, action, username, resource, perm): realm = resource.realm if resource else None if (realm, action) in self._handled_perms: authz, users = self._get_authz_info() if authz is None: return False if username == 'anonymous': usernames = ('$anonymous', '*') else: usernames = (username, '$authenticated', '*') if resource is None: return True if users & set(usernames) else None rm = RepositoryManager(self.env) try: repos = rm.get_repository(resource.parent.id) except TracError: return True # Allow error to be displayed in the repo index if repos is None: return True modules = [resource.parent.id or self.authz_module_name] if modules[0]: modules.append('') def check_path(path): path = '/' + join(repos.scope, path) if path != '/': path += '/' # Walk from resource up parent directories for spath in parent_iter(path): for module in modules: section = authz.get(module, {}).get(spath) if section: for user in usernames: result = section.get(user) if result is not None: return result # Allow access to parent directories of allowed resources if any( section.get(user) is True for module in modules for spath, section in authz.get( module, {}).iteritems() if spath.startswith(path) for user in usernames): return True if realm == 'source': return check_path(resource.id) elif realm == 'changeset': changes = list(repos.get_changeset(resource.id).get_changes()) if not changes or any( check_path(change[0]) for change in changes): return True def _get_authz_info(self): try: mtime = os.path.getmtime(self.authz_file) except OSError, e: if self._authz is not None: self.log.error('Error accessing authz file: %s', exception_to_unicode(e)) self._mtime = mtime = 0 self._authz = None self._users = set() if mtime > self._mtime: self._mtime = mtime rm = RepositoryManager(self.env) modules = set(repos.reponame for repos in rm.get_real_repositories()) if '' in modules and self.authz_module_name: modules.add(self.authz_module_name) modules.add('') self.log.info('Parsing authz file: %s' % self.authz_file) try: self._authz = parse(read_file(self.authz_file), modules) self._users = set(user for paths in self._authz.itervalues() for path in paths.itervalues() for user, result in path.iteritems() if result) except Exception, e: self._authz = None self._users = set() self.log.error('Error parsing authz file: %s', exception_to_unicode(e))
class DatabaseManager(Component): """Component used to manage the `IDatabaseConnector` implementations.""" implements(IEnvironmentSetupParticipant, ISystemInfoProvider) connectors = ExtensionPoint(IDatabaseConnector) connection_uri = Option( 'trac', 'database', 'sqlite:db/trac.db', """Database connection [wiki:TracEnvironment#DatabaseConnectionStrings string] for this project""") backup_dir = Option('trac', 'backup_dir', 'db', """Database backup location""") timeout = IntOption( 'trac', 'timeout', '20', """Timeout value for database connection, in seconds. Use '0' to specify ''no timeout''.""") debug_sql = BoolOption( 'trac', 'debug_sql', False, """Show the SQL queries in the Trac log, at DEBUG level. """) def __init__(self): self._cnx_pool = None self._transaction_local = ThreadLocal(wdb=None, rdb=None) def init_db(self): connector, args = self.get_connector() args['schema'] = db_default.schema connector.init_db(**args) version = db_default.db_version self.set_database_version(version, 'initial_database_version') self.set_database_version(version) def insert_default_data(self): self.insert_into_tables(db_default.get_data) def destroy_db(self): connector, args = self.get_connector() # Connections to on-disk db must be closed before deleting it. self.shutdown() connector.destroy_db(**args) def db_exists(self): connector, args = self.get_connector() return connector.db_exists(**args) def create_tables(self, schema): """Create the specified tables. :param schema: an iterable of table objects. :since: version 1.0.2 """ connector = self.get_connector()[0] with self.env.db_transaction as db: for table in schema: for sql in connector.to_sql(table): db(sql) def drop_columns(self, table, columns): """Drops the specified columns from table. :since: version 1.2 """ table_name = table.name if isinstance(table, Table) else table with self.env.db_transaction as db: if not db.has_table(table_name): raise self.env.db_exc.OperationalError('Table %s not found' % db.quote(table_name)) for col in columns: db.drop_column(table_name, col) def drop_tables(self, schema): """Drop the specified tables. :param schema: an iterable of `Table` objects or table names. :since: version 1.0.2 """ with self.env.db_transaction as db: for table in schema: table_name = table.name if isinstance(table, Table) else table db.drop_table(table_name) def insert_into_tables(self, data_or_callable): """Insert data into existing tables. :param data_or_callable: Nested tuples of table names, column names and row data:: (table1, (column1, column2), ((row1col1, row1col2), (row2col1, row2col2)), table2, ...) or a callable that takes a single parameter `db` and returns the aforementioned nested tuple. :since: version 1.1.3 """ with self.env.db_transaction as db: data = data_or_callable(db) if callable(data_or_callable) \ else data_or_callable for table, cols, vals in data: db.executemany( "INSERT INTO %s (%s) VALUES (%s)" % (db.quote(table), ','.join(cols), ','.join( ['%s'] * len(cols))), vals) def reset_tables(self): """Deletes all data from the tables and resets autoincrement indexes. :return: list of names of the tables that were reset. :since: version 1.1.3 """ with self.env.db_transaction as db: return db.reset_tables() def upgrade_tables(self, new_schema): """Upgrade table schema to `new_schema`, preserving data in columns that exist in the current schema and `new_schema`. :param new_schema: tuple or list of `Table` objects :since: version 1.2 """ with self.env.db_transaction as db: cursor = db.cursor() for new_table in new_schema: temp_table_name = new_table.name + '_old' has_table = self.has_table(new_table) if has_table: old_column_names = set(self.get_column_names(new_table)) new_column_names = {col.name for col in new_table.columns} column_names = old_column_names & new_column_names if column_names: cols_to_copy = ','.join( db.quote(name) for name in column_names) cursor.execute(""" CREATE TEMPORARY TABLE %s AS SELECT * FROM %s """ % (db.quote(temp_table_name), db.quote(new_table.name))) self.drop_tables((new_table, )) self.create_tables((new_table, )) if has_table and column_names: cursor.execute(""" INSERT INTO %s (%s) SELECT %s FROM %s """ % (db.quote(new_table.name), cols_to_copy, cols_to_copy, db.quote(temp_table_name))) for col in new_table.columns: if col.auto_increment: db.update_sequence(cursor, new_table.name, col.name) self.drop_tables((temp_table_name, )) def get_connection(self, readonly=False): """Get a database connection from the pool. If `readonly` is `True`, the returned connection will purposely lack the `rollback` and `commit` methods. """ if not self._cnx_pool: connector, args = self.get_connector() self._cnx_pool = ConnectionPool(5, connector, **args) db = self._cnx_pool.get_cnx(self.timeout or None) if readonly: db = ConnectionWrapper(db, readonly=True) return db def get_database_version(self, name='database_version'): """Returns the database version from the SYSTEM table as an int, or `False` if the entry is not found. :param name: The name of the entry that contains the database version in the SYSTEM table. Defaults to `database_version`, which contains the database version for Trac. """ with self.env.db_query as db: for value, in db( """ SELECT value FROM {0} WHERE name=%s """.format(db.quote('system')), (name, )): return int(value) else: return False def get_exceptions(self): return self.get_connector()[0].get_exceptions() def get_sequence_names(self): """Returns a list of the sequence names. :since: 1.3.2 """ with self.env.db_query as db: return db.get_sequence_names() def get_table_names(self): """Returns a list of the table names. :since: 1.1.6 """ with self.env.db_query as db: return db.get_table_names() def get_column_names(self, table): """Returns a list of the column names for `table`. :param table: a `Table` object or table name. :since: 1.2 """ table_name = table.name if isinstance(table, Table) else table with self.env.db_query as db: if not db.has_table(table_name): raise self.env.db_exc.OperationalError('Table %s not found' % db.quote(table_name)) return db.get_column_names(table_name) def has_table(self, table): """Returns whether the table exists.""" table_name = table.name if isinstance(table, Table) else table with self.env.db_query as db: return db.has_table(table_name) def set_database_version(self, version, name='database_version'): """Sets the database version in the SYSTEM table. :param version: an integer database version. :param name: The name of the entry that contains the database version in the SYSTEM table. Defaults to `database_version`, which contains the database version for Trac. """ current_database_version = self.get_database_version(name) if current_database_version is False: with self.env.db_transaction as db: db( """ INSERT INTO {0} (name, value) VALUES (%s, %s) """.format(db.quote('system')), (name, version)) elif version != self.get_database_version(name): with self.env.db_transaction as db: db( """ UPDATE {0} SET value=%s WHERE name=%s """.format(db.quote('system')), (version, name)) self.log.info("Upgraded %s from %d to %d", name, current_database_version, version) def needs_upgrade(self, version, name='database_version'): """Checks the database version to determine if an upgrade is needed. :param version: the expected integer database version. :param name: the name of the entry in the SYSTEM table that contains the database version. Defaults to `database_version`, which contains the database version for Trac. :return: `True` if the stored version is less than the expected version, `False` if it is equal to the expected version. :raises TracError: if the stored version is greater than the expected version. """ dbver = self.get_database_version(name) if dbver == version: return False elif dbver > version: raise TracError(_("Need to downgrade %(name)s.", name=name)) self.log.info("Need to upgrade %s from %d to %d", name, dbver, version) return True def upgrade(self, version, name='database_version', pkg='trac.upgrades'): """Invokes `do_upgrade(env, version, cursor)` in module `"%s/db%i.py" % (pkg, version)`, for each required version upgrade. :param version: the expected integer database version. :param name: the name of the entry in the SYSTEM table that contains the database version. Defaults to `database_version`, which contains the database version for Trac. :param pkg: the package containing the upgrade modules. :raises TracError: if the package or module doesn't exist. """ dbver = self.get_database_version(name) for i in xrange(dbver + 1, version + 1): module = '%s.db%i' % (pkg, i) try: upgrader = importlib.import_module(module) except ImportError: raise TracError( _("No upgrade module %(module)s.py", module=module)) with self.env.db_transaction as db: cursor = db.cursor() upgrader.do_upgrade(self.env, i, cursor) self.set_database_version(i, name) def shutdown(self, tid=None): if self._cnx_pool: self._cnx_pool.shutdown(tid) if not tid: self._cnx_pool = None def backup(self, dest=None): """Save a backup of the database. :param dest: base filename to write to. Returns the file actually written. """ connector, args = self.get_connector() if not dest: backup_dir = self.backup_dir if not os.path.isabs(backup_dir): backup_dir = os.path.join(self.env.path, backup_dir) db_str = self.config.get('trac', 'database') db_name, db_path = db_str.split(":", 1) dest_name = '%s.%i.%d.bak' % (db_name, self.env.database_version, int(time.time())) dest = os.path.join(backup_dir, dest_name) else: backup_dir = os.path.dirname(dest) if not os.path.exists(backup_dir): os.makedirs(backup_dir) return connector.backup(dest) def get_connector(self): scheme, args = parse_connection_uri(self.connection_uri) candidates = [ (priority, connector) for connector in self.connectors for scheme_, priority in connector.get_supported_schemes() if scheme_ == scheme ] if not candidates: raise TracError( _('Unsupported database type "%(scheme)s"', scheme=scheme)) priority, connector = max(candidates) if priority < 0: raise TracError(connector.error) if scheme == 'sqlite': if args['path'] == ':memory:': # Special case for SQLite in-memory database, always get # the /same/ connection over pass elif not os.path.isabs(args['path']): # Special case for SQLite to support a path relative to the # environment directory args['path'] = os.path.join(self.env.path, args['path'].lstrip('/')) if self.debug_sql: args['log'] = self.log return connector, args # IEnvironmentSetupParticipant methods def environment_created(self): pass def environment_needs_upgrade(self): return self.needs_upgrade(db_default.db_version) def upgrade_environment(self): self.upgrade(db_default.db_version) # ISystemInfoProvider methods def get_system_info(self): connector = self.get_connector()[0] for info in connector.get_system_info(): yield info