class VoteProportionalMarkers(Component): implements(IMapMarkerStyle) min_size = IntOption("geo", "min_marker_size", "3", "minimum map marker size") max_size = IntOption("geo", "max_marker_size", "16", "maximum map marker size") # method for IMapMarkerStyle def style(self, ticket, req, **style): votesystem = VoteSystem(self.env) votes = votesystem.get_vote_counts('ticket/%s' % ticket.id)[1] max_votes = votesystem.get_max_votes('ticket') size = self.marker_radius(votes, max_votes) return { 'pointRadius': str(int(size)) } # method for IRequireComponents def requires(self): return [ VoteSystem ] # method for calculating point size def marker_radius(self, votes, max_votes): if max_votes == 0: return self.min_size size = self.min_size ** 2 size += ((self.max_size ** 2) - size)*((votes/float(max_votes))**2) return sqrt(size)
class DiscussionWebAdmin(Component): """ The webadmin module implements discussion plugin administration via WebAdminPlugin. """ implements(IAdminPageProvider) topics_per_page = IntOption( 'discussion', 'topics_per_page', 20, 'The number of topics to display on each page inside a forum') # IAdminPageProvider def get_admin_pages(self, req): if req.perm.has_permission('DISCUSSION_ADMIN'): yield ('discussion', 'Discussion System', 'group', 'Forum Groups') yield ('discussion', 'Discussion System', 'forum', 'Forums') def process_admin_request(self, req, category, page, path_info): # Prepare request object if page == 'forum': if not req.args.has_key('group'): req.args['group'] = '-1' if path_info: req.args['forum'] = path_info else: if path_info: req.args['group'] = path_info req.args['component'] = 'admin' # Retrun page content api = DiscussionApi(self, req) return api.render_discussion(req)
class GitConnector(Component): implements(IRepositoryConnector, IWikiSyntaxProvider, IPropertyRenderer) _persistent_cache = BoolOption('git', 'persistent_cache', 'false', "Enable persistent caching of commit tree") _cached_repository = BoolOption('git', 'cached_repository', 'false', "Wrap `GitRepository` in `CachedRepository`") _shortrev_len = IntOption('git', 'shortrev_len', 7, "Length rev sha sums should be tried to be abbreviated to" " (must be >= 4 and <= 40)") _git_bin = PathOption('git', 'git_bin', '/usr/bin/git', "Path to git executable (relative to trac project folder!)") def __init__(self): self._version = None try: self._version = PyGIT.Storage.git_version(git_bin=self._git_bin) except PyGIT.GitError, e: self.log.error("GitError: %s", e) if self._version: self.log.info("detected GIT version %s", self._version['v_str']) self.env.systeminfo.append(('GIT', self._version['v_str'])) if not self._version['v_compatible']: self.log.error("GIT version %s installed not compatible " "(need >= %s)" , self._version['v_str'], self._version['v_min_str'])
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 SMTP server. (''since 0.9'')""") smtp_password = Option('notification', 'smtp_password', '', """Password for SMTP server. (''since 0.9'')""") use_tls = BoolOption( 'notification', 'use_tls', 'false', """Use SSL/TLS to send notifications over SMTP. (''since 0.10'')""") crlf = re.compile("\r?\n") def send(self, from_addr, recipients, message): # Ensure the message complies with RFC2822: use CRLF line endings message = CRLF.join(self.crlf.split(message)) self.log.info("Sending notification through SMTP at %s:%d to %s" % (self.smtp_server, self.smtp_port, recipients)) server = smtplib.SMTP(self.smtp_server, self.smtp_port) # server.set_debuglevel(True) if self.use_tls: server.ehlo() if not server.esmtp_features.has_key('starttls'): 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.time() server.sendmail(from_addr, recipients, message) t = time.time() - 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 DatabaseManager(Component): connectors = ExtensionPoint(IDatabaseConnector) connection_uri = Option( 'trac', 'database', 'sqlite:db/trac.db', """Database connection [wiki:TracEnvironment#DatabaseConnectionStrings string] for this project""") timeout = IntOption( 'trac', 'timeout', '20', """Timeout value for database connection, in seconds. Use '0' to specify ''no timeout''. ''(Since 0.11)''""") def __init__(self): self._cnx_pool = None def init_db(self): connector, args = self._get_connector() connector.init_db(**args) def get_connection(self): if not self._cnx_pool: connector, args = self._get_connector() self._cnx_pool = ConnectionPool(5, connector, **args) return self._cnx_pool.get_cnx(self.timeout or None) def shutdown(self, tid=None): if self._cnx_pool: self._cnx_pool.shutdown(tid) if not tid: self._cnx_pool = None def _get_connector(self): ### FIXME: Make it public? scheme, args = _parse_db_str(self.connection_uri) candidates = {} connector = None for connector in self.connectors: for scheme_, priority in connector.get_supported_schemes(): if scheme_ != scheme: continue highest = candidates.get(scheme_, (None, 0))[1] if priority > highest: candidates[scheme] = (connector, priority) connector = candidates.get(scheme, [None])[0] if not connector: raise TracError('Unsupported database type "%s"' % scheme) if scheme == 'sqlite': # Special case for SQLite to support a path relative to the # environment directory if args['path'] != ':memory:' and \ not args['path'].startswith('/'): args['path'] = os.path.join(self.env.path, args['path'].lstrip('/')) return connector, args
class WikiNotificationSystem(Component): from_email = Option('wiki-notification', 'from_email', 'trac+wiki@localhost', """Sender address to use in notification emails.""") from_name = Option( 'wiki-notification', 'from_name', None, """Sender name to use in notification emails. Defaults to project name.""") smtp_always_cc = ListOption( 'wiki-notification', 'smtp_always_cc', [], doc="""Comma separated list of email address(es) to always send notifications to. Addresses can be seen by all recipients (Cc:).""") smtp_always_bcc = ListOption( 'wiki-notification', 'smtp_always_bcc', [], doc="""Comma separated list of email address(es) to always send notifications to. Addresses do not appear publicly (Bcc:).""") use_public_cc = BoolOption( 'wiki-notification', 'use_public_cc', False, """Recipients can see email addresses of other CC'ed recipients. If this option is disabled(the default), recipients are put on BCC. (values: 1, on, enabled, true or 0, off, disabled, false)""") attach_diff = BoolOption( 'wiki-notification', 'attach_diff', False, """Send `diff`'s as an attachment instead of inline in email body.""") redirect_time = IntOption( 'wiki-notification', 'redirect_time', 5, """The default seconds a redirect should take when watching/un-watching a wiki page""") subject_template = Option( 'wiki-notification', 'subject_template', '$prefix $pagename $action', "A Genshi text template snippet used to get the notification subject.") banned_addresses = ListOption( 'wiki-notification', 'banned_addresses', [], doc="""A comma separated list of email addresses that should never be sent a notification email.""")
class OpenPgpKey(Credential): """Represents a single OpenPGP key.""" allow_usermod = BoolOption( 'crypto', 'gpg_keygen_allow_usermod', 'True', """Allow users to overwrite key generation presets. """) expire_date = Option( 'crypto', 'gpg_keygen_expire_date', '0', """Expiration date, set by ISO date, number of days/weeks/months/years like 365d/50w/12m/1y or an epoch value like seconds=<epoch>. Zero means non-expiring keys.""") key_type = Option('crypto', 'gpg_keygen_key_type', 'RSA', """Key type, one of 'RSA' (default), 'DSA'.""") key_length = IntOption('crypto', 'gpg_keygen_key_length', 2048, """Key bit length, supports 1024 or 2048.""") subkey_type = Option( 'crypto', 'gpg_keygen_subkey_type', 'ELG-E', """Subkey type, if using DSA primary key, one of 'RSA', 'ELG-E'.""") subkey_length = IntOption('crypto', 'gpg_keygen_subkey_length', 2048, """Subkey bit length, supports 1024 or 2048.""") factory = 'openpgp' def __init_(self, key_id=None, **kwargs): cb = CryptoBase(self.env) if key_id: key = cb.get_key(key_id) self.id = key['keyid'] self.props = { 'uid': key.get('uids')[1], 'length': key.get('length'), 'created': key.get('date'), 'expires': key.get('expires'), } else: if properties: key = cb.create_key(factory, **kwargs)
class IrkerNotifcationPlugin(Component): implements(ITicketChangeListener) host = Option('irker', 'host', 'localhost', doc="Host on which the irker daemon resides.") port = IntOption('irker', 'port', 6659, doc="Irker listen port.") target = Option( 'irker', 'target', 'irc://localhost/#commits', doc="IRC channel URL to which notifications are to be sent.") def notify(self, type, values): values['type'] = type values['author'] = re.sub(r' <.*', '', values['author']) #template = '%(project)s/%(branch)s %(rev)s %(author)s: %(logmsg)s' #template = '%(project)s %(rev)s %(author)s: %(logmsg)s' template = '%(project)s %(type)s %(id)s %(action)s %(author)s: %(summary)s' message = template % values #message = ' '.join(['%s=%s' % (key, value) for (key, value) in values.items()]) data = {"to": self.target, "privmsg": message.encode('utf-8').strip()} try: s = socket.create_connection((self.host, self.port)) s.sendall(json.dumps(data)) except socket.error: return False return True def ticket_created(self, ticket): values = prepare_ticket_values(ticket, 'created') values['author'] = values['reporter'] self.notify('ticket', values) def ticket_changed(self, ticket, comment, author, old_values): action = 'changed' if 'status' in old_values: if 'status' in ticket.values: if ticket.values['status'] != old_values['status']: action = ticket.values['status'] values = prepare_ticket_values(ticket, action) values.update({ 'comment': comment or '', 'author': author or '', 'old_values': old_values }) self.notify('ticket', values) def ticket_deleted(self, ticket): pass
class WikiLinkNewDecolator(Component): """ set \"new\" css-class to wiki link if the page is young. age can set in [wiki]-wiki_new_info_second in trac.ini""" implements(IRequestHandler) wrapped = None wiki_new_info_day = IntOption('wiki', 'wiki_new_info_second', '432000', doc=u"""age in seconds to add new icon. (Provided by !ContextChrome.!WikiLinkNewDecolator) """) def __init__(self): Component.__init__(self) if not self.wrapped: self.wrap() def wrap(self): wikisystem = self.compmgr[WikiSystem] def _format_link(*args, **kwargs): # hook method element = self.wrapped(*args, **kwargs) if isinstance(element, Element): class_ = element.attrib.get('class') if class_ and element.attrib.get('href'): # existing ticket deco = self.get_deco(*args, **kwargs) or [] element.attrib = element.attrib | [('class', ' '.join(deco + [class_]))] return element self.wrapped = wikisystem._format_link wikisystem._format_link = _format_link def get_deco(self, formatter, ns, pagename, label, ignore_missing, original_label=None): wikipage = WikiPage(self.env, pagename) if not wikipage.time: return now = datetime.now(formatter.req.tz) delta = now - wikipage.time limit = self.config.getint('wiki', 'wiki_new_info_second') if limit < delta.days * 86400 + delta.seconds: return return ['new'] # IRequestHandler Methods def match_request(self, req): return False def process_request(self, req): pass
class ValidEmailFilterStrategy(Component): """This strategy grants positive karma points to anonymous users with a valid RFC822 e-mail address.""" implements(IFilterStrategy) karma_points = IntOption( 'ticketvalidemail', 'validemail_karma', '10', """By how many points a valid RFC822 e-mail address improves the overall karma of the submission for anonymous users. Invalid e-mail will lower karma by that value.""" ) reject_emails_re = re.compile(r'^\w+@example\.(org|net|com)$') # IFilterStrategy implementation def is_external(self): return False def test(self, req, author, content, ip): points = -abs(self.karma_points) if req.authname and req.authname != 'anonymous': self.log.debug("Authenticated user, skipping...") return # Split up author into name and email, if possible author = author.encode('utf-8') author_name, author_email = parseaddr(author) self.log.debug("Author name is [%s] and e-mail is [%s]", author_name, author_email) if not author_email: return points, 'No e-mail found' elif self.reject_emails_re.match(author_email.lower()): return points, 'Example e-mail detected' elif rfc822.valid(author_email): points = abs(self.karma_points) return points, 'Valid e-mail found' else: return points, 'No valid RFC822 e-mail address found in reporter field' def train(self, req, author, content, ip, spam=True): pass
class SearchModule(Component): implements(INavigationContributor, IPermissionRequestor, IRequestHandler, ITemplateProvider, IWikiSyntaxProvider) search_sources = ExtensionPoint(ISearchSource) RESULTS_PER_PAGE = 10 min_query_length = IntOption( 'search', 'min_query_length', 3, """Minimum length of query string allowed when performing a search.""") default_disabled_filters = ListOption( 'search', 'default_disabled_filters', doc="""Specifies which search filters should be disabled by default on the search page. This will also restrict the filters for the quick search function. The filter names defined by default components are: `wiki`, `ticket`, `milestone` and `changeset`. For plugins, look for their implementation of the ISearchSource interface, in the `get_search_filters()` method, the first member of returned tuple. Once disabled, search filters can still be manually enabled by the user on the search page. (since 0.12)""") # INavigationContributor methods def get_active_navigation_item(self, req): return 'search' def get_navigation_items(self, req): if 'SEARCH_VIEW' in req.perm: yield ('mainnav', 'search', tag.a(_('Search'), href=req.href.search(), accesskey=4)) # IPermissionRequestor methods def get_permission_actions(self): return ['SEARCH_VIEW'] # IRequestHandler methods def match_request(self, req): return re.match(r'/search(?:/opensearch)?$', req.path_info) is not None def process_request(self, req): req.perm.assert_permission('SEARCH_VIEW') if req.path_info == '/search/opensearch': return ('opensearch.xml', {}, 'application/opensearchdescription+xml') query = req.args.get('q') available_filters = [] for source in self.search_sources: available_filters.extend(source.get_search_filters(req) or []) available_filters.sort(key=lambda f: f[1].lower()) filters = self._get_selected_filters(req, available_filters) data = self._prepare_data(req, query, available_filters, filters) if query: data['quickjump'] = self._check_quickjump(req, query) if query.startswith('!'): query = query[1:] terms = self._parse_query(req, query) if terms: results = self._do_search(req, terms, filters) if results: data.update(self._prepare_results(req, filters, results)) add_stylesheet(req, 'common/css/search.css') return 'search.html', data, None # ITemplateProvider methods def get_htdocs_dirs(self): return [] def get_templates_dirs(self): return [pkg_resources.resource_filename('trac.search', 'templates')] # IWikiSyntaxProvider methods def get_wiki_syntax(self): return [] def get_link_resolvers(self): yield ('search', self._format_link) def _format_link(self, formatter, ns, target, label): path, query, fragment = formatter.split_link(target) if path: href = formatter.href.search(q=path) if query: href += '&' + quote_query_string(query[1:]) else: href = formatter.href.search() + quote_query_string(query) href += fragment return tag.a(label, class_='search', href=href) # IRequestHandler helper methods def _get_selected_filters(self, req, available_filters): """Return selected filters or the default filters if none was selected. """ filters = [f[0] for f in available_filters if f[0] in req.args] if not filters: filters = [ f[0] for f in available_filters if f[0] not in self.default_disabled_filters and ( len(f) < 3 or len(f) > 2 and f[2]) ] return filters def _prepare_data(self, req, query, available_filters, filters): return { 'filters': [{ 'name': f[0], 'label': f[1], 'active': f[0] in filters } for f in available_filters], 'query': query, 'quickjump': None, 'results': [] } def _check_quickjump(self, req, kwd): """Look for search shortcuts""" noquickjump = as_int(req.args.get('noquickjump'), 0) # Source quickjump FIXME: delegate to ISearchSource.search_quickjump quickjump_href = None if kwd[0] == '/': quickjump_href = req.href.browser(kwd) name = kwd description = _('Browse repository path %(path)s', path=kwd) else: context = web_context(req, 'search') link = find_element(extract_link(self.env, context, kwd), 'href') if link is not None: quickjump_href = link.attrib.get('href') name = link.children description = link.attrib.get('title', '') if quickjump_href: # Only automatically redirect to local quickjump links if not quickjump_href.startswith(req.base_path or '/'): noquickjump = True if noquickjump: return { 'href': quickjump_href, 'name': tag.em(name), 'description': description } else: req.redirect(quickjump_href) def _get_search_terms(self, query): """Break apart a search query into its various search terms. Terms are grouped implicitly by word boundary, or explicitly by (single or double) quotes. """ terms = [] for term in re.split('(".*?")|(\'.*?\')|(\s+)', query): if term is not None and term.strip(): if term[0] == term[-1] and term[0] in "'\"": term = term[1:-1] terms.append(term) return terms def _parse_query(self, req, query): """Parse query and refuse those which would result in a huge result set """ terms = self._get_search_terms(query) if terms and (len(terms) > 1 or len(terms[0]) >= self.min_query_length): return terms add_warning( req, _( 'Search query too short. ' 'Query must be at least %(num)s characters long.', num=self.min_query_length)) def _do_search(self, req, terms, filters): results = [] for source in self.search_sources: results.extend( source.get_search_results(req, terms, filters) or []) return sorted(results, key=lambda x: x[2], reverse=True) def _prepare_results(self, req, filters, results): page = req.args.get('page', 1) page = as_int(page, default=1, min=1) try: results = Paginator(results, page - 1, self.RESULTS_PER_PAGE) except TracError: add_warning(req, _("Page %(page)s is out of range.", page=page)) page = 1 results = Paginator(results, page - 1, self.RESULTS_PER_PAGE) for idx, result in enumerate(results): results[idx] = { 'href': result[0], 'title': result[1], 'date': user_time(req, format_datetime, result[2]), 'author': result[3], 'excerpt': result[4] } pagedata = [] shown_pages = results.get_shown_pages(21) for shown_page in shown_pages: page_href = req.href.search([(f, 'on') for f in filters], q=req.args.get('q'), page=shown_page, noquickjump=1) pagedata.append([ page_href, None, str(shown_page), _("Page %(num)d", num=shown_page) ]) fields = ['href', 'class', 'string', 'title'] results.shown_pages = [dict(zip(fields, p)) for p in pagedata] results.current_page = { 'href': None, 'class': 'current', 'string': str(results.page + 1), 'title': None } if results.has_next_page: next_href = req.href.search(zip(filters, ['on'] * len(filters)), q=req.args.get('q'), page=page + 1, noquickjump=1) add_link(req, 'next', next_href, _('Next Page')) if results.has_previous_page: prev_href = req.href.search(zip(filters, ['on'] * len(filters)), q=req.args.get('q'), page=page - 1, noquickjump=1) add_link(req, 'prev', prev_href, _('Previous Page')) page_href = req.href.search(zip(filters, ['on'] * len(filters)), q=req.args.get('q'), noquickjump=1) return {'results': results, 'page_href': page_href}
class TimelineModule(Component): implements(INavigationContributor, IPermissionRequestor, IRequestHandler, IRequestFilter, ITemplateProvider, IWikiSyntaxProvider) event_providers = ExtensionPoint(ITimelineEventProvider) default_daysback = IntOption('timeline', 'default_daysback', 30, """Default number of days displayed in the Timeline, in days. """) max_daysback = IntOption('timeline', 'max_daysback', 90, """Maximum number of days (-1 for unlimited) displayable in the Timeline. """) abbreviated_messages = BoolOption('timeline', 'abbreviated_messages', True, """Whether wiki-formatted event messages should be truncated or not. This only affects the default rendering, and can be overriden by specific event providers, see their own documentation. """) _authors_pattern = re.compile(r'(-)?(?:"([^"]*)"|\'([^\']*)\'|([^\s]+))') # INavigationContributor methods def get_active_navigation_item(self, req): return 'timeline' def get_navigation_items(self, req): if 'TIMELINE_VIEW' in req.perm('timeline'): yield ('mainnav', 'timeline', tag.a(_("Timeline"), href=req.href.timeline(), accesskey=accesskey(req, 2))) # IPermissionRequestor methods def get_permission_actions(self): return ['TIMELINE_VIEW'] # IRequestHandler methods def match_request(self, req): return req.path_info == '/timeline' def process_request(self, req): req.perm('timeline').require('TIMELINE_VIEW') format = req.args.get('format') maxrows = req.args.getint('max', 50 if format == 'rss' else 0) lastvisit = req.session.as_int('timeline.lastvisit', 0) # indication of new events is unchanged when form is updated by user revisit = any(a in req.args for a in ['update', 'from', 'daysback', 'author']) if revisit: lastvisit = req.session.as_int('timeline.nextlastvisit', lastvisit) # Parse the from date and adjust the timestamp to the last second of # the day fromdate = datetime_now(req.tz) today = truncate_datetime(fromdate) yesterday = to_datetime(today.replace(tzinfo=None) - timedelta(days=1), req.tz) precisedate = precision = None if 'from' in req.args: # Acquire from date only from non-blank input reqfromdate = req.args.get('from').strip() if reqfromdate: try: precisedate = user_time(req, parse_date, reqfromdate) except TracError as e: add_warning(req, e) else: fromdate = precisedate.astimezone(req.tz) precision = req.args.get('precision', '') if precision.startswith('second'): precision = timedelta(seconds=1) elif precision.startswith('minute'): precision = timedelta(minutes=1) elif precision.startswith('hour'): precision = timedelta(hours=1) else: precision = None fromdate = to_datetime(datetime(fromdate.year, fromdate.month, fromdate.day, 23, 59, 59, 999999), req.tz) pref = req.session.as_int('timeline.daysback', self.default_daysback) default = 90 if format == 'rss' else pref daysback = req.args.as_int('daysback', default, min=1, max=self.max_daysback) authors = req.args.get('authors') if authors is None and format != 'rss': authors = req.session.get('timeline.authors') authors = (authors or '').strip() data = {'fromdate': fromdate, 'daysback': daysback, 'authors': authors, 'today': today, 'yesterday': yesterday, 'precisedate': precisedate, 'precision': precision, 'events': [], 'filters': [], 'abbreviated_messages': self.abbreviated_messages} available_filters = [] for event_provider in self.event_providers: with component_guard(self.env, req, event_provider): available_filters += (event_provider.get_timeline_filters(req) or []) # check the request or session for enabled filters, or use default filters = [f[0] for f in available_filters if f[0] in req.args] if not filters and format != 'rss': filters = [f[0] for f in available_filters if req.session.as_int('timeline.filter.' + f[0])] if not filters: filters = [f[0] for f in available_filters if len(f) == 2 or f[2]] # save the results of submitting the timeline form to the session if 'update' in req.args: for filter_ in available_filters: key = 'timeline.filter.%s' % filter_[0] if filter_[0] in req.args: req.session[key] = '1' elif key in req.session: del req.session[key] stop = fromdate start = to_datetime(stop.replace(tzinfo=None) - timedelta(days=daysback + 1), req.tz) # create author include and exclude sets include = set() exclude = set() for match in self._authors_pattern.finditer(authors): name = (match.group(2) or match.group(3) or match.group(4)).lower() if match.group(1): exclude.add(name) else: include.add(name) # gather all events for the given period of time events = [] for provider in self.event_providers: with component_guard(self.env, req, provider): for event in provider.get_timeline_events(req, start, stop, filters) or []: author = (event[2] or '').lower() if ((not include or author in include) and author not in exclude): events.append( self._event_data(req, provider, event, lastvisit)) # prepare sorted global list events = sorted(events, key=lambda e: e['datetime'], reverse=True) if maxrows: events = events[:maxrows] data['events'] = events if format == 'rss': rss_context = web_context(req, absurls=True) rss_context.set_hints(wiki_flavor='html', shorten_lines=False) data['context'] = rss_context return 'timeline.rss', data, {'content_type': 'application/rss+xml'} else: req.session.set('timeline.daysback', daysback, self.default_daysback) req.session.set('timeline.authors', authors, '') # store lastvisit if events and not revisit: lastviewed = to_utimestamp(events[0]['datetime']) req.session['timeline.lastvisit'] = max(lastvisit, lastviewed) req.session['timeline.nextlastvisit'] = lastvisit html_context = web_context(req) html_context.set_hints(wiki_flavor='oneliner', shorten_lines=self.abbreviated_messages) data['context'] = html_context add_stylesheet(req, 'common/css/timeline.css') rss_href = req.href.timeline([(f, 'on') for f in filters], daysback=90, max=50, authors=authors, format='rss') add_link(req, 'alternate', auth_link(req, rss_href), _('RSS Feed'), 'application/rss+xml', 'rss') Chrome(self.env).add_jquery_ui(req) for filter_ in available_filters: data['filters'].append({'name': filter_[0], 'label': filter_[1], 'enabled': filter_[0] in filters}) # Navigation to the previous/next period of 'daysback' days previous_start = fromdate.replace(tzinfo=None) - \ timedelta(days=daysback + 1) previous_start = format_date(previous_start, format='iso8601', tzinfo=req.tz) add_link(req, 'prev', req.href.timeline(from_=previous_start, authors=authors, daysback=daysback), _("Previous Period")) if today - fromdate > timedelta(days=0): next_start = fromdate.replace(tzinfo=None) + \ timedelta(days=daysback + 1) next_start = format_date(to_datetime(next_start, req.tz), format='iso8601', tzinfo=req.tz) add_link(req, 'next', req.href.timeline(from_=next_start, authors=authors, daysback=daysback), _("Next Period")) prevnext_nav(req, _("Previous Period"), _("Next Period")) return 'timeline.html', data # ITemplateProvider methods def get_htdocs_dirs(self): return [] def get_templates_dirs(self): return [pkg_resources.resource_filename('trac.timeline', 'templates')] # IRequestFilter methods def pre_process_request(self, req, handler): return handler def post_process_request(self, req, template, data, metadata): if data: def pretty_dateinfo(date, format=None, dateonly=False): if not date: return '' if format == 'date': absolute = user_time(req, format_date, date) else: absolute = user_time(req, format_datetime, date) now = datetime_now(localtz) relative = pretty_timedelta(date, now) if not format: format = req.session.get('dateinfo', Chrome(self.env).default_dateinfo_format) if format == 'relative': if date > now: label = _("in %(relative)s", relative=relative) \ if not dateonly else relative title = _("on %(date)s at %(time)s", date=user_time(req, format_date, date), time=user_time(req, format_time, date)) return tag.span(label, title=title) else: label = _("%(relative)s ago", relative=relative) \ if not dateonly else relative title = _("See timeline at %(absolutetime)s", absolutetime=absolute) else: if dateonly: label = absolute elif req.lc_time == 'iso8601': label = _("at %(iso8601)s", iso8601=absolute) elif format == 'date': label = _("on %(date)s", date=absolute) else: label = _("on %(date)s at %(time)s", date=user_time(req, format_date, date), time=user_time(req, format_time, date)) if date > now: title = _("in %(relative)s", relative=relative) return tag.span(label, title=title) title = _("See timeline %(relativetime)s ago", relativetime=relative) return self.get_timeline_link(req, date, label, precision='second', title=title) def dateinfo(date): return pretty_dateinfo(date, format='relative', dateonly=True) data['pretty_dateinfo'] = pretty_dateinfo data['dateinfo'] = dateinfo return template, data, metadata # IWikiSyntaxProvider methods def get_wiki_syntax(self): return [] def get_link_resolvers(self): def link_resolver(formatter, ns, target, label): path, query, fragment = split_url_into_path_query_fragment(target) precision = None time = path.split("T", 1) if len(time) > 1: time = time[1].split("Z")[0] if len(time) >= 6: precision = 'seconds' elif len(time) >= 4: precision = 'minutes' elif len(time) >= 2: precision = 'hours' try: dt = parse_date(path, utc, locale='iso8601', hint='iso8601') return self.get_timeline_link(formatter.req, dt, label, precision, query, fragment) except TracError as e: return tag.a(label, title=to_unicode(e), class_='timeline missing') yield 'timeline', link_resolver # Public methods def get_timeline_link(self, req, date, label=None, precision='hours', query=None, fragment=None, title=None): iso_date = format_datetime(date, 'iso8601', req.tz) href = req.href.timeline(from_=iso_date, precision=precision) return tag.a(label or iso_date, class_='timeline', title=title or _("See timeline at %(absolutetime)s", absolutetime=iso_date), href=concat_path_query_fragment(href, query, fragment)) # Internal methods def _event_data(self, req, provider, event, lastvisit): """Compose the timeline event date from the event tuple and prepared provider methods""" if len(event) == 5: # with special provider kind, datetime, author, data, provider = event else: kind, datetime, author, data = event render = lambda field, context: \ provider.render_timeline_event(context, field, event) localized_datetime = to_datetime(datetime, tzinfo=req.tz) localized_date = truncate_datetime(localized_datetime) datetime_uid = to_utimestamp(localized_datetime) return {'kind': kind, 'author': author, 'date': localized_date, 'datetime': localized_datetime, 'datetime_uid': datetime_uid, 'render': render, 'unread': lastvisit and lastvisit < datetime_uid, 'event': event, 'data': data, 'provider': provider}
class Icons(Component): """Display icons in lined with text. The wiki markup `(|name|)`, or the equivalent `Icon` macro, shows a named icon that can be in line with text. During side-by-side wiki editing, the same wiki markup, or macro, can be used as a temporary search facility to find icons in the vast library. The number of icons presented to the wiki author can be limited to prevent exhaustive network traffic. This limit is defined in the `[wikiextras]` section in `trac.ini`. """ implements(ITemplateProvider, IWikiMacroProvider, IWikiSyntaxProvider) icon_limit = IntOption( 'wikiextras', 'icon_limit', 32, """To prevent exhaustive network traffic, limit the maximum number of icons generated by the macro `Icon`. Set to 0 for unlimited number of icons (this will produce exhaustive network traffic--you have been warned!)""") shadowless = BoolOption('wikiextras', 'shadowless_icons', 'false', 'Use shadowless icons.') def icon_location(self, size='S'): """ Returns `(prefix, abspath)` tuple based on `size` which is one of `small`, `medium` or `large` (or an abbreviation thereof.. 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. """ try: return FUGUE_ICONS[self.shadowless][size[0].upper()] except Exception: return FUGUE_ICONS[self.shadowless]['S'] def _render_icon(self, formatter, name, size): if not name: return size = size.upper()[0] if size else 'S' name = name.lower() if any(x in name for x in ['*', '?']): #noinspection PyArgumentList return ShowIcons(self.env)._render(formatter, 2, name, size, True, self.icon_limit) else: loc = self.icon_location(size) return tag.img(src=formatter.href.chrome(loc[0], '%s.png' % name), alt=name, style="vertical-align: text-bottom") # ITemplateProvider methods def get_htdocs_dirs(self): dirs = [] for data in FUGUE_ICONS.itervalues(): for d in data.itervalues(): dirs.append(tuple(d)) return dirs def get_templates_dirs(self): return [] # IWikiSyntaxProvider methods wiki_pat = r'!?\(\|([-*?._a-z0-9]+)(?:,\s*(\w*))?\|\)' wiki_re = re.compile(wiki_pat) #noinspection PyUnusedLocal def _format_icon(self, formatter, match, fullmatch=None): m = Icons.wiki_re.match(match) name, size = m.group(1, 2) return self._render_icon(formatter, name, size) def get_wiki_syntax(self): yield (Icons.wiki_pat, self._format_icon) def get_link_resolvers(self): return [] # IWikiMacroProvider methods def get_macros(self): yield 'Icon' #noinspection PyUnusedLocal def get_macro_description(self, name): return cleandoc("""Shows a named icon that can be in line with text. Syntax: {{{ [[Icon(name, size)]] }}} where * `name` is the name of the icon. When `name` contains a pattern character (`*` or `?`), a 2-column preview of matching icons is presented, which should mainly be used for finding and selecting an icon during wiki page editing in side-by-side mode (however, no more than %d icons are presented to prevent exhaustive network traffic.) * `size` is optionally one of `small`, `medium` or `large` or an abbreviation thereof (defaults `small`). Example: {{{ [[Icon(smiley)]] }}} Use `ShowIcons` for static presentation of available icons. Smileys like `:-)` are automatically rendered as icons. Use `ShowSmileys` to se all available smileys. Following wiki markup is equivalent to using this macro: {{{ (|name, size|) }}} """ % self.icon_limit) #noinspection PyUnusedLocal def expand_macro(self, formatter, name, content): # content = name, size if not content: return args = [a.strip() for a in content.split(',')] + [None, None] name, size = args[0], args[1] return self._render_icon(formatter, name, size)
class LoginModule(Component): """User authentication manager. This component implements user authentication based on HTTP authentication provided by the web-server, combined with cookies for communicating the login information across the whole site. This mechanism expects that the web-server is setup so that a request to the path '/login' requires authentication (such as Basic or Digest). The login name is then stored in the database and associated with a unique key that gets passed back to the user agent using the 'trac_auth' cookie. This cookie is used to identify the user in subsequent requests to non-protected resources. """ implements(IAuthenticator, INavigationContributor, IRequestHandler) is_valid_default_handler = False check_ip = BoolOption('trac', 'check_auth_ip', 'false', """Whether the IP address of the user should be checked for authentication.""") ignore_case = BoolOption('trac', 'ignore_auth_case', 'false', """Whether login names should be converted to lower case.""") auth_cookie_domain = Option('trac', 'auth_cookie_domain', '', """Auth cookie domain attribute. The auth cookie can be shared among multiple subdomains by setting the value to the domain. (//since 1.2//) """) 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 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'')""") # IAuthenticator methods def authenticate(self, req): authname = None if req.remote_user: authname = req.remote_user elif 'trac_auth' in req.incookie: authname = self._get_name_for_cookie(req, req.incookie['trac_auth']) if not authname: return None if self.ignore_case: authname = authname.lower() return authname # INavigationContributor methods def get_active_navigation_item(self, req): return 'login' def get_navigation_items(self, req): if req.authname and req.authname != 'anonymous': yield ('metanav', 'login', tag_("logged in as %(user)s", user=Chrome(self.env).authorinfo(req, req.authname))) yield ('metanav', 'logout', tag.form(tag.div(tag.button(_("Logout"), name='logout', type='submit')), action=req.href.logout(), method='post', id='logout', class_='trac-logout')) else: yield ('metanav', 'login', tag.a(_("Login"), href=req.href.login())) # IRequestHandler methods def match_request(self, req): return re.match('/(login|logout)/?$', req.path_info) def process_request(self, req): if req.path_info.startswith('/login'): self._do_login(req) elif req.path_info.startswith('/logout'): self._do_logout(req) self._redirect_back(req) # Internal methods def _do_login(self, req): """Log the remote user in. This function expects to be called when the remote user name is available. The user name is inserted into the `auth_cookie` table and a cookie identifying the user on subsequent requests is sent back to the client. If the Authenticator was created with `ignore_case` set to true, then the authentication name passed from the web server in req.remote_user will be converted to lower case before being used. This is to avoid problems on installations authenticating against Windows which is not case sensitive regarding user names and domain names """ if not req.remote_user: # TRANSLATOR: ... refer to the 'installation documentation'. (link) inst_doc = tag.a(_("installation documentation"), title=_("Configuring Authentication"), href=req.href.wiki('TracInstall') + "#ConfiguringAuthentication") raise TracError(tag_("Authentication information not available. " "Please refer to the %(inst_doc)s.", inst_doc=inst_doc)) remote_user = req.remote_user if self.ignore_case: remote_user = remote_user.lower() if req.authname not in ('anonymous', remote_user): raise TracError(_("Already logged in as %(user)s.", user=req.authname)) with self.env.db_transaction as db: # Delete cookies older than 10 days db("DELETE FROM auth_cookie WHERE time < %s", (int(time_now()) - 86400 * 10,)) # Insert a new cookie if we haven't already got one cookie = None trac_auth = req.incookie.get('trac_auth') if trac_auth is not None: name = self._cookie_to_name(req, trac_auth) cookie = trac_auth.value if name == remote_user else None if cookie is None: cookie = hex_entropy() db(""" INSERT INTO auth_cookie (cookie, name, ipnr, time) VALUES (%s, %s, %s, %s) """, (cookie, remote_user, req.remote_addr, int(time_now()))) req.authname = remote_user req.outcookie['trac_auth'] = cookie if self.auth_cookie_domain: req.outcookie['trac_auth']['domain'] = self.auth_cookie_domain req.outcookie['trac_auth']['path'] = self.auth_cookie_path \ or req.base_path or '/' if self.env.secure_cookies: req.outcookie['trac_auth']['secure'] = True req.outcookie['trac_auth']['httponly'] = True if self.auth_cookie_lifetime > 0: req.outcookie['trac_auth']['expires'] = self.auth_cookie_lifetime def _do_logout(self, req): """Log the user out. Simply deletes the corresponding record from the auth_cookie table. """ if req.method != 'POST': return if req.authname == 'anonymous': # Not logged in return if 'trac_auth' in req.incookie: self.env.db_transaction("DELETE FROM auth_cookie WHERE cookie=%s", (req.incookie['trac_auth'].value,)) else: self.env.db_transaction("DELETE FROM auth_cookie WHERE name=%s", (req.authname,)) self._expire_cookie(req) custom_redirect = self.config['metanav'].get('logout.redirect') if custom_redirect: if not re.match(r'https?:|/', custom_redirect): custom_redirect = req.href(custom_redirect) req.redirect(custom_redirect) def _expire_cookie(self, req): """Instruct the user agent to drop the auth cookie by setting the "expires" property to a date in the past. """ req.outcookie['trac_auth'] = '' if self.auth_cookie_domain: req.outcookie['trac_auth']['domain'] = self.auth_cookie_domain req.outcookie['trac_auth']['path'] = self.auth_cookie_path \ or req.base_path or '/' req.outcookie['trac_auth']['expires'] = -10000 if self.env.secure_cookies: req.outcookie['trac_auth']['secure'] = True req.outcookie['trac_auth']['httponly'] = True def _cookie_to_name(self, req, cookie): # This is separated from _get_name_for_cookie(), because the # latter is overridden in AccountManager. if self.check_ip: sql = "SELECT name FROM auth_cookie WHERE cookie=%s AND ipnr=%s" args = (cookie.value, req.remote_addr) else: sql = "SELECT name FROM auth_cookie WHERE cookie=%s" args = (cookie.value,) for name, in self.env.db_query(sql, args): return name def _get_name_for_cookie(self, req, cookie): name = self._cookie_to_name(req, cookie) if name is None: # The cookie is invalid (or has been purged from the # database), so tell the user agent to drop it as it is # invalid self._expire_cookie(req) return name def _redirect_back(self, req): """Redirect the user back to the URL she came from.""" referer = self._referer(req) if referer: if not referer.startswith(('http://', 'https://')): # Make URL absolute scheme, host = urlparse.urlparse(req.base_url)[:2] referer = urlparse.urlunparse((scheme, host, referer, None, None, None)) pos = req.base_url.find(':') base_scheme = req.base_url[:pos] base_noscheme = req.base_url[pos:] base_noscheme_norm = base_noscheme.rstrip('/') referer_noscheme = referer[referer.find(':'):] # only redirect to referer if it is from the same site if referer_noscheme == base_noscheme or \ referer_noscheme.startswith(base_noscheme_norm + '/'): # avoid redirect loops if referer_noscheme.rstrip('/') != \ base_noscheme_norm + req.path_info.rstrip('/'): req.redirect(base_scheme + referer_noscheme) req.redirect(req.abs_href()) def _referer(self, req): return req.args.get('referer') or req.get_header('Referer')
class LogModule(Component): implements(INavigationContributor, IPermissionRequestor, IRequestHandler, IWikiSyntaxProvider) default_log_limit = IntOption( 'revisionlog', 'default_log_limit', 100, """Default value for the limit argument in the TracRevisionLog. (''since 0.11'')""") graph_colors = ListOption( 'revisionlog', 'graph_colors', ['#cc0', '#0c0', '#0cc', '#00c', '#c0c', '#c00'], doc="""Comma-separated list of colors to use for the TracRevisionLog graph display. (''since 1.0'')""") # INavigationContributor methods def get_active_navigation_item(self, req): return 'browser' def get_navigation_items(self, req): return [] # IPermissionRequestor methods def get_permission_actions(self): return ['LOG_VIEW'] # IRequestHandler methods def match_request(self, req): match = re.match(r'/log(/.*)?$', req.path_info) if match: req.args['path'] = match.group(1) or '/' return True def process_request(self, req): req.perm.require('LOG_VIEW') mode = req.args.get('mode', 'stop_on_copy') path = req.args.get('path', '/') rev = req.args.get('rev') stop_rev = req.args.get('stop_rev') revs = req.args.get('revs') format = req.args.get('format') verbose = req.args.get('verbose') limit = int(req.args.get('limit') or self.default_log_limit) rm = RepositoryManager(self.env) reponame, repos, path = rm.get_repository_by_path(path) if not repos: raise ResourceNotFound( _("Repository '%(repo)s' not found", repo=reponame)) if reponame != repos.reponame: # Redirect alias qs = req.query_string req.redirect( req.href.log(repos.reponame or None, path) + ('?' + qs if qs else '')) normpath = repos.normalize_path(path) # if `revs` parameter is given, then we're restricted to the # corresponding revision ranges. # If not, then we're considering all revisions since `rev`, # on that path, in which case `revranges` will be None. revranges = None if revs: try: revranges = Ranges(revs) rev = revranges.b except ValueError: pass rev = unicode(repos.normalize_rev(rev)) display_rev = repos.display_rev # The `history()` method depends on the mode: # * for ''stop on copy'' and ''follow copies'', it's `Node.history()` # unless explicit ranges have been specified # * for ''show only add, delete'' we're using # `Repository.get_path_history()` cset_resource = repos.resource.child('changeset') show_graph = False if mode == 'path_history': def history(): for h in repos.get_path_history(path, rev): if 'CHANGESET_VIEW' in req.perm(cset_resource(id=h[1])): yield h elif revranges: def history(): prevpath = path expected_next_item = None ranges = list(revranges.pairs) ranges.reverse() for (a, b) in ranges: a = repos.normalize_rev(a) b = repos.normalize_rev(b) while not repos.rev_older_than(b, a) and b != a: node = get_existing_node(req, repos, prevpath, b) node_history = list(node.get_history(2)) p, rev, chg = node_history[0] if repos.rev_older_than(rev, a): break # simply skip, no separator if 'CHANGESET_VIEW' in req.perm(cset_resource(id=rev)): if expected_next_item: # check whether we're continuing previous range np, nrev, nchg = expected_next_item if rev != nrev: # no, we need a separator yield (np, nrev, None) yield node_history[0] prevpath = node_history[-1][0] # follow copy b = repos.previous_rev(rev) if len(node_history) > 1: expected_next_item = node_history[-1] else: expected_next_item = None if expected_next_item: yield (expected_next_item[0], expected_next_item[1], None) else: show_graph = path == '/' and not verbose \ and not repos.has_linear_changesets def history(): node = get_existing_node(req, repos, path, rev) for h in node.get_history(): if 'CHANGESET_VIEW' in req.perm(cset_resource(id=h[1])): yield h # -- retrieve history, asking for limit+1 results info = [] depth = 1 previous_path = normpath count = 0 for old_path, old_rev, old_chg in history(): if stop_rev and repos.rev_older_than(old_rev, stop_rev): break old_path = repos.normalize_path(old_path) item = { 'path': old_path, 'rev': old_rev, 'existing_rev': old_rev, 'change': old_chg, 'depth': depth, } if old_chg == Changeset.DELETE: item['existing_rev'] = repos.previous_rev(old_rev, old_path) if not (mode == 'path_history' and old_chg == Changeset.EDIT): info.append(item) if old_path and old_path != previous_path and \ not (mode == 'path_history' and old_path == normpath): depth += 1 item['depth'] = depth item['copyfrom_path'] = old_path if mode == 'stop_on_copy': break elif mode == 'path_history': depth -= 1 if old_chg is None: # separator entry stop_limit = limit else: count += 1 stop_limit = limit + 1 if count >= stop_limit: break previous_path = old_path if info == []: node = get_existing_node(req, repos, path, rev) if repos.rev_older_than(stop_rev, node.created_rev): # FIXME: we should send a 404 error here raise TracError( _( "The file or directory '%(path)s' doesn't " "exist at revision %(rev)s or at any previous revision.", path=path, rev=display_rev(rev)), _('Nonexistent path')) # Generate graph data graph = {} if show_graph: threads, vertices, columns = \ make_log_graph(repos, (item['rev'] for item in info)) graph.update(threads=threads, vertices=vertices, columns=columns, colors=self.graph_colors, line_width=0.04, dot_radius=0.1) add_script(req, 'common/js/excanvas.js', ie_if='IE') add_script(req, 'common/js/log_graph.js') add_script_data(req, graph=graph) def make_log_href(path, **args): link_rev = rev if rev == str(repos.youngest_rev): link_rev = None params = {'rev': link_rev, 'mode': mode, 'limit': limit} params.update(args) if verbose: params['verbose'] = verbose return req.href.log(repos.reponame or None, path, **params) if format in ('rss', 'changelog'): info = [i for i in info if i['change']] # drop separators if info and count > limit: del info[-1] elif info and count >= limit: # stop_limit reached, there _might_ be some more next_rev = info[-1]['rev'] next_path = info[-1]['path'] next_revranges = None if revranges: next_revranges = str(revranges.truncate(next_rev)) if next_revranges or not revranges: older_revisions_href = make_log_href(next_path, rev=next_rev, revs=next_revranges) add_link( req, 'next', older_revisions_href, _('Revision Log (restarting at %(path)s, rev. %(rev)s)', path=next_path, rev=display_rev(next_rev))) # only show fully 'limit' results, use `change == None` as a marker info[-1]['change'] = None revisions = [i['rev'] for i in info] changes = get_changes(repos, revisions, self.log) extra_changes = {} if format == 'changelog': for rev in revisions: changeset = changes[rev] cs = {} cs['message'] = wrap(changeset.message, 70, initial_indent='\t', subsequent_indent='\t') files = [] actions = [] for cpath, kind, chg, bpath, brev in changeset.get_changes(): files.append(bpath if chg == Changeset.DELETE else cpath) actions.append(chg) cs['files'] = files cs['actions'] = actions extra_changes[rev] = cs data = { 'context': web_context(req, 'source', path, parent=repos.resource), 'reponame': repos.reponame or None, 'repos': repos, 'path': path, 'rev': rev, 'stop_rev': stop_rev, 'display_rev': display_rev, 'revranges': revranges, 'mode': mode, 'verbose': verbose, 'limit': limit, 'items': info, 'changes': changes, 'extra_changes': extra_changes, 'graph': graph, 'wiki_format_messages': self.config['changeset'].getbool('wiki_format_messages') } if format == 'changelog': return 'revisionlog.txt', data, 'text/plain' elif format == 'rss': data['email_map'] = Chrome(self.env).get_email_map() data['context'] = web_context(req, 'source', path, parent=repos.resource, absurls=True) return 'revisionlog.rss', data, 'application/rss+xml' item_ranges = [] range = [] for item in info: if item['change'] is None: # separator if range: # start new range range.append(item) item_ranges.append(range) range = [] else: range.append(item) if range: item_ranges.append(range) data['item_ranges'] = item_ranges add_stylesheet(req, 'common/css/diff.css') add_stylesheet(req, 'common/css/browser.css') path_links = get_path_links(req.href, repos.reponame, path, rev) if path_links: data['path_links'] = path_links if path != '/': add_link(req, 'up', path_links[-2]['href'], _('Parent directory')) rss_href = make_log_href(path, format='rss', revs=revs, stop_rev=stop_rev) add_link(req, 'alternate', auth_link(req, rss_href), _('RSS Feed'), 'application/rss+xml', 'rss') changelog_href = make_log_href(path, format='changelog', revs=revs, stop_rev=stop_rev) add_link(req, 'alternate', changelog_href, _('ChangeLog'), 'text/plain') add_ctxtnav(req, _('View Latest Revision'), href=req.href.browser(repos.reponame or None, path)) if 'next' in req.chrome['links']: next = req.chrome['links']['next'][0] add_ctxtnav( req, tag.span(tag.a(_('Older Revisions'), href=next['href']), Markup(' →'))) return 'revisionlog.html', data, None # IWikiSyntaxProvider methods REV_RANGE = r"(?:%s|%s)" % (Ranges.RE_STR, ChangesetModule.CHANGESET_ID) # int rev ranges or any kind of rev def get_wiki_syntax(self): yield ( # [...] form, starts with optional intertrac: [T... or [trac ... r"!?\[(?P<it_log>%s\s*)" % WikiParser.INTERTRAC_SCHEME + # <from>:<to> + optional path restriction r"(?P<log_revs>%s)(?P<log_path>[/?][^\]]*)?\]" % self.REV_RANGE, lambda x, y, z: self._format_link(x, 'log1', y[1:-1], y, z)) yield ( # r<from>:<to> form + optional path restriction (no intertrac) r"(?:\b|!)r%s\b(?:/[a-zA-Z0-9_/+-]+)?" % Ranges.RE_STR, lambda x, y, z: self._format_link(x, 'log2', '@' + y[1:], y)) def get_link_resolvers(self): yield ('log', self._format_link) def _format_link(self, formatter, ns, match, label, fullmatch=None): if ns == 'log1': groups = fullmatch.groupdict() it_log = groups.get('it_log') revs = groups.get('log_revs') path = groups.get('log_path') or '/' target = '%s%s@%s' % (it_log, path, revs) # prepending it_log is needed, as the helper expects it there intertrac = formatter.shorthand_intertrac_helper( 'log', target, label, fullmatch) if intertrac: return intertrac path, query, fragment = formatter.split_link(path) else: assert ns in ('log', 'log2') if ns == 'log': match, query, fragment = formatter.split_link(match) else: query = fragment = '' match = ''.join(reversed(match.split('/', 1))) path = match revs = '' if self.LOG_LINK_RE.match(match): indexes = [sep in match and match.index(sep) for sep in ':@'] idx = min([i for i in indexes if i is not False]) path, revs = match[:idx], match[idx + 1:] rm = RepositoryManager(self.env) try: reponame, repos, path = rm.get_repository_by_path(path) if not reponame: reponame = rm.get_default_repository(formatter.context) if reponame is not None: repos = rm.get_repository(reponame) if repos: revranges = None if any(c for c in ':-,' if c in revs): revranges = self._normalize_ranges(repos, path, revs) revs = None if 'LOG_VIEW' in formatter.perm: if revranges: href = formatter.href.log(repos.reponame or None, path or '/', revs=str(revranges)) else: try: rev = repos.normalize_rev(revs) except NoSuchChangeset: rev = None href = formatter.href.log(repos.reponame or None, path or '/', rev=rev) if query and (revranges or revs): query = '&' + query[1:] return tag.a(label, class_='source', href=href + query + fragment) errmsg = _("No permission to view change log") elif reponame: errmsg = _("Repository '%(repo)s' not found", repo=reponame) else: errmsg = _("No default repository defined") except TracError, e: errmsg = to_unicode(e) return tag.a(label, class_='missing source', title=errmsg)
class QueuesModule(Component): implements(IRequestHandler, ITemplateProvider, IRequestFilter) reports = ListOption('queues', 'reports', default=[], doc="List of report numbers to treat as queues") pad_length = IntOption( 'queues', 'pad_length', default=2, doc="Max length of position fields to prefix with 0s") max_position = IntOption( 'queues', 'max_position', default=99, doc="Max position value (default is 99); set to 0 for no maximum") # ITemplateProvider methods def get_htdocs_dirs(self): from pkg_resources import resource_filename return [('queues', resource_filename(__name__, 'htdocs'))] def get_templates_dirs(self): from pkg_resources import resource_filename return [resource_filename(__name__, 'templates')] # IRequestFilter methods def pre_process_request(self, req, handler): return handler def post_process_request(self, req, template, data, content_type): if self._valid_request(req): add_stylesheet(req, 'queues/queues.css') try: Chrome(self.env).add_jquery_ui(req) except AttributeError: add_script(req, 'queues/jquery-ui-1.8.16.custom.min.js') add_script(req, '/queues/queues.js') return template, data, content_type # IRequestHandler methods def match_request(self, req): return req.path_info.startswith('/queues/') def process_request(self, req): data = { 'groups': self._get_groups(), 'pad_length': self.pad_length, 'max_position': self.max_position } return 'queues.html', {'data': data}, 'text/javascript' # private methods def _valid_request(self, req): """Checks permissions and that report is a queue report.""" if req.perm.has_permission('TICKET_MODIFY') and \ 'action=' not in req.query_string and \ self._get_report(req): return True return False def _get_report(self, req): """Returns the report number as a string if the request is of a queue report listed in the queues config. For example, only urls of reports 11 and 12 would return True for this config but any other report url would return False: [queues] reports: 11, 12 """ report_re = re.compile(r"/report/(?P<num>[1-9][0-9]*)") match = report_re.search(req.path_info) if match: report = match.groupdict()['num'] if report in self.reports: return report return None def _get_groups(self): """Extract from config a mapping of group names to behaviors. A sample config file: [queues] group.triage = clear group.verifying = ignore The group names should exactly match the group names in the reports. For the above config, a dict is returned with group name as keys and the behavior string as the value.""" groups = {} opts = dict(self.env.config.options('queues')) for key, val in opts.items(): if not key.startswith('group.'): continue groups[key[6:]] = val return groups
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.') first_day = IntOption('datefield', 'first_day', default=0, doc='First day of the week. 0 == Sunday.') date_sep = Option('datefield', 'separator', default='/', doc='The separator character to use for dates.') show_week = BoolOption('datefield', 'weeknumbers', default='false', doc='Show ISO8601 week number in calendar?') show_panel = BoolOption('datefield', 'panel', default='false', doc='Show button panel at bottom? (Today, Done)') change_month = BoolOption('datefield', 'change_month', default='false', doc='Show a month dropdown in datepicker?') change_year = BoolOption('datefield', 'change_year', default='false', doc='Show a year dropdown in datepicker?') num_months = IntOption('datefield', 'months', default='1', doc='Number of months visible in datepicker') match_req = ListOption('datefield', 'match_request', default='', doc='Additional request paths to match (use input class="datepick")') use_milestone = BoolOption('datefield', 'milestone', default='false', doc="""Use datepicker for milestone due/completed fields? If you turn this on, you must use MM/DD/YYYY for the date format. Set format to mdy and separator to / (default=Off)""") implements(IRequestFilter, IRequestHandler, ITemplateProvider, \ ITicketManipulator, ITemplateStreamFilter) # IRequestHandler methods def match_request(self, req): return req.path_info.startswith('/datefield') def process_request(self, req): # Use get to handle default format format = { 'dmy': 'dd%smm%syy', 'mdy': 'mm%sdd%syy', 'ymd': 'yy%smm%sdd' }.get(self.date_format, 'dd%smm%syy')%(self.date_sep, self.date_sep) data = {} data['calendar'] = req.href.chrome('common', 'ics.png') data['format'] = format data['first_day'] = self.first_day data['show_week'] = self.show_week data['show_panel'] = self.show_panel data['change_month'] = self.change_month data['change_year'] = self.change_year data['num_months'] = self.num_months return 'datefield.html', {'data': data}, 'text/javascript' # ITemplateStreamFilter methods def filter_stream(self, req, method, filename, stream, data): def attr_callback(name, event): attrs = event[1][1] return ' '.join(filter(None, (attrs.get('class'), 'datepick'))) if filename == 'ticket.html': for field in list(self._date_fields()): stream = stream | Transformer( '//input[@name="field_' + field + '"]' ).attr('class', attr_callback) elif self.use_milestone and filename in ('milestone_edit.html', 'admin_milestones.html'): for field in ('duedate', 'completeddate'): stream = stream | Transformer( '//input[@name="' + field + '"]' ).attr('class', attr_callback) return stream # IRequestFilter methods def pre_process_request(self, req, handler): return handler def post_process_request(self, req, template, data, content_type): mine = ['/newticket', '/ticket', '/simpleticket'] if self.use_milestone: mine.append('/milestone') mine.append('/admin/ticket/milestones') match = False for target in mine + self.match_req: if req.path_info.startswith(target): match = True break if match: add_script(req, 'datefield/js/jquery-ui-1.6.custom.min.js') # virtual script add_script(req, '/datefield/datefield.js') add_stylesheet(req, 'datefield/css/ui.datepicker.css') add_stylesheet(req, 'datefield/css/ui.all.css') return template, data, 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] or u'').strip() if not val and self.config['ticket-custom'].getbool(field+'.date_empty', default=False): continue if self.date_sep and len(self.date_sep.strip()) > 0: if len(val.split(self.date_sep)) != 3: raise Exception # Token exception to force failure else: if re.match('.*[^\d].*', val.strip()): raise Exception 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.warn('DateFieldModule: Got an exception, assuming it is a validation failure.\n'+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') and value == "true": yield key.split('.', 1)[0]
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 acct_mgr.* = enabled }}} The first option tells Trac to disable the [wiki:TracReports report module]. The second option instructs Trac to enable all components in the `acct_mgr` 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. Non-absolute paths are relative to the Environment `conf` directory. """) 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. """) 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 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. """) 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 = 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=[]): """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 = os.path.normpath(os.path.normcase(path)) self.log = None self.config = None if create: self.create(options) 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())) 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. :since 1.3.1: deprecated and will be removed in 1.5.1. Use system_info property instead. """ return self.system_info # 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, 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(): 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(Environment, self).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: tag = read_file(os.path.join(self.path, 'VERSION')).splitlines()[0] if tag != _VERSION: raise Exception( _("Unknown Trac environment type '%(type)s'", type=tag)) except Exception as e: raise TracError( _("No Trac environment found at %(path)s\n" "%(e)s", path=self.path, e=e)) @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=[]): """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 http://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 DatabaseManager(self).init_db() @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 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.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).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 a 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: with self.component_guard(participant, reraise=True): if participant.environment_needs_upgrade(): self.log.warning( "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: 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("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 TimelineModule(Component): implements(INavigationContributor, IPermissionRequestor, IRequestHandler, IRequestFilter, ITemplateProvider, IWikiSyntaxProvider) event_providers = ExtensionPoint(ITimelineEventProvider) default_daysback = IntOption( 'timeline', 'default_daysback', 30, """Default number of days displayed in the Timeline, in days. (''since 0.9.'')""") max_daysback = IntOption( 'timeline', 'max_daysback', 90, """Maximum number of days (-1 for unlimited) displayable in the Timeline. (''since 0.11'')""") abbreviated_messages = BoolOption( 'timeline', 'abbreviated_messages', True, """Whether wiki-formatted event messages should be truncated or not. This only affects the default rendering, and can be overriden by specific event providers, see their own documentation. (''Since 0.11'')""") _authors_pattern = re.compile(r'(-)?(?:"([^"]*)"|\'([^\']*)\'|([^\s]+))') # INavigationContributor methods def get_active_navigation_item(self, req): return 'timeline' def get_navigation_items(self, req): if 'TIMELINE_VIEW' in req.perm: yield ('mainnav', 'timeline', tag.a(_('Timeline'), href=req.href.timeline(), accesskey=2)) # IPermissionRequestor methods def get_permission_actions(self): return ['TIMELINE_VIEW'] # IRequestHandler methods def match_request(self, req): return req.path_info == '/timeline' def process_request(self, req): req.perm.assert_permission('TIMELINE_VIEW') format = req.args.get('format') maxrows = int(req.args.get('max', format == 'rss' and 50 or 0)) # Parse the from date and adjust the timestamp to the last second of # the day fromdate = today = datetime.now(req.tz) precisedate = precision = None if 'from' in req.args: # Acquire from date only from non-blank input reqfromdate = req.args['from'].strip() if reqfromdate: precisedate = parse_date(reqfromdate, req.tz) fromdate = precisedate precision = req.args.get('precision', '') if precision.startswith('second'): precision = timedelta(seconds=1) elif precision.startswith('minute'): precision = timedelta(minutes=1) elif precision.startswith('hour'): precision = timedelta(hours=1) else: precision = None fromdate = fromdate.replace(hour=23, minute=59, second=59, microsecond=999999) daysback = as_int(req.args.get('daysback'), format == 'rss' and 90 or None) if daysback is None: daysback = as_int(req.session.get('timeline.daysback'), None) if daysback is None: daysback = self.default_daysback daysback = max(0, daysback) if self.max_daysback >= 0: daysback = min(self.max_daysback, daysback) authors = req.args.get('authors') if authors is None and format != 'rss': authors = req.session.get('timeline.authors') authors = (authors or '').strip() data = { 'fromdate': fromdate, 'daysback': daysback, 'authors': authors, 'today': format_date(today, tzinfo=req.tz), 'yesterday': format_date(today - timedelta(days=1), tzinfo=req.tz), 'precisedate': precisedate, 'precision': precision, 'events': [], 'filters': [], 'abbreviated_messages': self.abbreviated_messages } available_filters = [] for event_provider in self.event_providers: available_filters += event_provider.get_timeline_filters(req) or [] # check the request or session for enabled filters, or use default filters = [f[0] for f in available_filters if f[0] in req.args] if not filters and format != 'rss': filters = [ f[0] for f in available_filters if req.session.get('timeline.filter.' + f[0]) == '1' ] if not filters: filters = [f[0] for f in available_filters if len(f) == 2 or f[2]] # save the results of submitting the timeline form to the session if 'update' in req.args: for filter in available_filters: key = 'timeline.filter.%s' % filter[0] if filter[0] in req.args: req.session[key] = '1' elif key in req.session: del req.session[key] stop = fromdate start = stop - timedelta(days=daysback + 1) # create author include and exclude sets include = set() exclude = set() for match in self._authors_pattern.finditer(authors): name = (match.group(2) or match.group(3) or match.group(4)).lower() if match.group(1): exclude.add(name) else: include.add(name) # gather all events for the given period of time events = [] for provider in self.event_providers: try: for event in provider.get_timeline_events( req, start, stop, filters) or []: # Check for 0.10 events author = (event[len(event) < 6 and 2 or 4] or '').lower() if (not include or author in include) \ and not author in exclude: events.append(self._event_data(provider, event)) except Exception, e: # cope with a failure of that provider self._provider_failure(e, req, provider, filters, [f[0] for f in available_filters]) # prepare sorted global list events = sorted(events, key=lambda e: e['date'], reverse=True) if maxrows: events = events[:maxrows] data['events'] = events if format == 'rss': data['email_map'] = Chrome(self.env).get_email_map() rss_context = Context.from_request(req, absurls=True) rss_context.set_hints(wiki_flavor='html', shorten_lines=False) data['context'] = rss_context return 'timeline.rss', data, 'application/rss+xml' else: req.session['timeline.daysback'] = daysback req.session['timeline.authors'] = authors html_context = Context.from_request(req) html_context.set_hints(wiki_flavor='oneliner', shorten_lines=self.abbreviated_messages) data['context'] = html_context add_stylesheet(req, 'common/css/timeline.css') rss_href = req.href.timeline([(f, 'on') for f in filters], daysback=90, max=50, authors=authors, format='rss') add_link(req, 'alternate', rss_href, _('RSS Feed'), 'application/rss+xml', 'rss') for filter_ in available_filters: data['filters'].append({ 'name': filter_[0], 'label': filter_[1], 'enabled': filter_[0] in filters }) # Navigation to the previous/next period of 'daysback' days previous_start = format_date(fromdate - timedelta(days=daysback + 1), format='%Y-%m-%d', tzinfo=req.tz) add_link( req, 'prev', req.href.timeline(from_=previous_start, authors=authors, daysback=daysback), _('Previous Period')) if today - fromdate > timedelta(days=0): next_start = format_date(fromdate + timedelta(days=daysback + 1), format='%Y-%m-%d', tzinfo=req.tz) add_link( req, 'next', req.href.timeline(from_=next_start, authors=authors, daysback=daysback), _('Next Period')) prevnext_nav(req, _('Previous Period'), _('Next Period')) return 'timeline.html', data, None
class NotificationRestAPI(Component): """ Component implements simple REST API for messages """ implements(IJSONDataPublisherInterface, IRequestHandler) juggernaut_host = Option('multiproject-messages', 'juggernaut_host', None, 'Juggernaut server host name or ip (or proxy on front them). Defaults to current domain') juggernaut_port = IntOption('multiproject-messages', 'juggernaut_port', None, 'Juggernaut server port. Defaults to current port') juggernaut_secure = BoolOption('multiproject-messages', 'juggernaut_secure', False, 'Secure connection or not') juggernaut_transports = ListOption('multiproject-messages', 'juggernaut_transports', [], 'Set/limit the used tranportation methods. Defaults to automatic selection. ' 'Valid values: websocket, flashsocket, htmlfile, xhr-polling, jsonp-polling') redis_host = Option('multiproject-messages', 'redis_host', 'localhost', 'Redis server host name or ip') redis_port = IntOption('multiproject-messages', 'redis_port', 6379, 'Redis server port') handlers = { 'list':'_list_notifications', } # IJSONDataPublisherInterface def publish_json_data(self, req): return {'conf': { 'juggernaut_host': self.juggernaut_host, 'juggernaut_port': self.juggernaut_port, 'juggernaut_secure': self.juggernaut_secure, # FIXME: Using self.juggernaut_transports directly returns all items in a string 'juggernaut_transports': self.config.getlist('multiproject-messages', 'juggernaut_transports', []), 'redis_port': self.redis_port, 'redis_host': self.redis_host, }} # IRequestHandler def match_request(self, req): return req.path_info.startswith('/api/notification') def process_request(self, req): # Select handler based on last part of the request path action = req.path_info.rsplit('/', 1)[1] if action in self.handlers.keys(): return getattr(self, self.handlers[action])(req) # Single notification if 'id' in req.args: return self._get_notification(req) return send_json(req, {'result': 'Missing action'}, status=404) def _list_notifications(self, req): """ Returns the list of missed notification and optionally reset them """ chname = None initiator = req.args.get('initiator', '') reset = req.args.get('reset', 'false').lower() in ('yes', 'true', 'on', '1') ntype = req.args.get('type', '') # Check permissions if req.authname == 'anonymous': return send_json(req, {'result': 'Permission denied'}, status=403) userstore = get_userstore() user = userstore.getUser(req.authname) ns = self.env[NotificationSystem] if not ns: return send_json(req, []) # Fetch notifications sent to user chname = ns.generate_channel_name(user_id=user.id) # Get notifications try: notifications = ns.get_notifications(chname) except TracError, e: self.log.error('Failed to retrieve notifications') return send_json(req, {'result': e.message}, status=500) # Internal filtering and notification reset function def filter_and_reset(notification): if initiator and notification.get('initiator', '') != initiator: return False if ntype and notification.get('type', '') != ntype: return False if reset: ns.reset_notification(chname, notification) return True # Filter by sender if set notifications = filter(filter_and_reset, notifications) # If user want's to reset status, send empty notification so the listening clients can update their state if reset: ns.send_notification([chname], {'type': ntype}, store=False) return send_json(req, notifications)
class DiscussionCore(Component): """ The core module implements a message board, including wiki links to discussions, topics and messages. """ implements(INavigationContributor, IRequestHandler, ITemplateProvider, IPermissionRequestor) topics_per_page = IntOption( 'discussion', 'topics_per_page', 20, 'The number of topics to display on each page inside a forum') title = Option('discussion', 'title', 'Discussion', 'Main navigation bar button title.') # IPermissionRequestor methods def get_permission_actions(self): return [ 'DISCUSSION_VIEW', 'DISCUSSION_APPEND', 'DISCUSSION_MODERATE', 'DISCUSSION_ADMIN' ] # ITemplateProvider methods def get_htdocs_dirs(self): from pkg_resources import resource_filename return [('discussion', resource_filename(__name__, 'htdocs'))] def get_templates_dirs(self): from pkg_resources import resource_filename return [resource_filename(__name__, 'templates')] # INavigationContributor methods def get_active_navigation_item(self, req): return 'discussion' def get_navigation_items(self, req): if req.perm.has_permission('DISCUSSION_VIEW'): yield 'mainnav', 'discussion', html.a(self.title, href=req.href.discussion()) # IRequestHandler methods def match_request(self, req): if req.path_info == '/discussion/redirect': # Proces redirection request. self.log.debug(req.path_info) self.log.debug(req.args.get('href')) req.redirect(req.href(req.args.get('href'))) else: # Prepare regular requests. match = re.match( r'''/discussion(?:/?$|/(\d+)(?:/?$|/(\d+))(?:/?$|/(\d+)))$''', req.path_info) if match: forum = match.group(1) topic = match.group(2) message = match.group(3) if forum: req.args['forum'] = forum if topic: req.args['topic'] = topic if message: req.args['message'] = message return match def process_request(self, req): # Prepare request object req.args['component'] = 'core' # Return page content api = DiscussionApi(self, req) return api.render_discussion(req)
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: 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 '' 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 AutoWikify(Component): """ Automatically create links for all known Wiki pages, even those that are not in CamelCase. """ implements(IWikiSyntaxProvider, IWikiChangeListener) minimum_length = IntOption( 'autowikify', 'minimum_length', 3, """Minimum length of wiki page name to perform auto-wikification on.""" ) explicitly_wikify = ListOption( 'autowikify', 'explicitly_wikify', doc="""List of Wiki pages to always Wikify, regardless of size.""") exclude = ListOption( 'autowikify', 'exclude', doc="""List of Wiki pages to exclude from auto-wikification.""") pages = set() pages_re = None def __init__(self): self._all_pages() self._update() # IWikiChangeListener methods def wiki_page_added(self, page): self.pages.add(page.name) self._update() def wiki_page_changed(self, page, version, t, comment, author, ipnr): pass def wiki_page_deleted(self, page): if page.name in self.pages: self.pages.remove(page.name) else: self._all_pages() self._update() def wiki_page_version_deleted(self, page): pass # IWikiSyntaxProvider methods def get_wiki_syntax(self): yield (self.pages_re, self._page_formatter) def get_link_resolvers(self): return [] # Internal methods def _all_pages(self): self.pages = set(WikiSystem(self.env).get_pages()) def _update(self): explicitly_wikified = set([ p.strip() for p in (self.env.config.get('autowikify', 'explicitly_wikify') or '').split(',') if p.strip() ]) pages = set([p for p in self.pages if len(p) >= self.minimum_length]) pages.update(self.explicitly_wikify) pages.difference_update(self.exclude) pattern = r'\b(?P<autowiki>' + '|'.join( [re.escape(page) for page in pages]) + r')\b' self.pages_re = pattern WikiSystem(self.env)._compiled_rules = None def _page_formatter(self, f, n, match): page = match.group('autowiki') return Markup('<a href="%s" class="wiki">%s</a>' % (self.env.href.wiki(page), escape(page)))
class WikiModule(Component): implements(IContentConverter, INavigationContributor, IPermissionRequestor, IRequestHandler, ITimelineEventProvider, ISearchSource, ITemplateProvider) page_manipulators = ExtensionPoint(IWikiPageManipulator) realm = WikiSystem.realm max_size = IntOption('wiki', 'max_size', 262144, """Maximum allowed wiki page size in characters.""") default_edit_area_height = IntOption('wiki', 'default_edit_area_height', 20, """Default height of the textarea on the wiki edit page. (//Since 1.1.5//)""") START_PAGE = property(lambda self: WikiSystem.START_PAGE) TITLE_INDEX_PAGE = property(lambda self: WikiSystem.TITLE_INDEX_PAGE) PAGE_TEMPLATES_PREFIX = 'PageTemplates/' DEFAULT_PAGE_TEMPLATE = 'DefaultPage' # IContentConverter methods def get_supported_conversions(self): yield ('txt', _("Plain Text"), 'txt', 'text/x-trac-wiki', 'text/plain', 9) def convert_content(self, req, mimetype, content, key): return content, 'text/plain;charset=utf-8' # INavigationContributor methods def get_active_navigation_item(self, req): return 'wiki' def get_navigation_items(self, req): if 'WIKI_VIEW' in req.perm(self.realm, self.START_PAGE): yield ('mainnav', 'wiki', tag.a(_("Wiki"), href=req.href.wiki(), accesskey=accesskey(req, 1))) if 'WIKI_VIEW' in req.perm(self.realm, 'TracGuide'): yield ('metanav', 'help', tag.a(_("Help/Guide"), href=req.href.wiki('TracGuide'), accesskey=accesskey(req, 6))) # IPermissionRequestor methods def get_permission_actions(self): actions = ['WIKI_CREATE', 'WIKI_DELETE', 'WIKI_MODIFY', 'WIKI_RENAME', 'WIKI_VIEW'] return actions + [('WIKI_ADMIN', actions)] # IRequestHandler methods def match_request(self, req): match = re.match(r'/wiki(?:/(.+))?$', req.path_info) if match: if match.group(1): req.args['page'] = match.group(1) return 1 def process_request(self, req): action = req.args.get('action', 'view') pagename = req.args.get('page', self.START_PAGE) version = None if req.args.get('version'): # Allow version to be empty version = req.args.getint('version') old_version = req.args.getint('old_version') if pagename.startswith('/') or pagename.endswith('/') or \ '//' in pagename: pagename = re.sub(r'/{2,}', '/', pagename.strip('/')) req.redirect(req.href.wiki(pagename)) if not validate_page_name(pagename): raise TracError(_("Invalid Wiki page name '%(name)s'", name=pagename)) page = WikiPage(self.env, pagename) versioned_page = WikiPage(self.env, pagename, version) req.perm(versioned_page.resource).require('WIKI_VIEW') if version and versioned_page.version != version: raise ResourceNotFound( _('No version "%(num)s" for Wiki page "%(name)s"', num=version, name=page.name)) add_stylesheet(req, 'common/css/wiki.css') if req.method == 'POST': if action == 'edit': if 'cancel' in req.args: req.redirect(req.href.wiki(page.name)) has_collision = version != page.version for a in ('preview', 'diff', 'merge'): if a in req.args: action = a break versioned_page.text = req.args.get('text') valid = self._validate(req, versioned_page) if action == 'edit' and not has_collision and valid: return self._do_save(req, versioned_page) else: return self._render_editor(req, page, action, has_collision) elif action == 'edit_comment': self._do_edit_comment(req, versioned_page) elif action == 'delete': self._do_delete(req, versioned_page) elif action == 'rename': return self._do_rename(req, page) elif action == 'diff': style, options, diff_data = get_diff_options(req) contextall = diff_data['options']['contextall'] req.redirect(req.href.wiki(versioned_page.name, action='diff', old_version=old_version, version=version, contextall=contextall or None)) else: raise HTTPBadRequest(_("Invalid request arguments.")) elif action == 'delete': return self._render_confirm_delete(req, page) elif action == 'rename': return self._render_confirm_rename(req, page) elif action == 'edit': return self._render_editor(req, page) elif action == 'edit_comment': return self._render_edit_comment(req, versioned_page) elif action == 'diff': return self._render_diff(req, versioned_page) elif action == 'history': return self._render_history(req, versioned_page) else: format = req.args.get('format') if format: Mimeview(self.env).send_converted(req, 'text/x-trac-wiki', versioned_page.text, format, versioned_page.name) return self._render_view(req, versioned_page) # ITemplateProvider methods def get_htdocs_dirs(self): return [] def get_templates_dirs(self): return [pkg_resources.resource_filename('trac.wiki', 'templates')] # Internal methods def _validate(self, req, page): valid = True # Validate page size if len(req.args.get('text', '')) > self.max_size: add_warning(req, _("The wiki page is too long (must be less " "than %(num)s characters)", num=self.max_size)) valid = False # Give the manipulators a pass at post-processing the page for manipulator in self.page_manipulators: for field, message in manipulator.validate_wiki_page(req, page): valid = False if field: add_warning(req, tag_("The Wiki page field %(field)s" " is invalid: %(message)s", field=tag.strong(field), message=message)) else: add_warning(req, tag_("Invalid Wiki page: %(message)s", message=message)) return valid def _page_data(self, req, page, action=''): title = get_resource_summary(self.env, page.resource) if action: title += ' (%s)' % action return {'page': page, 'action': action, 'title': title} def _prepare_diff(self, req, page, old_text, new_text, old_version, new_version): diff_style, diff_options, diff_data = get_diff_options(req) diff_context = 3 for option in diff_options: if option.startswith('-U'): diff_context = int(option[2:]) break if diff_context < 0: diff_context = None diffs = diff_blocks(old_text, new_text, context=diff_context, ignore_blank_lines='-B' in diff_options, ignore_case='-i' in diff_options, ignore_space_changes='-b' in diff_options) def version_info(v, last=0): return {'path': get_resource_name(self.env, page.resource), # TRANSLATOR: wiki page 'rev': v or _("currently edited"), 'shortrev': v or last + 1, 'href': req.href.wiki(page.name, version=v) if v else None} changes = [{'diffs': diffs, 'props': [], 'new': version_info(new_version, old_version), 'old': version_info(old_version)}] add_stylesheet(req, 'common/css/diff.css') add_script(req, 'common/js/diff.js') return diff_data, changes def _do_edit_comment(self, req, page): req.perm(page.resource).require('WIKI_ADMIN') redirect_to = req.args.get('redirect_to') version = old_version = None if redirect_to == 'diff': version = page.version old_version = version - 1 redirect_href = req.href.wiki(page.name, action=redirect_to, version=version, old_version=old_version) if 'cancel' in req.args: req.redirect(redirect_href) new_comment = req.args.get('new_comment') page.edit_comment(new_comment) add_notice(req, _("The comment of version %(version)s of the page " "%(name)s has been updated.", version=page.version, name=page.name)) req.redirect(redirect_href) def _do_delete(self, req, page): req.perm(page.resource).require('WIKI_DELETE') if 'cancel' in req.args: req.redirect(get_resource_url(self.env, page.resource, req.href)) version = req.args.getint('version') old_version = req.args.getint('old_version', version) with self.env.db_transaction: if version and old_version and version > old_version: # delete from `old_version` exclusive to `version` inclusive: for v in xrange(old_version, version): page.delete(v + 1) else: # only delete that `version`, or the whole page if `None` page.delete(version) if not page.exists: add_notice(req, _("The page %(name)s has been deleted.", name=page.name)) req.redirect(req.href.wiki()) else: if version and old_version and version > old_version + 1: add_notice(req, _("The versions %(from_)d to %(to)d of the " "page %(name)s have been deleted.", from_=old_version + 1, to=version, name=page.name)) else: add_notice(req, _("The version %(version)d of the page " "%(name)s has been deleted.", version=version, name=page.name)) req.redirect(req.href.wiki(page.name)) def _do_rename(self, req, page): req.perm(page.resource).require('WIKI_RENAME') if 'cancel' in req.args: req.redirect(get_resource_url(self.env, page.resource, req.href)) old_name, old_version = page.name, page.version new_name = req.args.get('new_name', '') new_name = re.sub(r'/{2,}', '/', new_name.strip('/')) redirect = req.args.get('redirect') # verify input parameters warn = None if not new_name: warn = _("A new name is mandatory for a rename.") elif not validate_page_name(new_name): warn = _("The new name is invalid (a name which is separated " "with slashes cannot be '.' or '..').") elif new_name == old_name: warn = _("The new name must be different from the old name.") elif WikiPage(self.env, new_name).exists: warn = _("The page %(name)s already exists.", name=new_name) if warn: add_warning(req, warn) return self._render_confirm_rename(req, page, new_name) with self.env.db_transaction as db: page.rename(new_name) if redirect: redirection = WikiPage(self.env, old_name) redirection.text = _('See [wiki:"%(name)s"].', name=new_name) author = get_reporter_id(req) comment = u'[wiki:"%s@%d" %s] \u2192 [wiki:"%s"].' % ( new_name, old_version, old_name, new_name) redirection.save(author, comment) add_notice(req, _("The page %(old_name)s has been renamed to " "%(new_name)s.", old_name=old_name, new_name=new_name)) if redirect: add_notice(req, _("The page %(old_name)s has been recreated " "with a redirect to %(new_name)s.", old_name=old_name, new_name=new_name)) req.redirect(req.href.wiki(old_name if redirect else new_name)) def _do_save(self, req, page): if not page.exists: req.perm(page.resource).require('WIKI_CREATE') else: req.perm(page.resource).require('WIKI_MODIFY') if 'WIKI_CHANGE_READONLY' in req.perm(page.resource): # Modify the read-only flag if it has been changed and the user is # WIKI_ADMIN page.readonly = int('readonly' in req.args) try: page.save(get_reporter_id(req, 'author'), req.args.get('comment')) except TracError: add_warning(req, _("Page not modified, showing latest version.")) return self._render_view(req, page) href = req.href.wiki(page.name, action='diff', version=page.version) add_notice(req, tag_("Your changes have been saved in version " "%(version)s (%(diff)s).", version=page.version, diff=tag.a(_("diff"), href=href))) req.redirect(get_resource_url(self.env, page.resource, req.href, version=None)) def _render_confirm_delete(self, req, page): req.perm(page.resource).require('WIKI_DELETE') version = None if 'delete_version' in req.args: version = req.args.getint('version', 0) old_version = req.args.getint('old_version', version) what = 'multiple' if version and old_version \ and version - old_version > 1 \ else 'single' if version else 'page' num_versions = 0 new_date = None old_date = None for v, t, author, comment in page.get_history(): if (v <= version or what == 'page') and new_date is None: new_date = t if (v <= old_version and what == 'multiple' or num_versions > 1 and what == 'single'): break num_versions += 1 old_date = t data = self._page_data(req, page, 'delete') attachments = Attachment.select(self.env, self.realm, page.name) data.update({ 'what': what, 'new_version': None, 'old_version': None, 'num_versions': num_versions, 'new_date': new_date, 'old_date': old_date, 'attachments': list(attachments), }) if version is not None: data.update({'new_version': version, 'old_version': old_version}) self._wiki_ctxtnav(req, page) return 'wiki_delete.html', data def _render_confirm_rename(self, req, page, new_name=None): req.perm(page.resource).require('WIKI_RENAME') data = self._page_data(req, page, 'rename') data['new_name'] = new_name if new_name is not None else page.name self._wiki_ctxtnav(req, page) return 'wiki_rename.html', data def _render_diff(self, req, page): if not page.exists: raise TracError(_("Version %(num)s of page \"%(name)s\" does not " "exist", num=req.args.get('version'), name=page.name)) old_version = req.args.getint('old_version') if old_version: if old_version == page.version: old_version = None elif old_version > page.version: # FIXME: what about reverse diffs? old_version = page.resource.version page = WikiPage(self.env, page.name, old_version) req.perm(page.resource).require('WIKI_VIEW') latest_page = WikiPage(self.env, page.name) req.perm(latest_page.resource).require('WIKI_VIEW') new_version = page.version date = author = comment = None num_changes = 0 prev_version = next_version = None for version, t, a, c in latest_page.get_history(): if version == new_version: date = t author = a or 'anonymous' comment = c or '--' else: if version < new_version: num_changes += 1 if not prev_version: prev_version = version if old_version is None or version == old_version: old_version = version break else: next_version = version if not old_version: old_version = 0 old_page = WikiPage(self.env, page.name, old_version) req.perm(old_page.resource).require('WIKI_VIEW') # -- text diffs old_text = old_page.text.splitlines() new_text = page.text.splitlines() diff_data, changes = self._prepare_diff(req, page, old_text, new_text, old_version, new_version) # -- prev/up/next links if prev_version: add_link(req, 'prev', req.href.wiki(page.name, action='diff', version=prev_version), _("Version %(num)s", num=prev_version)) add_link(req, 'up', req.href.wiki(page.name, action='history'), _('Page history')) if next_version: add_link(req, 'next', req.href.wiki(page.name, action='diff', version=next_version), _("Version %(num)s", num=next_version)) data = self._page_data(req, page, 'diff') data.update({ 'change': {'date': date, 'author': author, 'comment': comment}, 'new_version': new_version, 'old_version': old_version, 'latest_version': latest_page.version, 'num_changes': num_changes, 'longcol': 'Version', 'shortcol': 'v', 'changes': changes, 'diff': diff_data, 'can_edit_comment': 'WIKI_ADMIN' in req.perm(page.resource), }) prevnext_nav(req, _("Previous Change"), _("Next Change"), _("Wiki History")) return 'wiki_diff.html', data def _render_editor(self, req, page, action='edit', has_collision=False): if has_collision: if action == 'merge': page = WikiPage(self.env, page.name) req.perm(page.resource).require('WIKI_VIEW') else: action = 'collision' if not page.exists: req.perm(page.resource).require('WIKI_CREATE') else: req.perm(page.resource).require('WIKI_MODIFY') original_text = page.text comment = req.args.get('comment', '') if 'text' in req.args: page.text = req.args.get('text') elif 'template' in req.args: template = req.args.get('template') template = template[1:] if template.startswith('/') \ else self.PAGE_TEMPLATES_PREFIX + template template_page = WikiPage(self.env, template) if template_page and template_page.exists and \ 'WIKI_VIEW' in req.perm(template_page.resource): page.text = template_page.text elif 'version' in req.args: version = None if req.args.get('version'): # Allow version to be empty version = req.args.as_int('version') if version is not None: old_page = WikiPage(self.env, page.name, version) req.perm(page.resource).require('WIKI_VIEW') page.text = old_page.text comment = _("Reverted to version %(version)s.", version=version) if action in ('preview', 'diff'): page.readonly = 'readonly' in req.args author = get_reporter_id(req, 'author') defaults = {'editrows': str(self.default_edit_area_height)} prefs = {key: req.session.get('wiki_%s' % key, defaults.get(key)) for key in ('editrows', 'sidebyside')} if 'from_editor' in req.args: sidebyside = req.args.get('sidebyside') or None if sidebyside != prefs['sidebyside']: req.session.set('wiki_sidebyside', int(bool(sidebyside)), 0) else: sidebyside = prefs['sidebyside'] if sidebyside: editrows = max(int(prefs['editrows']), len(page.text.splitlines()) + 1) else: editrows = req.args.get('editrows') if editrows: if editrows != prefs['editrows']: req.session.set('wiki_editrows', editrows, defaults['editrows']) else: editrows = prefs['editrows'] data = self._page_data(req, page, action) context = web_context(req, page.resource) data.update({ 'context': context, 'author': author, 'comment': comment, 'edit_rows': editrows, 'sidebyside': sidebyside, 'scroll_bar_pos': req.args.get('scroll_bar_pos', ''), 'diff': None, 'attachments': AttachmentModule(self.env).attachment_data(context) }) if action in ('diff', 'merge'): old_text = original_text.splitlines() if original_text else [] new_text = page.text.splitlines() if page.text else [] diff_data, changes = self._prepare_diff( req, page, old_text, new_text, page.version, '') data.update({'diff': diff_data, 'changes': changes, 'action': 'preview', 'merge': action == 'merge', 'longcol': 'Version', 'shortcol': 'v'}) elif sidebyside and action != 'collision': data['action'] = 'preview' self._wiki_ctxtnav(req, page) Chrome(self.env).add_wiki_toolbars(req) Chrome(self.env).add_auto_preview(req) add_script(req, 'common/js/wiki.js') return 'wiki_edit.html', data def _render_edit_comment(self, req, page): req.perm(page.resource).require('WIKI_ADMIN') data = self._page_data(req, page, 'edit_comment') data.update({'redirect_to': req.args.get('redirect_to', 'history')}) self._wiki_ctxtnav(req, page) return 'wiki_edit_comment.html', data def _render_history(self, req, page): """Extract the complete history for a given page. This information is used to present a changelog/history for a given page. """ if not page.exists: raise TracError(_("Page %(name)s does not exist", name=page.name)) data = self._page_data(req, page, 'history') history = [] for version, date, author, comment in page.get_history(): history.append({ 'version': version, 'date': date, 'author': author, 'comment': comment or '' }) data.update({ 'history': history, 'resource': page.resource, 'can_edit_comment': 'WIKI_ADMIN' in req.perm(page.resource) }) add_ctxtnav(req, _("Back to %(wikipage)s", wikipage=page.name), req.href.wiki(page.name)) return 'history_view.html', data def _render_view(self, req, page): version = page.resource.version # Add registered converters if page.exists: for conversion in Mimeview(self.env) \ .get_supported_conversions('text/x-trac-wiki'): conversion_href = req.href.wiki(page.name, version=version, format=conversion.key) add_link(req, 'alternate', conversion_href, conversion.name, conversion.in_mimetype) data = self._page_data(req, page) if page.name == self.START_PAGE: data['title'] = '' ws = WikiSystem(self.env) context = web_context(req, page.resource) higher, related = [], [] if not page.exists: if 'WIKI_CREATE' not in req.perm(page.resource): raise ResourceNotFound(_("Page %(name)s not found", name=page.name)) formatter = OneLinerFormatter(self.env, context) if '/' in page.name: parts = page.name.split('/') for i in xrange(len(parts) - 2, -1, -1): name = '/'.join(parts[:i] + [parts[-1]]) if not ws.has_page(name): higher.append(ws._format_link(formatter, 'wiki', '/' + name, name, False)) else: name = page.name name = name.lower() related = [each for each in ws.pages if name in each.lower() and 'WIKI_VIEW' in req.perm(self.realm, each)] related.sort() related = [ws._format_link(formatter, 'wiki', '/' + each, each, False) for each in related] latest_page = WikiPage(self.env, page.name) prev_version = next_version = None if version: version = as_int(version, None) if version is not None: for hist in latest_page.get_history(): v = hist[0] if v != version: if v < version: if not prev_version: prev_version = v break else: next_version = v prefix = self.PAGE_TEMPLATES_PREFIX templates = [template[len(prefix):] for template in ws.get_pages(prefix) if 'WIKI_VIEW' in req.perm(self.realm, template)] # -- prev/up/next links if prev_version: add_link(req, 'prev', req.href.wiki(page.name, version=prev_version), _("Version %(num)s", num=prev_version)) parent = None if version: add_link(req, 'up', req.href.wiki(page.name, version=None), _("View latest version")) elif '/' in page.name: parent = page.name[:page.name.rindex('/')] add_link(req, 'up', req.href.wiki(parent, version=None), _("View parent page")) if next_version: add_link(req, 'next', req.href.wiki(page.name, version=next_version), _('Version %(num)s', num=next_version)) # Add ctxtnav entries if version: prevnext_nav(req, _("Previous Version"), _("Next Version"), _("View Latest Version")) else: if parent: add_ctxtnav(req, _('Up'), req.href.wiki(parent)) self._wiki_ctxtnav(req, page) # Plugin content validation fields = {'text': page.text} for manipulator in self.page_manipulators: manipulator.prepare_wiki_page(req, page, fields) text = fields.get('text', '') data.update({ 'context': context, 'text': text, 'latest_version': latest_page.version, 'attachments': AttachmentModule(self.env).attachment_data(context), 'start_page': self.START_PAGE, 'default_template': self.DEFAULT_PAGE_TEMPLATE, 'templates': templates, 'version': version, 'higher': higher, 'related': related, 'resourcepath_template': 'wiki_page_path.html', 'fullwidth': req.session.get('wiki_fullwidth'), }) add_script(req, 'common/js/wiki.js') return 'wiki_view.html', data def _wiki_ctxtnav(self, req, page): """Add the normal wiki ctxtnav entries.""" if 'WIKI_VIEW' in req.perm('wiki', self.START_PAGE): add_ctxtnav(req, _("Start Page"), req.href.wiki(self.START_PAGE)) if 'WIKI_VIEW' in req.perm('wiki', self.TITLE_INDEX_PAGE): add_ctxtnav(req, _("Index"), req.href.wiki(self.TITLE_INDEX_PAGE)) if page.exists: add_ctxtnav(req, _("History"), req.href.wiki(page.name, action='history')) # ITimelineEventProvider methods def get_timeline_filters(self, req): if 'WIKI_VIEW' in req.perm: yield ('wiki', _('Wiki changes')) def get_timeline_events(self, req, start, stop, filters): if 'wiki' in filters: wiki_realm = Resource(self.realm) for ts, name, comment, author, version in self.env.db_query(""" SELECT time, name, comment, author, version FROM wiki WHERE time>=%s AND time<=%s """, (to_utimestamp(start), to_utimestamp(stop))): wiki_page = wiki_realm(id=name, version=version) if 'WIKI_VIEW' not in req.perm(wiki_page): continue yield ('wiki', from_utimestamp(ts), author, (wiki_page, comment)) # Attachments for event in AttachmentModule(self.env).get_timeline_events( req, wiki_realm, start, stop): yield event def render_timeline_event(self, context, field, event): wiki_page, comment = event[3] if field == 'url': return context.href.wiki(wiki_page.id, version=wiki_page.version) elif field == 'title': name = tag.em(get_resource_name(self.env, wiki_page)) if wiki_page.version > 1: return tag_("%(page)s edited", page=name) else: return tag_("%(page)s created", page=name) elif field == 'description': markup = format_to(self.env, None, context.child(resource=wiki_page), comment) if wiki_page.version > 1: diff_href = context.href.wiki( wiki_page.id, version=wiki_page.version, action='diff') markup = tag(markup, " (", tag.a(_("diff"), href=diff_href), ")") return markup # ISearchSource methods def get_search_filters(self, req): if 'WIKI_VIEW' in req.perm: yield ('wiki', _('Wiki')) def get_search_results(self, req, terms, filters): if not 'wiki' in filters: return with self.env.db_query as db: sql_query, args = search_to_sql(db, ['w1.name', 'w1.author', 'w1.text'], terms) wiki_realm = Resource(self.realm) for name, ts, author, text in db(""" SELECT w1.name, w1.time, w1.author, w1.text FROM wiki w1,(SELECT name, max(version) AS ver FROM wiki GROUP BY name) w2 WHERE w1.version = w2.ver AND w1.name = w2.name AND """ + sql_query, args): page = wiki_realm(id=name) if 'WIKI_VIEW' in req.perm(page): yield (get_resource_url(self.env, page, req.href), '%s: %s' % (name, shorten_line(text)), from_utimestamp(ts), author, shorten_result(text, terms)) # Attachments for result in AttachmentModule(self.env).get_search_results( req, wiki_realm, terms): yield result
class DatabaseManager(Component): 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''. ''(Since 0.11)''""") debug_sql = BoolOption( 'trac', 'debug_sql', False, """Show the SQL queries in the Trac log, at DEBUG level. ''(Since 0.11.5)''""") def __init__(self): self._cnx_pool = None def init_db(self): connector, args = self.get_connector() from trac.db_default import schema args['schema'] = schema connector.init_db(**args) def get_connection(self, readonly=False): """Get a database connection from the pool. If `readonly` is `True`, the returned connection will purposedly 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 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.get_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_db_str(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 args['path'].startswith('/'): # 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 _get_connector = get_connector # For 0.11 compatibility
class AttachmentModule(Component): implements(IRequestHandler, INavigationContributor, IWikiSyntaxProvider, IResourceManager) realm = 'attachment' is_valid_default_handler = False change_listeners = ExtensionPoint(IAttachmentChangeListener) manipulators = ExtensionPoint(IAttachmentManipulator) CHUNK_SIZE = 4096 max_size = IntOption( 'attachment', 'max_size', 262144, """Maximum allowed file size (in bytes) for attachments.""") max_zip_size = IntOption( 'attachment', 'max_zip_size', 2097152, """Maximum allowed total size (in bytes) for an attachment list to be downloadable as a `.zip`. Set this to -1 to disable download as `.zip`. (''since 1.0'')""") render_unsafe_content = BoolOption( 'attachment', 'render_unsafe_content', 'false', """Whether attachments 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 attach a file containing cross-site scripting attacks. For public sites where anonymous users can create attachments it is recommended to leave this option disabled.""") # INavigationContributor methods def get_active_navigation_item(self, req): return req.args.get('realm') def get_navigation_items(self, req): return [] # IRequestHandler methods def match_request(self, req): match = re.match(r'/(raw-|zip-)?attachment/([^/]+)(?:/(.*))?$', req.path_info) if match: format, realm, path = match.groups() if format: req.args['format'] = format[:-1] req.args['realm'] = realm if path: req.args['path'] = path return True def process_request(self, req): parent_id = None parent_realm = req.args.get('realm') path = req.args.get('path') filename = None if not parent_realm or not path: raise HTTPBadRequest(_('Bad request')) if parent_realm == 'attachment': raise TracError( tag_("%(realm)s is not a valid parent realm", realm=tag.code(parent_realm))) parent_realm = Resource(parent_realm) action = req.args.get('action', 'view') if action == 'new': parent_id = path.rstrip('/') else: last_slash = path.rfind('/') if last_slash == -1: parent_id, filename = path, '' else: parent_id, filename = path[:last_slash], path[last_slash + 1:] parent = parent_realm(id=parent_id) if not resource_exists(self.env, parent): raise ResourceNotFound( _("Parent resource %(parent)s doesn't exist", parent=get_resource_name(self.env, parent))) # Link the attachment page to parent resource parent_name = get_resource_name(self.env, parent) parent_url = get_resource_url(self.env, parent, req.href) add_link(req, 'up', parent_url, parent_name) add_ctxtnav(req, _('Back to %(parent)s', parent=parent_name), parent_url) if not filename: # there's a trailing '/' if req.args.get('format') == 'zip': self._download_as_zip(req, parent) elif action != 'new': return self._render_list(req, parent) attachment = Attachment(self.env, parent.child(self.realm, filename)) if req.method == 'POST': if action == 'new': data = self._do_save(req, attachment) elif action == 'delete': self._do_delete(req, attachment) else: raise HTTPBadRequest(_("Invalid request arguments.")) elif action == 'delete': data = self._render_confirm_delete(req, attachment) elif action == 'new': data = self._render_form(req, attachment) else: data = self._render_view(req, attachment) add_stylesheet(req, 'common/css/code.css') return 'attachment.html', data, None # IWikiSyntaxProvider methods def get_wiki_syntax(self): return [] def get_link_resolvers(self): yield ('raw-attachment', self._format_link) yield ('attachment', self._format_link) # Public methods def viewable_attachments(self, context): """Return the list of viewable attachments in the given context. :param context: the `~trac.mimeview.api.RenderingContext` corresponding to the parent `~trac.resource.Resource` for the attachments """ parent = context.resource attachments = [] for attachment in Attachment.select(self.env, parent.realm, parent.id): if 'ATTACHMENT_VIEW' in context.perm(attachment.resource): attachments.append(attachment) return attachments def attachment_data(self, context): """Return a data dictionary describing the list of viewable attachments in the current context. """ attachments = self.viewable_attachments(context) parent = context.resource total_size = sum(attachment.size for attachment in attachments) new_att = parent.child(self.realm) return { 'attach_href': get_resource_url(self.env, new_att, context.href), 'download_href': get_resource_url(self.env, new_att, context.href, format='zip') if total_size <= self.max_zip_size else None, 'can_create': 'ATTACHMENT_CREATE' in context.perm(new_att), 'attachments': attachments, 'parent': context.resource } def get_history(self, start, stop, realm): """Return an iterable of tuples describing changes to attachments on a particular object realm. The tuples are in the form (change, realm, id, filename, time, description, author). `change` can currently only be `created`. FIXME: no iterator """ for realm, id, filename, ts, description, author in \ self.env.db_query(""" SELECT type, id, filename, time, description, author FROM attachment WHERE time > %s AND time < %s AND type = %s """, (to_utimestamp(start), to_utimestamp(stop), realm)): time = from_utimestamp(ts or 0) yield ('created', realm, id, filename, time, description, author) def get_timeline_events(self, req, resource_realm, start, stop): """Return an event generator suitable for ITimelineEventProvider. Events are changes to attachments on resources of the given `resource_realm.realm`. """ for change, realm, id, filename, time, descr, author in \ self.get_history(start, stop, resource_realm.realm): attachment = resource_realm(id=id).child(self.realm, filename) if 'ATTACHMENT_VIEW' in req.perm(attachment): yield ('attachment', time, author, (attachment, descr), self) def render_timeline_event(self, context, field, event): attachment, descr = event[3] if field == 'url': return self.get_resource_url(attachment, context.href) elif field == 'title': name = get_resource_name(self.env, attachment.parent) title = get_resource_summary(self.env, attachment.parent) return tag_("%(attachment)s attached to %(resource)s", attachment=tag.em(os.path.basename(attachment.id)), resource=tag.em(name, title=title)) elif field == 'description': return format_to(self.env, None, context.child(attachment.parent), descr) def get_search_results(self, req, resource_realm, terms): """Return a search result generator suitable for ISearchSource. Search results are attachments on resources of the given `resource_realm.realm` whose filename, description or author match the given terms. """ with self.env.db_query as db: sql_query, args = search_to_sql( db, ['filename', 'description', 'author'], terms) for id, time, filename, desc, author in db( """ SELECT id, time, filename, description, author FROM attachment WHERE type = %s AND """ + sql_query, (resource_realm.realm, ) + args): attachment = resource_realm(id=id).child(self.realm, filename) if 'ATTACHMENT_VIEW' in req.perm(attachment): yield (get_resource_url(self.env, attachment, req.href), get_resource_shortname(self.env, attachment), from_utimestamp(time), author, shorten_result(desc, terms)) # IResourceManager methods def get_resource_realms(self): yield self.realm def get_resource_url(self, resource, href, **kwargs): """Return an URL to the attachment itself. A `format` keyword argument equal to `'raw'` will be converted to the raw-attachment prefix. """ if not resource.parent: return None format = kwargs.get('format') prefix = 'attachment' if format in ('raw', 'zip'): kwargs.pop('format') prefix = format + '-attachment' parent_href = unicode_unquote( get_resource_url(self.env, resource.parent(version=None), Href(''))) if not resource.id: # link to list of attachments, which must end with a trailing '/' # (see process_request) return href(prefix, parent_href, '', **kwargs) else: return href(prefix, parent_href, resource.id, **kwargs) def get_resource_description(self, resource, format=None, **kwargs): if not resource.parent: return _("Unparented attachment %(id)s", id=resource.id) if format == 'compact': return '%s (%s)' % (resource.id, get_resource_name(self.env, resource.parent)) elif format == 'summary': return Attachment(self.env, resource).description if resource.id: return _("Attachment '%(id)s' in %(parent)s", id=resource.id, parent=get_resource_name(self.env, resource.parent)) else: return _("Attachments of %(parent)s", parent=get_resource_name(self.env, resource.parent)) def resource_exists(self, resource): try: attachment = Attachment(self.env, resource) return os.path.exists(attachment.path) except ResourceNotFound: return False # Internal methods def _do_save(self, req, attachment): req.perm(attachment.resource).require('ATTACHMENT_CREATE') parent_resource = attachment.resource.parent if 'cancel' in req.args: req.redirect(get_resource_url(self.env, parent_resource, req.href)) upload = req.args.getfirst('attachment') if not hasattr(upload, 'filename') or not upload.filename: raise TracError(_("No file uploaded")) if hasattr(upload.file, 'fileno'): size = os.fstat(upload.file.fileno())[6] else: upload.file.seek(0, 2) # seek to end of file size = upload.file.tell() upload.file.seek(0) if size == 0: raise TracError(_("Can't upload empty file")) # Maximum attachment size (in bytes) max_size = self.max_size if 0 <= max_size < size: raise TracError( _("Maximum attachment size: %(num)s", num=pretty_size(max_size)), _("Upload failed")) filename = _normalized_filename(upload.filename) if not filename: raise TracError(_("No file uploaded")) # Now the filename is known, update the attachment resource attachment.filename = filename attachment.description = req.args.get('description', '') attachment.author = get_reporter_id(req, 'author') attachment.ipnr = req.remote_addr # Validate attachment valid = True for manipulator in self.manipulators: for field, message in manipulator.validate_attachment( req, attachment): valid = False if field: add_warning( req, tag_( "Attachment field %(field)s is invalid: " "%(message)s", field=tag.strong(field), message=message)) else: add_warning( req, tag_("Invalid attachment: %(message)s", message=message)) if not valid: # Display the attach form with pre-existing data # NOTE: Local file path not known, file field cannot be repopulated add_warning(req, _('Note: File must be selected again.')) data = self._render_form(req, attachment) data['is_replace'] = req.args.get('replace') return data if req.args.get('replace'): try: old_attachment = Attachment(self.env, attachment.resource(id=filename)) if not (req.authname and req.authname != 'anonymous' and old_attachment.author == req.authname) \ and 'ATTACHMENT_DELETE' \ not in req.perm(attachment.resource): raise PermissionError(msg=_( "You don't have permission to " "replace the attachment %(name)s. You can only " "replace your own attachments. Replacing other's " "attachments requires ATTACHMENT_DELETE permission.", name=filename)) if (not attachment.description.strip() and old_attachment.description): attachment.description = old_attachment.description old_attachment.delete() except TracError: pass # don't worry if there's nothing to replace attachment.insert(filename, upload.file, size) req.redirect( get_resource_url(self.env, attachment.resource(id=None), req.href)) def _do_delete(self, req, attachment): req.perm(attachment.resource).require('ATTACHMENT_DELETE') parent_href = get_resource_url(self.env, attachment.resource.parent, req.href) if 'cancel' in req.args: req.redirect(parent_href) attachment.delete() req.redirect(parent_href) def _render_confirm_delete(self, req, attachment): req.perm(attachment.resource).require('ATTACHMENT_DELETE') return { 'mode': 'delete', 'title': _('%(attachment)s (delete)', attachment=get_resource_name(self.env, attachment.resource)), 'attachment': attachment } def _render_form(self, req, attachment): req.perm(attachment.resource).require('ATTACHMENT_CREATE') return { 'mode': 'new', 'author': get_reporter_id(req), 'attachment': attachment, 'max_size': self.max_size } def _download_as_zip(self, req, parent, attachments=None): if attachments is None: attachments = self.viewable_attachments(web_context(req, parent)) total_size = sum(attachment.size for attachment in attachments) if total_size > self.max_zip_size: raise TracError( _("Maximum total attachment size: %(num)s", num=pretty_size(self.max_zip_size)), _("Download failed")) req.send_response(200) req.send_header('Content-Type', 'application/zip') filename = 'attachments-%s-%s.zip' % \ (parent.realm, re.sub(r'[/\\:]', '-', unicode(parent.id))) req.send_header('Content-Disposition', content_disposition('inline', filename)) req.end_headers() def write_partial(fileobj, start): end = fileobj.tell() fileobj.seek(start, 0) remaining = end - start while remaining > 0: chunk = fileobj.read(min(remaining, 4096)) req.write(chunk) remaining -= len(chunk) fileobj.seek(end, 0) return end pos = 0 fileobj = TemporaryFile(prefix='trac-', suffix='.zip') try: zipfile = ZipFile(fileobj, 'w', ZIP_DEFLATED) for attachment in attachments: zipinfo = create_zipinfo(attachment.filename, mtime=attachment.date, comment=attachment.description) try: with attachment.open() as fd: zipfile.writestr(zipinfo, fd.read()) except ResourceNotFound: pass # skip missing files else: pos = write_partial(fileobj, pos) finally: try: zipfile.close() write_partial(fileobj, pos) finally: fileobj.close() raise RequestDone def _render_list(self, req, parent): data = { 'mode': 'list', 'attachment': None, # no specific attachment 'attachments': self.attachment_data(web_context(req, parent)) } return 'attachment.html', data, None def _render_view(self, req, attachment): req.perm(attachment.resource).require('ATTACHMENT_VIEW') can_delete = 'ATTACHMENT_DELETE' in req.perm(attachment.resource) req.check_modified(attachment.date, str(can_delete)) data = { 'mode': 'view', 'title': get_resource_name(self.env, attachment.resource), 'attachment': attachment } with attachment.open() as fd: mimeview = Mimeview(self.env) # MIME type detection str_data = fd.read(1000) fd.seek(0) mime_type = mimeview.get_mimetype(attachment.filename, str_data) # Eventually send the file directly format = req.args.get('format') if format == 'zip': self._download_as_zip(req, attachment.resource.parent, [attachment]) elif format in ('raw', 'txt'): 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') if format == 'txt': mime_type = 'text/plain' elif not mime_type: mime_type = 'application/octet-stream' if 'charset=' not in mime_type: charset = mimeview.get_charset(str_data, mime_type) mime_type = mime_type + '; charset=' + charset req.send_file(attachment.path, mime_type) # add ''Plain Text'' alternate link if needed if (self.render_unsafe_content and mime_type and not mime_type.startswith('text/plain')): plaintext_href = get_resource_url(self.env, attachment.resource, req.href, format='txt') add_link(req, 'alternate', plaintext_href, _('Plain Text'), mime_type) # add ''Original Format'' alternate link (always) raw_href = get_resource_url(self.env, attachment.resource, req.href, format='raw') add_link(req, 'alternate', raw_href, _('Original Format'), mime_type) self.log.debug("Rendering preview of file %s with mime-type %s", attachment.filename, mime_type) data['preview'] = mimeview.preview_data( web_context(req, attachment.resource), fd, os.fstat(fd.fileno()).st_size, mime_type, attachment.filename, raw_href, annotations=['lineno']) return data def _format_link(self, formatter, ns, target, label): link, params, fragment = formatter.split_link(target) ids = link.split(':', 2) attachment = None if len(ids) == 3: known_realms = ResourceSystem(self.env).get_known_realms() # new-style attachment: TracLinks (filename:realm:id) if ids[1] in known_realms: attachment = Resource(ids[1], ids[2]).child(self.realm, ids[0]) else: # try old-style attachment: TracLinks (realm:id:filename) if ids[0] in known_realms: attachment = Resource(ids[0], ids[1]).child(self.realm, ids[2]) else: # local attachment: TracLinks (filename) attachment = formatter.resource.child(self.realm, link) if attachment and 'ATTACHMENT_VIEW' in formatter.perm(attachment): try: model = Attachment(self.env, attachment) raw_href = get_resource_url(self.env, attachment, formatter.href, format='raw') if ns.startswith('raw'): return tag.a(label, class_='attachment', href=raw_href + params, title=get_resource_name(self.env, attachment)) href = get_resource_url(self.env, attachment, formatter.href) title = get_resource_name(self.env, attachment) return tag( tag.a(label, class_='attachment', title=title, href=href + params), tag.a(u'\u200b', class_='trac-rawlink', href=raw_href + params, title=_("Download"))) except ResourceNotFound: pass # FIXME: should be either: # # model = Attachment(self.env, attachment) # if model.exists: # ... # # or directly: # # if attachment.exists: # # (related to #4130) return tag.a(label, class_='missing attachment')
class ShowIcons(Component): """Macro to list available icons on a wiki page. The `ShowIcons` macro displays a table of available icons, matching a search criteria. The number of presented icons can be limited to prevent exhaustive network traffic. This limit is defined in the `[wikiextras]` section in `trac.ini`. """ implements(ITemplateProvider, IWikiMacroProvider) showicons_limit = IntOption( 'wikiextras', 'showicons_limit', 96, """To prevent exhaustive network traffic, limit the maximum number of icons generated by the macro `ShowIcons`. Set to 0 for unlimited number of icons (this will produce exhaustive network traffic--you have been warned!)""") def _render(self, formatter, cols, name_pat, size, header, limit): #noinspection PyArgumentList icon = Icons(self.env) icon_dir = icon.icon_location(size)[1] files = fnmatch.filter(os.listdir(icon_dir), '%s.png' % name_pat) icon_names = [os.path.splitext(p)[0] for p in files] if limit: displayed_icon_names = reduce_names(icon_names, limit) else: displayed_icon_names = icon_names icon_table = render_table( displayed_icon_names, cols, lambda name: icon._render_icon(formatter, name, size)) if not len(icon_names): message = 'No %s icon matches %s' % (SIZE_DESCR[size], name_pat) elif len(icon_names) == 1: message = 'Showing the only %s icon matching %s' % \ (SIZE_DESCR[size], name_pat) elif len(displayed_icon_names) == len(icon_names): message = 'Showing all %d %s icons matching %s' % \ (len(icon_names), SIZE_DESCR[size], name_pat) else: message = 'Showing %d of %d %s icons matching %s' % \ (len(displayed_icon_names), len(icon_names), SIZE_DESCR[size], name_pat) return tag.div(tag.p(tag.small(message)) if header else '', icon_table) # ITemplateProvider methods def get_htdocs_dirs(self): return [] def get_templates_dirs(self): return [] # IWikiMacroProvider methods def get_macros(self): yield 'ShowIcons' #noinspection PyUnusedLocal def get_macro_description(self, name): #noinspection PyStringFormat return cleandoc("""Renders in a table a list of available icons. No more than %(showicons_limit)d icons are displayed to prevent exhaustive network traffic. Syntax: {{{ [[ShowIcons(cols, name-pattern, size, header, limit)]] }}} where * `cols` is optionally the number of columns in the table (defaults 3). * `name-pattern` selects which icons to list (use `*` and `?`). * `size` is optionally one of `small`, `medium` or `large` or an abbreviation thereof (defaults `small`). * `header` is optionally one of `header` and `noheader` or an abbreviation thereof (header is displayed by default) * `limit` specifies an optional upper limit of number of displayed icons (however, no more than %(showicons_limit)d will be displayed). The last three optional parameters (`size`, `header` and `limit`) can be stated in any order. Example: {{{ [[ShowIcons(smile*)]] # all small icons matching smile* [[ShowIcons(4, smile*)]] # four columns [[ShowIcons(smile*, 10)]] # limit to 10 icons [[ShowIcons(smile*, 10, nohead)]] # no header [[ShowIcons(smile*, m)]] # medium-size }}} """ % {'showicons_limit': self.showicons_limit}) #noinspection PyUnusedLocal def expand_macro(self, formatter, name, content, args=None): # content = cols, name-pattern, size, header, limit args = [] if content: args = [a.strip() for a in content.split(',')] args += [''] * 2 a = args.pop(0) # cols if a.isdigit(): cols = max(int(a), 1) a = args.pop(0) else: cols = 3 # name_pat name_pat = a if not name_pat: name_pat = '*' # size, header and limit size = 'S' header = True limit = self.showicons_limit while args: a = args.pop(0).lower() if a.isdigit(): limit = min(int(a), limit) elif a and any(d.startswith(a) for d in SIZE_DESCR.values()): size = a.upper()[0] elif a and any(d.startswith(a) for d in ['header', 'noheader']): header = a[0].startswith('h') return self._render(formatter, cols, name_pat, size, header, limit)
class FullBlogModule(Component): implements(IRequestHandler, INavigationContributor, ISearchSource, ITimelineEventProvider, ITemplateProvider) # Options month_names = ListOption( 'fullblog', 'month_names', doc="""Ability to specify a list of month names for display in groupings. If empty it will make a list from default locale setting. Enter list of 12 months like: `month_names = January, February, ..., December` """) personal_blog = BoolOption( 'fullblog', 'personal_blog', False, """When using the Blog as a personal blog (only one author), setting to 'True' will disable the display of 'Browse by author:' in sidebar, and also removes various author links and references. """) num_items = IntOption( 'fullblog', 'num_items_front', 20, """Option to specify how many recent posts to display on the front page of the Blog (and RSS feeds).""") archive_rss_icon = BoolOption( 'fullblog', 'archive_rss_icon', True, """Controls whether or not to display the rss icon""") all_rss_icons = BoolOption( 'fullblog', 'all_rss_icons', False, """Controls whether or not to display rss icons more than once""") # INavigationContributor methods 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 'blog' 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 'BLOG_VIEW' in req.perm('blog'): yield ('mainnav', 'blog', tag.a(_('Blog'), href=req.href.blog())) # IRequstHandler methods def match_request(self, req): """Return whether the handler wants to process the given request.""" match = re.match(r'^/blog(?:/(.*)|$)', req.path_info) if match: req.args['blog_path'] = '' if match.group(1): req.args['blog_path'] = match.group(1) return True def process_request(self, req): """ Processing the request. """ req.perm('blog').assert_permission('BLOG_VIEW') blog_core = FullBlogCore(self.env) format = req.args.get('format', '').lower() command, pagename, path_items, listing_data = self._parse_path(req) action = req.args.get('action', 'view').lower() try: version = int(req.args.get('version', 0)) except: version = 0 data = {} template = 'fullblog_view.html' data['blog_about'] = BlogPost(self.env, 'about') data['blog_infotext'] = blog_core.get_bloginfotext() blog_month_names = map_month_names( self.env.config.getlist('fullblog', 'month_names')) data['blog_month_names'] = blog_month_names self.env.log.debug( "Blog debug: command=%r, pagename=%r, path_items=%r" % (command, pagename, path_items)) if not command: # Request for just root (display latest) data['blog_post_list'] = [] count = 0 maxcount = self.num_items blog_posts = get_blog_posts(self.env) for post in blog_posts: bp = BlogPost(self.env, post[0], post[1]) if 'BLOG_VIEW' in req.perm(bp.resource): data['blog_post_list'].append(bp) count += 1 if maxcount and count == maxcount: # Only display a certain number on front page (from config) break data['blog_list_title'] = "Recent posts" + \ (len(blog_posts) > maxcount and \ " (max %d) - Browse or Archive for more" % (maxcount,) \ or '') add_link(req, 'alternate', req.href.blog(format='rss'), 'RSS Feed', 'application/rss+xml', 'rss') elif command == 'archive': # Requesting the archive page template = 'fullblog_archive.html' data['blog_archive'] = [] for period, period_posts in group_posts_by_month( get_blog_posts(self.env)): allowed_posts = [] for post in period_posts: bp = BlogPost(self.env, post[0], post[1]) if 'BLOG_VIEW' in req.perm(bp.resource): allowed_posts.append(post) if allowed_posts: data['blog_archive'].append((period, allowed_posts)) add_link(req, 'alternate', req.href.blog(format='rss'), 'RSS Feed', 'application/rss+xml', 'rss') elif command == 'view' and pagename: # Requesting a specific blog post the_post = BlogPost(self.env, pagename, version) req.perm(the_post.resource).require('BLOG_VIEW') if not the_post.version: raise HTTPNotFound("No blog post named '%s'." % pagename) if req.method == 'POST': # Adding/Previewing a comment # Permission? req.perm(the_post.resource).require('BLOG_COMMENT') comment = BlogComment(self.env, pagename) comment.comment = req.args.get('comment', '') comment.author = (req.authname != 'anonymous' and req.authname) \ or req.args.get('author') comment.time = datetime.datetime.now(utc) warnings = [] if 'cancelcomment' in req.args: req.redirect(req.href.blog(pagename)) elif 'previewcomment' in req.args: warnings.extend( blog_core.create_comment(req, comment, verify_only=True)) elif 'submitcomment' in req.args and not warnings: warnings.extend(blog_core.create_comment(req, comment)) if not warnings: req.redirect( req.href.blog(pagename) + '#comment-' + str(comment.number)) data['blog_comment'] = comment # Push all warnings out to the user. for field, reason in warnings: if field: add_warning(req, "Field '%s': %s" % (field, reason)) else: add_warning(req, reason) data['blog_post'] = the_post context = web_context(req, the_post.resource, absurls=format == 'rss' and True or False) data['context'] = context if format == 'rss': return 'fullblog_post.rss', data, 'application/rss+xml' # Regular web response context = web_context(req, the_post.resource) data['blog_attachments'] = AttachmentModule( self.env).attachment_data(context) # Previous and Next ctxtnav prev, next = blog_core.get_prev_next_posts(req.perm, the_post.name) if prev: add_link(req, 'prev', req.href.blog(prev), prev) if next: add_link(req, 'next', req.href.blog(next), next) if arity(prevnext_nav) == 4: # 0.12 compat following trac:changeset:8597 prevnext_nav(req, 'Previous Post', 'Next Post') else: prevnext_nav(req, 'Post') # RSS feed for post and comments add_link(req, 'alternate', req.href.blog(pagename, format='rss'), 'RSS Feed', 'application/rss+xml', 'rss') elif command in ['create', 'edit']: template = 'fullblog_edit.html' default_pagename = blog_core._get_default_postname(req.authname) the_post = BlogPost(self.env, pagename or default_pagename) warnings = [] if command == 'create' and req.method == 'GET' and not the_post.version: # Support appending query arguments for populating intial fields the_post.update_fields(req.args) if command == 'create' and the_post.version: # Post with name or suggested name already exists if 'BLOG_CREATE' in req.perm and the_post.name == default_pagename \ and not req.method == 'POST': if default_pagename: add_notice( req, "Suggestion for new name already exists " "('%s'). Please make a new name." % the_post.name) elif pagename: warnings.append( ('', "A post named '%s' already exists. Enter new name." % the_post.name)) the_post = BlogPost(self.env, '') if command == 'edit': req.perm(the_post.resource).require( 'BLOG_VIEW') # Starting point if req.method == 'POST': # Create or edit a blog post if 'blog-cancel' in req.args: if req.args.get('action', '') == 'edit': req.redirect(req.href.blog(pagename)) else: req.redirect(req.href.blog()) # Assert permissions if command == 'create': req.perm(Resource('blog', None)).require('BLOG_CREATE') elif command == 'edit': if the_post.author == req.authname: req.perm(the_post.resource).require('BLOG_MODIFY_OWN') else: req.perm(the_post.resource).require('BLOG_MODIFY_ALL') # Check input orig_author = the_post.author if not the_post.update_fields(req.args): warnings.append(('', "None of the fields have changed.")) version_comment = req.args.get('new_version_comment', '') if 'blog-preview' in req.args: warnings.extend( blog_core.create_post(req, the_post, req.authname, version_comment, verify_only=True)) elif 'blog-save' in req.args and not warnings: warnings.extend( blog_core.create_post(req, the_post, req.authname, version_comment)) if not warnings: req.redirect(req.href.blog(the_post.name)) context = web_context(req, the_post.resource) data['context'] = context data['blog_attachments'] = AttachmentModule( self.env).attachment_data(context) data['blog_action'] = 'preview' data['blog_version_comment'] = version_comment if (orig_author and orig_author != the_post.author) and ( not 'BLOG_MODIFY_ALL' in req.perm(the_post.resource)): add_notice(req, "If you change the author you cannot " \ "edit the post again due to restricted permissions.") data['blog_orig_author'] = orig_author for field, reason in warnings: if field: add_warning(req, "Field '%s': %s" % (field, reason)) else: add_warning(req, reason) data['blog_edit'] = the_post elif command == 'delete': bp = BlogPost(self.env, pagename) req.perm(bp.resource).require('BLOG_DELETE') if 'blog-cancel' in req.args: req.redirect(req.href.blog(pagename)) comment = int(req.args.get('comment', '0')) warnings = [] if comment: # Deleting a specific comment bc = BlogComment(self.env, pagename, comment) if not bc.number: raise TracError( "Cannot delete. Blog post name and/or comment number missing." ) if req.method == 'POST' and comment and pagename: warnings.extend(blog_core.delete_comment(bc)) if not warnings: add_notice(req, "Blog comment %d deleted." % comment) req.redirect(req.href.blog(pagename)) template = 'fullblog_delete.html' data['blog_comment'] = bc else: # Delete a version of a blog post or all versions # with comments and attachments if only version. if not bp.version: raise TracError( "Cannot delete. Blog post '%s' does not exist." % (bp.name)) version = int(req.args.get('version', '0')) if req.method == 'POST': if 'blog-version-delete' in req.args: if bp.version != version: raise TracError( "Cannot delete. Can only delete most recent version." ) warnings.extend( blog_core.delete_post(bp, version=bp.versions[-1])) elif 'blog-delete' in req.args: version = 0 warnings.extend( blog_core.delete_post(bp, version=version)) if not warnings: if version > 1: add_notice( req, "Blog post '%s' version %d deleted." % (pagename, version)) req.redirect(req.href.blog(pagename)) else: add_notice(req, "Blog post '%s' deleted." % pagename) req.redirect(req.href.blog()) template = 'fullblog_delete.html' data['blog_post'] = bp for field, reason in warnings: if field: add_warning(req, "Field '%s': %s" % (field, reason)) else: add_warning(req, reason) elif command.startswith('listing-'): # 2007/10 or category/something or author/theuser title = category = author = '' from_dt = to_dt = None if command == 'listing-month': from_dt = listing_data['from_dt'] to_dt = listing_data['to_dt'] title = "Posts for the month of %s %d" % ( blog_month_names[from_dt.month - 1], from_dt.year) add_link(req, 'alternate', req.href.blog(format='rss'), 'RSS Feed', 'application/rss+xml', 'rss') elif command == 'listing-category': category = listing_data['category'] if category: title = "Posts in category %s" % category add_link(req, 'alternate', req.href.blog('category', category, format='rss'), 'RSS Feed', 'application/rss+xml', 'rss') elif command == 'listing-author': author = listing_data['author'] if author: title = "Posts by author %s" % author add_link(req, 'alternate', req.href.blog('author', author, format='rss'), 'RSS Feed', 'application/rss+xml', 'rss') if not (author or category or (from_dt and to_dt)): raise HTTPNotFound("Not a valid path for viewing blog posts.") blog_posts = [] for post in get_blog_posts(self.env, category=category, author=author, from_dt=from_dt, to_dt=to_dt): bp = BlogPost(self.env, post[0], post[1]) if 'BLOG_VIEW' in req.perm(bp.resource): blog_posts.append(bp) data['blog_post_list'] = blog_posts data['blog_list_title'] = title else: raise HTTPNotFound("Not a valid blog path.") if (not command or command.startswith('listing-')) and format == 'rss': data['context'] = web_context(req, absurls=True) data['blog_num_items'] = self.num_items return 'fullblog.rss', data, 'application/rss+xml' data['blog_months'], data['blog_authors'], data['blog_categories'], \ data['blog_total'] = \ blog_core.get_months_authors_categories( user=req.authname, perm=req.perm) if 'BLOG_CREATE' in req.perm('blog'): add_ctxtnav(req, 'New Post', href=req.href.blog('create'), title="Create new Blog Post") add_stylesheet(req, 'tracfullblog/css/fullblog.css') add_stylesheet(req, 'common/css/code.css') data['blog_personal_blog'] = self.env.config.getbool( 'fullblog', 'personal_blog') data['blog_archive_rss_icon'] = self.all_rss_icons \ or self.archive_rss_icon data['blog_all_rss_icons'] = self.all_rss_icons return (template, data, None) # ISearchSource methods def get_search_filters(self, req): """Return a list of filters that this search source supports. Each filter must be a `(name, label[, default])` tuple, where `name` is the internal name, `label` is a human-readable name for display and `default` is an optional boolean for determining whether this filter is searchable by default. """ if 'BLOG_VIEW' in req.perm('blog', id=None): yield ('blog', 'Blog') def get_search_results(self, req, terms, filters): """Return a list of search results matching each search term in `terms`. The `filters` parameters is a list of the enabled filters, each item being the name of the tuples returned by `get_search_events`. The events returned by this function must be tuples of the form `(href, title, date, author, excerpt).` """ blog_realm = Resource('blog') if not 'BLOG_VIEW' in req.perm(blog_realm): return if 'blog' in filters: # Blog posts results = search_blog_posts(self.env, terms) for name, version, publish_time, author, title, body in results: bp_resource = blog_realm(id=name, version=version) if 'BLOG_VIEW' in req.perm(bp_resource): yield (req.href.blog(name), 'Blog: ' + title, publish_time, author, shorten_result(text=body, keywords=terms)) # Blog comments results = search_blog_comments(self.env, terms) for post_name, comment_number, comment, comment_author, \ comment_time in results: bp_resource = blog_realm(id=post_name, version=None) if 'BLOG_VIEW' in req.perm(bp_resource): bp = BlogPost(self.env, post_name) yield (req.href.blog(post_name) + '#comment-' + str(comment_number), 'Blog: ' + bp.title + ' (Comment ' + str(comment_number) + ')', comment_time, comment_author, shorten_result(text=comment, keywords=terms)) # ITimelineEventProvider methods def get_timeline_filters(self, req): if 'BLOG_VIEW' in req.perm('blog', id=None): yield ('blog', _('Blog details')) def get_timeline_events(self, req, start, stop, filters): if 'blog' in filters: blog_realm = Resource('blog') if not 'BLOG_VIEW' in req.perm(blog_realm): return add_stylesheet(req, 'tracfullblog/css/fullblog.css') # Blog posts blog_posts = get_blog_posts(self.env, from_dt=start, to_dt=stop, all_versions=True) for name, version, time, author, title, body, category_list \ in blog_posts: bp_resource = blog_realm(id=name, version=version) if 'BLOG_VIEW' not in req.perm(bp_resource): continue bp = BlogPost(self.env, name, version=version) yield ('blog', bp.version_time, bp.version_author, (bp_resource, bp, None)) # Attachments (will be rendered by attachment module) for event in AttachmentModule(self.env).get_timeline_events( req, blog_realm, start, stop): yield event # Blog comments blog_comments = get_blog_comments(self.env, from_dt=start, to_dt=stop) blog_comments = sorted(blog_comments, key=itemgetter(4), reverse=True) for post_name, number, comment, author, time in blog_comments: bp_resource = blog_realm(id=post_name) if 'BLOG_VIEW' not in req.perm(bp_resource): continue bp = BlogPost(self.env, post_name) bc = BlogComment(self.env, post_name, number=number) yield ('blog', time, author, (bp_resource, bp, bc)) def render_timeline_event(self, context, field, event): bp_resource, bp, bc = event[3] compat_format_0_11_2 = 'oneliner' if hasattr(context, '_hints'): compat_format_0_11_2 = None if bc: # A blog comment if field == 'url': return context.href.blog(bp.name) + '#comment-%d' % bc.number elif field == 'title': return tag('Blog: ', tag.em(bp.title), ' comment added') elif field == 'description': comment = compat_format_0_11_2 and shorten_line(bc.comment) \ or bc.comment return format_to(self.env, compat_format_0_11_2, context(resource=bp_resource), comment) else: # A blog post if field == 'url': return context.href.blog(bp.name) elif field == 'title': return tag('Blog: ', tag.em(bp.title), bp.version > 1 and ' edited' or ' created') elif field == 'description': comment = compat_format_0_11_2 and shorten_line(bp.version_comment) \ or bp.version_comment return format_to(self.env, compat_format_0_11_2, context(resource=bp_resource), comment) # ITemplateProvider methods def get_htdocs_dirs(self): """ Makes the 'htdocs' folder inside the egg available. """ return [('tracfullblog', resource_filename('tracfullblog', 'htdocs'))] def get_templates_dirs(self): """ Location of Trac templates provided by plugin. """ return [resource_filename('tracfullblog', 'templates')] # Internal methods def _parse_path(self, req): """ Parses the request path for the blog and returns a ('command', 'pagename', 'path_items', 'listing_data') tuple. """ # Parse out the path and actions from args path = req.args.get('blog_path', '') path_items = path.split('/') path_items = [item for item in path_items if item] # clean out empties command = pagename = '' listing_data = {} from_dt, to_dt = parse_period(path_items) if not path_items: pass # emtpy default for return is fine elif len(path_items) > 1 and path_items[0].lower() in [ 'view', 'edit', 'delete' ]: command = path_items[0].lower() pagename = '/'.join(path_items[1:]) elif len(path_items) == 1 and path_items[0].lower() == 'archive': command = path_items[0].lower() elif len(path_items) >= 1 and path_items[0].lower() == 'create': command = path_items[0].lower() pagename = req.args.get('name','') or (len(path_items) > 1 \ and '/'.join(path_items[1:])) elif len(path_items) > 1 and path_items[0].lower() in [ 'author', 'category' ]: command = 'listing' + '-' + path_items[0].lower() listing_data[path_items[0].lower()] = '/'.join(path_items[1:]) elif len(path_items) == 2 and (from_dt, to_dt) != (None, None): command = 'listing-month' listing_data['from_dt'] = from_dt listing_data['to_dt'] = to_dt else: # A request for a regular page command = 'view' pagename = path return (command, pagename, path_items, listing_data)
class GitConnector(Component): implements(IRepositoryConnector, ISystemInfoProvider, IWikiSyntaxProvider) def __init__(self): self._version = None try: self._version = PyGIT.Storage.git_version(git_bin=self.git_bin) except PyGIT.GitError as e: self.log.error("GitError: %s", e) if self._version: self.log.info("detected GIT version %s", self._version['v_str']) if not self._version['v_compatible']: self.log.error( "GIT version %s installed not compatible" "(need >= %s)", self._version['v_str'], self._version['v_min_str']) # ISystemInfoProvider methods def get_system_info(self): if self._version: yield 'GIT', self._version['v_str'] # IWikiSyntaxProvider methods def _format_sha_link(self, formatter, sha, label): # FIXME: this function needs serious rethinking... reponame = '' context = formatter.context while context: if context.resource.realm in ('source', 'changeset'): reponame = context.resource.parent.id break context = context.parent try: repos = self.env.get_repository(reponame) if not repos: raise Exception("Repository '%s' not found" % reponame) sha = repos.normalize_rev(sha) # in case it was abbreviated changeset = repos.get_changeset(sha) return tag.a(label, class_='changeset', title=shorten_line(changeset.message), href=formatter.href.changeset(sha, repos.reponame)) except Exception as e: return tag.a(label, class_='missing changeset', title=to_unicode(e), rel='nofollow') def get_wiki_syntax(self): yield (r'(?:\b|!)r?[0-9a-fA-F]{%d,40}\b' % self.wiki_shortrev_len, lambda fmt, sha, match: self._format_sha_link( fmt, sha.startswith('r') and sha[1:] or sha, sha)) def get_link_resolvers(self): yield ('sha', lambda fmt, _, sha, label, match=None: self. _format_sha_link(fmt, sha, label)) # IRepositoryConnector methods persistent_cache = BoolOption( 'git', 'persistent_cache', 'false', """Enable persistent caching of commit tree.""") cached_repository = BoolOption( 'git', 'cached_repository', 'false', """Wrap `GitRepository` in `CachedRepository`.""") shortrev_len = IntOption( 'git', 'shortrev_len', 7, """The length at which a sha1 should be abbreviated to (must be >= 4 and <= 40). """) wiki_shortrev_len = IntOption( 'git', 'wikishortrev_len', 40, """The minimum length of an hex-string for which auto-detection as sha1 is performed (must be >= 4 and <= 40). """) trac_user_rlookup = BoolOption( 'git', 'trac_user_rlookup', 'false', """Enable reverse mapping of git email addresses to trac user ids. Performance will be reduced if there are many users and the `cached_repository` option is `disabled`. A repository resync is required after changing the value of this option. """) use_committer_id = BoolOption( 'git', 'use_committer_id', 'true', """Use git-committer id instead of git-author id for the changeset ''Author'' field. """) use_committer_time = BoolOption( 'git', 'use_committer_time', 'true', """Use git-committer timestamp instead of git-author timestamp for the changeset ''Timestamp'' field. """) git_fs_encoding = Option( 'git', 'git_fs_encoding', 'utf-8', """Define charset encoding of paths within git repositories.""") git_bin = Option('git', 'git_bin', 'git', """Path to the git executable.""") def get_supported_types(self): yield ('git', 8) def get_repository(self, type, dir, params): """GitRepository factory method""" assert type == 'git' if not (4 <= self.shortrev_len <= 40): raise TracError( _("%(option)s must be in the range [4..40]", option="[git] shortrev_len")) if not (4 <= self.wiki_shortrev_len <= 40): raise TracError( _("%(option)s must be in the range [4..40]", option="[git] wikishortrev_len")) if not self._version: raise TracError(_("GIT backend not available")) elif not self._version['v_compatible']: raise TracError( _( "GIT version %(hasver)s installed not " "compatible (need >= %(needsver)s)", hasver=self._version['v_str'], needsver=self._version['v_min_str'])) if self.trac_user_rlookup: def rlookup_uid(email): """Reverse map 'real name <*****@*****.**>' addresses to trac user ids. :return: `None` if lookup failed """ try: _, email = email.rsplit('<', 1) email, _ = email.split('>', 1) email = email.lower() except Exception: return None for _uid, _name, _email in self.env.get_known_users(): try: if email == _email.lower(): return _uid except Exception: continue else: def rlookup_uid(_): return None repos = GitRepository( self.env, dir, params, self.log, persistent_cache=self.persistent_cache, git_bin=self.git_bin, git_fs_encoding=self.git_fs_encoding, shortrev_len=self.shortrev_len, rlookup_uid=rlookup_uid, use_committer_id=self.use_committer_id, use_committer_time=self.use_committer_time, ) if self.cached_repository: repos = GitCachedRepository(self.env, repos, self.log) self.log.debug("enabled CachedRepository for '%s'", dir) else: self.log.debug("disabled CachedRepository for '%s'", dir) return repos
self._format_sha_link(fmt, 'changeset', sha, sha)) def get_link_resolvers(self): yield ('sha', self._format_sha_link) ####################### # IRepositoryConnector _persistent_cache = BoolOption('git', 'persistent_cache', 'false', "enable persistent caching of commit tree") _cached_repository = BoolOption('git', 'cached_repository', 'false', "wrap `GitRepository` in `CachedRepository`") _shortrev_len = IntOption('git', 'shortrev_len', 7, "length rev sha sums should be tried to be abbreviated to" " (must be >= 4 and <= 40)") _git_bin = PathOption('git', 'git_bin', '/usr/bin/git', "path to git executable (relative to trac project folder!)") def get_supported_types(self): yield ("git", 8) def get_repository(self, type, dir, authname): """GitRepository factory method""" assert type == "git" if not self._version: raise TracError("GIT backend not available") elif not self._version['v_compatible']: