Ejemplo n.º 1
0
    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)
Ejemplo n.º 2
0
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)
Ejemplo n.º 3
0
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'])
Ejemplo n.º 4
0
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()
Ejemplo n.º 5
0
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
Ejemplo n.º 6
0
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.""")
Ejemplo n.º 7
0
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)
Ejemplo n.º 8
0
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
Ejemplo n.º 9
0
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
Ejemplo n.º 10
0
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
Ejemplo n.º 11
0
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}
Ejemplo n.º 12
0
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}
Ejemplo n.º 13
0
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)
Ejemplo n.º 14
0
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')
Ejemplo n.º 15
0
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(' &rarr;')))

        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)
Ejemplo n.º 16
0
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
Ejemplo n.º 17
0
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]
Ejemplo n.º 18
0
Archivo: env.py Proyecto: hanotch/trac
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)
Ejemplo n.º 19
0
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
Ejemplo n.º 20
0
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)
Ejemplo n.º 21
0
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)
Ejemplo n.º 22
0
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
Ejemplo n.º 23
0
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)))
Ejemplo n.º 24
0
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
Ejemplo n.º 25
0
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
Ejemplo n.º 26
0
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')
Ejemplo n.º 27
0
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)
Ejemplo n.º 28
0
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)
Ejemplo n.º 29
0
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
Ejemplo n.º 30
0
                               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']: