allura-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From hei...@apache.org
Subject [32/45] allura git commit: [#7878] Used 2to3 to see what issues would come up
Date Fri, 29 May 2015 20:40:54 GMT
http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/controllers/auth.py
----------------------------------------------------------------------
diff --git a/controllers/auth.py b/controllers/auth.py
new file mode 100644
index 0000000..0e95e09
--- /dev/null
+++ b/controllers/auth.py
@@ -0,0 +1,1065 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+import logging
+import os
+import datetime
+import re
+
+import bson
+import tg
+from tg import expose, flash, redirect, validate, config, session
+from tg.decorators import with_trailing_slash, without_trailing_slash
+from pylons import tmpl_context as c, app_globals as g
+from pylons import request, response
+from webob import exc as wexc
+from paste.deploy.converters import asbool
+from urllib.parse import urlparse, urljoin
+
+import allura.tasks.repo_tasks
+from allura import model as M
+from allura.lib import validators as V
+from allura.lib.security import require_authenticated, has_access
+from allura.lib import helpers as h
+from allura.lib import plugin
+from allura.lib.decorators import require_post
+from allura.lib.repository import RepositoryApp
+from allura.lib.widgets import (
+    SubscriptionForm,
+    OAuthApplicationForm,
+    OAuthRevocationForm,
+    LoginForm,
+    ForgottenPasswordForm,
+    DisableAccountForm)
+from allura.lib.widgets import forms, form_fields as ffw
+from allura.lib import mail_util
+from allura.controllers import BaseController
+
+log = logging.getLogger(__name__)
+
+class F(object):
+    login_form = LoginForm()
+    password_change_form = forms.PasswordChangeForm(action='/auth/preferences/change_password')
+    upload_key_form = forms.UploadKeyForm(action='/auth/preferences/upload_sshkey')
+    recover_password_change_form = forms.PasswordChangeBase()
+    forgotten_password_form = ForgottenPasswordForm()
+    subscription_form = SubscriptionForm()
+    registration_form = forms.RegistrationForm(action='/auth/save_new')
+    oauth_application_form = OAuthApplicationForm(action='register')
+    oauth_revocation_form = OAuthRevocationForm(
+        action='/auth/preferences/revoke_oauth')
+    change_personal_data_form = forms.PersonalDataForm()
+    add_socialnetwork_form = forms.AddSocialNetworkForm()
+    remove_socialnetwork_form = forms.RemoveSocialNetworkForm()
+    add_telnumber_form = forms.AddTelNumberForm()
+    add_website_form = forms.AddWebsiteForm()
+    skype_account_form = forms.SkypeAccountForm()
+    remove_textvalue_form = forms.RemoveTextValueForm()
+    add_timeslot_form = forms.AddTimeSlotForm()
+    remove_timeslot_form = forms.RemoveTimeSlotForm()
+    add_inactive_period_form = forms.AddInactivePeriodForm()
+    remove_inactive_period_form = forms.RemoveInactivePeriodForm()
+    save_skill_form = forms.AddUserSkillForm()
+    remove_skill_form = forms.RemoveSkillForm()
+    disable_account_form = DisableAccountForm()
+
+
+class AuthController(BaseController):
+
+    def __init__(self):
+        self.preferences = PreferencesController()
+        self.user_info = UserInfoController()
+        self.subscriptions = SubscriptionsController()
+        self.oauth = OAuthController()
+        if asbool(config.get('auth.allow_user_to_disable_account', False)):
+            self.disable = DisableAccountController()
+
+    def __getattr__(self, name):
+        urls = plugin.UserPreferencesProvider.get().additional_urls()
+        if name not in urls:
+            raise AttributeError("'%s' object has no attribute '%s'" % (type(self).__name__, name))
+        return urls[name]
+
+    @expose()
+    def prefs(self, *args, **kwargs):
+        '''
+        Redirect old /auth/prefs URL to /auth/subscriptions
+        (to handle old email links, etc).
+        '''
+        redirect('/auth/subscriptions/')
+
+    @expose('jinja:allura:templates/login.html')
+    @with_trailing_slash
+    def index(self, *args, **kwargs):
+        orig_request = request.environ.get('pylons.original_request', None)
+        if 'return_to' in kwargs:
+            return_to = kwargs.pop('return_to')
+        elif orig_request:
+            return_to = orig_request.url
+        else:
+            return_to = request.referer
+        c.form = F.login_form
+        return dict(return_to=return_to)
+
+    @expose('jinja:allura:templates/login_fragment.html')
+    def login_fragment(self, *args, **kwargs):
+        return self.index(*args, **kwargs)
+
+    @expose('jinja:allura:templates/create_account.html')
+    def create_account(self, **kw):
+        if not asbool(config.get('auth.allow_user_registration', True)):
+            raise wexc.HTTPNotFound()
+        c.form = F.registration_form
+        return dict()
+
+    def _validate_hash(self, hash):
+        login_url = config.get('auth.login_url', '/auth/')
+        if not hash:
+            redirect(login_url)
+        user_record = M.User.query.find(
+            {'tool_data.AuthPasswordReset.hash': hash}).first()
+        if not user_record:
+            log.info('Reset hash not found: {}'.format(hash))
+            flash('Unable to process reset, please try again')
+            redirect(login_url)
+        hash_expiry = user_record.get_tool_data(
+            'AuthPasswordReset', 'hash_expiry')
+        if not hash_expiry or hash_expiry < datetime.datetime.utcnow():
+            log.info('Reset hash expired: {} {}'.format(hash, hash_expiry))
+            flash('Unable to process reset, please try again')
+            redirect(login_url)
+        return user_record
+
+    @expose('jinja:allura:templates/forgotten_password.html')
+    def forgotten_password(self, hash=None, **kw):
+        provider = plugin.AuthenticationProvider.get(request)
+        if not provider.forgotten_password_process:
+            raise wexc.HTTPNotFound()
+        user_record = None
+        if not hash:
+            c.forgotten_password_form = F.forgotten_password_form
+        else:
+            user_record = self._validate_hash(hash)
+            c.recover_password_change_form = F.recover_password_change_form
+        return dict(hash=hash, user_record=user_record)
+
+    @expose()
+    @require_post()
+    @validate(F.recover_password_change_form, error_handler=forgotten_password)
+    def set_new_password(self, hash=None, pw=None, pw2=None):
+        provider = plugin.AuthenticationProvider.get(request)
+        if not provider.forgotten_password_process:
+            raise wexc.HTTPNotFound()
+        user = self._validate_hash(hash)
+        user.set_password(pw)
+        user.set_tool_data('AuthPasswordReset', hash='', hash_expiry='')  # Clear password reset token
+        h.auditlog_user('Password changed (through recovery process)', user=user)
+        flash('Password changed')
+        redirect('/auth/?return_to=/')  # otherwise the default return_to would be the forgotten_password referrer page
+
+    @expose()
+    @require_post()
+    def password_recovery_hash(self, email=None, **kw):
+        provider = plugin.AuthenticationProvider.get(request)
+        if not provider.forgotten_password_process:
+            raise wexc.HTTPNotFound()
+        if not email:
+            redirect('/')
+
+        user_record = M.User.by_email_address(email)
+        allow_non_primary_email_reset = asbool(config.get('auth.allow_non_primary_email_password_reset', True))
+
+
+        if not re.match(r"[^@]+@[^@]+\.[^@]+", email):
+            flash('Enter email in correct format!','error')
+            redirect('/auth/forgotten_password')
+
+        if not allow_non_primary_email_reset:
+            message = 'If the given email address is on record, a password reset email has been sent to the account\'s primary email address.'
+            email_record = M.EmailAddress.get(email=provider.get_primary_email_address(user_record=user_record),
+                                                    confirmed=True)
+        else:
+            message = 'A password reset email has been sent, if the given email address is on record in our system.'
+            email_record = M.EmailAddress.get(email=email, confirmed=True)
+
+
+        if user_record and email_record and email_record.confirmed:
+            hash = h.nonce(42)
+            user_record.set_tool_data('AuthPasswordReset',
+                                      hash=hash,
+                                      hash_expiry=datetime.datetime.utcnow() +
+                                      datetime.timedelta(seconds=int(config.get('auth.recovery_hash_expiry_period', 600))))
+
+            log.info('Sending password recovery link to %s', email_record.email)
+            subject = '%s Password recovery' % config['site_name']
+            text = g.jinja2_env.get_template('allura:templates/mail/forgot_password.txt').render(dict(
+                user=user_record,
+                config=config,
+                hash=hash,
+            ))
+
+            allura.tasks.mail_tasks.sendsimplemail.post(
+                toaddr=email_record.email,
+                fromaddr=config['forgemail.return_path'],
+                reply_to=config['forgemail.return_path'],
+                subject=subject,
+                message_id=h.gen_message_id(),
+                text=text)
+        h.auditlog_user('Password recovery link sent to: %s', email, user=user_record)
+        flash(message)
+        redirect('/')
+
+    @expose()
+    @require_post()
+    @validate(F.registration_form, error_handler=create_account)
+    def save_new(self, display_name=None, username=None, pw=None, email=None, **kw):
+        if not asbool(config.get('auth.allow_user_registration', True)):
+            raise wexc.HTTPNotFound()
+        require_email = asbool(config.get('auth.require_email_addr', False))
+        user = M.User.register(
+            dict(username=username,
+                 display_name=display_name,
+                 password=pw,
+                 pending=require_email))
+        user.set_tool_data('allura', pwd_reset_preserve_session=session.id)  # else the first password set causes this session to be invalidated
+        if require_email:
+            em = user.claim_address(email)
+            if em:
+                em.send_verification_link()
+            flash('User "%s" registered. Verification link was sent to your email.' % username)
+        else:
+            plugin.AuthenticationProvider.get(request).login(user)
+            flash('User "%s" registered' % username)
+        redirect('/')
+
+    @expose()
+    def send_verification_link(self, a):
+        addr = M.EmailAddress.get(email=a, claimed_by_user_id=c.user._id)
+        confirmed_emails = M.EmailAddress.find(dict(email=a, confirmed=True)).all()
+        confirmed_emails = [item for item in confirmed_emails if item != addr]
+
+        if addr:
+            if any(email.confirmed for email in confirmed_emails):
+                addr.send_claim_attempt()
+            else:
+                addr.send_verification_link()
+            flash('Verification link sent')
+        else:
+            flash('No such address', 'error')
+        redirect(request.referer)
+
+    def _verify_addr(self, addr):
+        confirmed_by_other = M.EmailAddress.find(dict(email=addr.email, confirmed=True)).all() if addr else []
+        confirmed_by_other = [item for item in confirmed_by_other if item != addr]
+
+        if addr and not confirmed_by_other:
+            addr.confirmed = True
+            user = addr.claimed_by_user(include_pending=True)
+            flash('Email address confirmed')
+            h.auditlog_user('Email address verified: %s',  addr.email, user=user)
+            if user.pending:
+                plugin.AuthenticationProvider.get(request).activate_user(user)
+        else:
+            flash('Unknown verification link', 'error')
+
+    @expose()
+    def verify_addr(self, a):
+        addr = M.EmailAddress.get(nonce=a)
+        self._verify_addr(addr)
+        redirect('/auth/preferences/')
+
+    @expose()
+    def logout(self):
+        plugin.AuthenticationProvider.get(request).logout()
+        redirect(config.get('auth.post_logout_url', '/'))
+
+    @expose()
+    @require_post()
+    @validate(F.login_form, error_handler=index)
+    def do_login(self, return_to=None, **kw):
+        location = '/'
+
+        if session.get('expired-username'):
+            if return_to and return_to not in plugin.AuthenticationProvider.pwd_expired_allowed_urls:
+                location = tg.url(plugin.AuthenticationProvider.pwd_expired_allowed_urls[0], dict(return_to=return_to))
+            else:
+                location = tg.url(plugin.AuthenticationProvider.pwd_expired_allowed_urls[0])
+        elif return_to and return_to != request.url:
+            rt_host = urlparse(urljoin(config['base_url'], return_to)).netloc
+            base_host = urlparse(config['base_url']).netloc
+            if rt_host == base_host:
+                location = return_to
+
+        redirect(location)
+
+    @expose(content_type='text/plain')
+    def refresh_repo(self, *repo_path):
+        # post-commit hooks use this
+        if not repo_path:
+            return 'No repo specified'
+        repo_path = '/' + '/'.join(repo_path)
+        project, rest = h.find_project(repo_path)
+        if project is None:
+            return 'No project at %s' % repo_path
+        if not rest:
+            return '%s does not include a repo mount point' % repo_path
+        h.set_context(project.shortname,
+                      rest[0], neighborhood=project.neighborhood)
+        if c.app is None or not getattr(c.app, 'repo'):
+            return 'Cannot find repo at %s' % repo_path
+        allura.tasks.repo_tasks.refresh.post()
+        return '%r refresh queued.\n' % c.app.repo
+
+    def _auth_repos(self, user):
+        def _unix_group_name(neighborhood, shortname):
+            path = neighborhood.url_prefix + \
+                shortname[len(neighborhood.shortname_prefix):]
+            parts = [p for p in path.split('/') if p]
+            if len(parts) == 2 and parts[0] == 'p':
+                parts = parts[1:]
+            return '.'.join(reversed(parts))
+
+        repos = []
+        for p in user.my_projects():
+            for p in [p] + p.direct_subprojects:
+                for app in p.app_configs:
+                    if not issubclass(g.entry_points["tool"][app.tool_name], RepositoryApp):
+                        continue
+                    if not has_access(app, 'write', user, p):
+                        continue
+                    repos.append('/%s/%s/%s' % (
+                        app.tool_name.lower(),
+                        _unix_group_name(p.neighborhood, p.shortname),
+                        app.options['mount_point']))
+        repos.sort()
+        return repos
+
+    @expose('json:')
+    def repo_permissions(self, repo_path=None, username=None, **kw):
+        """Expects repo_path to be a filesystem path like
+            <tool>/<project>.<neighborhood>/reponame[.git]
+        unless the <neighborhood> is 'p', in which case it is
+            <tool>/<project>/reponame[.git]
+
+        Returns JSON describing this user's permissions on that repo.
+        """
+        disallow = dict(allow_read=False, allow_write=False,
+                        allow_create=False)
+        # Find the user
+        user = M.User.by_username(username)
+        if not user:
+            response.status = 404
+            return dict(disallow, error='unknown user')
+        if not repo_path:
+            return dict(allow_write=self._auth_repos(user))
+
+        parts = [p for p in repo_path.split(os.path.sep) if p]
+        # strip the tool name
+        parts = parts[1:]
+        if '.' in parts[0]:
+            project, neighborhood = parts[0].split('.')
+        else:
+            project, neighborhood = parts[0], 'p'
+        parts = [neighborhood, project] + parts[1:]
+        project_path = '/' + '/'.join(parts)
+        project, rest = h.find_project(project_path)
+        if project is None:
+            log.info("Can't find project at %s from repo_path %s",
+                     project_path, repo_path)
+            response.status = 404
+            return dict(disallow, error='unknown project')
+        c.project = project
+        c.app = project.app_instance(rest[0])
+        if not c.app:
+            c.app = project.app_instance(os.path.splitext(rest[0])[0])
+        if c.app is None:
+            log.info("Can't find repo at %s on repo_path %s",
+                     rest[0], repo_path)
+            return disallow
+        return dict(allow_read=has_access(c.app, 'read')(user=user),
+                    allow_write=has_access(c.app, 'write')(user=user),
+                    allow_create=has_access(c.app, 'create')(user=user))
+
+    @expose('jinja:allura:templates/pwd_expired.html')
+    @without_trailing_slash
+    def pwd_expired(self, **kw):
+        require_authenticated()
+        c.form = F.password_change_form
+        return {'return_to': kw.get('return_to')}
+
+    @expose()
+    @require_post()
+    @without_trailing_slash
+    @validate(V.NullValidator(), error_handler=pwd_expired)
+    def pwd_expired_change(self, **kw):
+        require_authenticated()
+        return_to = kw.get('return_to')
+        kw = F.password_change_form.to_python(kw, None)
+        ap = plugin.AuthenticationProvider.get(request)
+        try:
+            expired_username = session.get('expired-username')
+            expired_user = M.User.query.get(username=expired_username) if expired_username else None
+            ap.set_password(expired_user or c.user, kw['oldpw'], kw['pw'])
+            expired_user.set_tool_data('allura', pwd_reset_preserve_session=session.id)
+            expired_user.set_tool_data('AuthPasswordReset', hash='', hash_expiry='')  # Clear password reset token
+
+        except wexc.HTTPUnauthorized:
+            flash('Incorrect password', 'error')
+            redirect(tg.url('/auth/pwd_expired', dict(return_to=return_to)))
+        flash('Password changed')
+        session.pop('pwd-expired', None)
+        session['username'] = session.get('expired-username')
+        session.pop('expired-username', None)
+
+        session.save()
+        h.auditlog_user('Password reset (via expiration process)')
+        if return_to and return_to != request.url:
+            redirect(return_to)
+        else:
+            redirect('/')
+
+
+def select_new_primary_addr(user, ignore_emails=[]):
+    for obj_e in user.email_addresses:
+        obj = user.address_object(obj_e)
+        if obj and obj.confirmed and obj_e not in ignore_emails:
+            return obj_e
+
+
+class PreferencesController(BaseController):
+
+    def _check_security(self):
+        require_authenticated()
+
+    @with_trailing_slash
+    @expose('jinja:allura:templates/user_prefs.html')
+    def index(self, **kw):
+        c.enter_password = ffw.Lightbox(name='enter_password')
+        c.password_change_form = F.password_change_form
+        c.upload_key_form = F.upload_key_form
+        provider = plugin.AuthenticationProvider.get(request)
+        menu = provider.account_navigation()
+        return dict(menu=menu, user=c.user)
+
+    def _update_emails(self, user, admin=False, form_params={}):
+        # not using **kw in method signature, to ensure 'admin' can't be passed in via a form submit
+        kw = form_params
+        addr = kw.pop('addr', None)
+        new_addr= kw.pop('new_addr', None)
+        primary_addr = kw.pop('primary_addr', None)
+        oid = kw.pop('oid', None)
+        new_oid = kw.pop('new_oid', None)
+        provider = plugin.AuthenticationProvider.get(request)
+        for i, (old_a, data) in enumerate(zip(user.email_addresses, addr or [])):
+            obj = user.address_object(old_a)
+            if data.get('delete') or not obj:
+                if not admin and (not kw.get('password') or not provider.validate_password(user, kw.get('password'))):
+                    flash('You must provide your current password to delete an email', 'error')
+                    return
+                if primary_addr == user.email_addresses[i]:
+                    if select_new_primary_addr(user, ignore_emails=primary_addr) is None \
+                            and asbool(config.get('auth.require_email_addr', False)):
+                        flash('You must have at least one verified email address.', 'error')
+                        return
+                    else:
+                        # clear it now, a new one will get set below
+                        user.set_pref('email_address', None)
+                        primary_addr = None
+                        user.set_tool_data('AuthPasswordReset', hash='', hash_expiry='')
+                h.auditlog_user('Email address deleted: %s', user.email_addresses[i], user=user)
+                del user.email_addresses[i]
+                if obj:
+                    obj.delete()
+        if new_addr.get('claim') or new_addr.get('addr'):
+            user.set_tool_data('AuthPasswordReset', hash='', hash_expiry='')  # Clear password reset token
+            claimed_emails_limit = config.get('user_prefs.maximum_claimed_emails', None)
+            if claimed_emails_limit and len(user.email_addresses) >= int(claimed_emails_limit):
+                flash('You cannot claim more than %s email addresses.' % claimed_emails_limit, 'error')
+                return
+            if not admin and (not kw.get('password') or not provider.validate_password(user, kw.get('password'))):
+                flash('You must provide your current password to claim new email', 'error')
+                return
+
+            claimed_emails = M.EmailAddress.find({'email': new_addr['addr']}).all()
+
+            if any(email.claimed_by_user_id == user._id for email in claimed_emails):
+                flash('Email address already claimed', 'error')
+
+            elif mail_util.isvalid(new_addr['addr']):
+                em = M.EmailAddress.create(new_addr['addr'])
+                if em:
+                    user.email_addresses.append(em.email)
+                    em.claimed_by_user_id = user._id
+
+                    confirmed_emails = [email for email in claimed_emails if email.confirmed]
+                    if not confirmed_emails:
+                        if not admin:
+                            em.send_verification_link()
+                        else:
+                            AuthController()._verify_addr(em)
+                    else:
+                        em.send_claim_attempt()
+
+                    if not admin:
+                        user.set_tool_data('AuthPasswordReset', hash='', hash_expiry='')
+                        flash('A verification email has been sent.  Please check your email and click to confirm.')
+
+                    h.auditlog_user('New email address: %s', new_addr['addr'], user=user)
+                else:
+                    flash('Email address %s is invalid' % new_addr['addr'], 'error')
+            else:
+                flash('Email address %s is invalid' % new_addr['addr'], 'error')
+        if not primary_addr and not user.get_pref('email_address') and user.email_addresses:
+            primary_addr = select_new_primary_addr(user)
+        if primary_addr:
+            if user.get_pref('email_address') != primary_addr:
+                if not admin and (not kw.get('password') or not provider.validate_password(user, kw.get('password'))):
+                    flash('You must provide your current password to change primary address', 'error')
+                    return
+                h.auditlog_user(
+                    'Primary email changed: %s => %s',
+                    user.get_pref('email_address'),
+                    primary_addr,
+                    user=user)
+            user.set_pref('email_address', primary_addr)
+            user.set_tool_data('AuthPasswordReset', hash='', hash_expiry='')
+
+    @h.vardec
+    @expose()
+    @require_post()
+    def update_emails(self, **kw):
+        if asbool(config.get('auth.allow_edit_prefs', True)):
+            self._update_emails(c.user, form_params=kw)
+        redirect('.')
+
+    @h.vardec
+    @expose()
+    @require_post()
+    def update(self, preferences=None, **kw):
+        if asbool(config.get('auth.allow_edit_prefs', True)):
+            if not preferences.get('display_name'):
+                flash("Display Name cannot be empty.", 'error')
+                redirect('.')
+            old = c.user.get_pref('display_name')
+            c.user.set_pref('display_name', preferences['display_name'])
+            if old != preferences['display_name']:
+                h.auditlog_user('Display Name changed %s => %s', old, preferences['display_name'])
+            for k, v in preferences.items():
+                if k == 'results_per_page':
+                    v = int(v)
+                c.user.set_pref(k, v)
+        redirect('.')
+
+    @expose()
+    @require_post()
+    @validate(V.NullValidator(), error_handler=index)
+    def change_password(self, **kw):
+        kw = F.password_change_form.to_python(kw, None)
+        ap = plugin.AuthenticationProvider.get(request)
+        try:
+            ap.set_password(c.user, kw['oldpw'], kw['pw'])
+            c.user.set_tool_data('allura', pwd_reset_preserve_session=session.id)
+            c.user.set_tool_data('AuthPasswordReset', hash='', hash_expiry='')
+
+        except wexc.HTTPUnauthorized:
+            flash('Incorrect password', 'error')
+            redirect('.')
+        flash('Password changed')
+        h.auditlog_user('Password changed')
+        redirect('.')
+
+    @expose()
+    @require_post()
+    def upload_sshkey(self, key=None):
+        ap = plugin.AuthenticationProvider.get(request)
+        try:
+            ap.upload_sshkey(c.user.username, key)
+        except AssertionError as ae:
+            flash('Error uploading key: %s' % ae, 'error')
+        flash('Key uploaded')
+        redirect('.')
+
+    @expose()
+    @require_post()
+    def user_message(self, allow_user_messages=False):
+        c.user.set_pref('disable_user_messages', not allow_user_messages)
+        redirect(request.referer)
+
+
+class UserInfoController(BaseController):
+
+    def __init__(self, *args, **kwargs):
+        self.skills = UserSkillsController()
+        self.contacts = UserContactsController()
+        self.availability = UserAvailabilityController()
+
+    def _check_security(self):
+        require_authenticated()
+
+    @with_trailing_slash
+    @expose('jinja:allura:templates/user_info.html')
+    def index(self, **kw):
+        provider = plugin.AuthenticationProvider.get(request)
+        menu = provider.account_navigation()
+        return dict(menu=menu)
+
+    @expose()
+    @require_post()
+    @validate(F.change_personal_data_form, error_handler=index)
+    def change_personal_data(self, **kw):
+        require_authenticated()
+        c.user.set_pref('sex', kw['sex'])
+        c.user.set_pref('birthdate', kw.get('birthdate'))
+        localization = {'country': kw.get('country'), 'city': kw.get('city')}
+        c.user.set_pref('localization', localization)
+        c.user.set_pref('timezone', kw['timezone'])
+
+        flash('Your personal data was successfully updated!')
+        redirect('.')
+
+
+class UserSkillsController(BaseController):
+
+    def __init__(self, category=None):
+        self.category = category
+        super(UserSkillsController, self).__init__()
+
+    def _check_security(self):
+        require_authenticated()
+
+    @expose()
+    def _lookup(self, catshortname, *remainder):
+        cat = M.TroveCategory.query.get(shortname=catshortname)
+        return UserSkillsController(category=cat), remainder
+
+    @with_trailing_slash
+    @expose('jinja:allura:templates/user_skills.html')
+    def index(self, **kw):
+        l = []
+        parents = []
+        if kw.get('selected_category') is not None:
+            selected_skill = M.TroveCategory.query.get(
+                trove_cat_id=int(kw.get('selected_category')))
+        elif self.category:
+            selected_skill = self.category
+        else:
+            l = M.TroveCategory.query.find(
+                dict(trove_parent_id=0, show_as_skill=True)).all()
+            selected_skill = None
+        if selected_skill:
+            l = [scat for scat in selected_skill.subcategories
+                 if scat.show_as_skill]
+            temp_cat = selected_skill.parent_category
+            while temp_cat:
+                parents = [temp_cat] + parents
+                temp_cat = temp_cat.parent_category
+        provider = plugin.AuthenticationProvider.get(request)
+        menu = provider.account_navigation()
+        return dict(
+            skills_list=l,
+            selected_skill=selected_skill,
+            parents=parents,
+            menu=menu,
+            add_details_fields=(len(l) == 0))
+
+    @expose()
+    @require_post()
+    @validate(F.save_skill_form, error_handler=index)
+    def save_skill(self, **kw):
+        trove_id = int(kw.get('selected_skill'))
+        category = M.TroveCategory.query.get(trove_cat_id=trove_id)
+
+        new_skill = dict(
+            category_id=category._id,
+            level=kw.get('level'),
+            comment=kw.get('comment'))
+
+        s = [skill for skill in c.user.skills
+             if str(skill.category_id) != str(new_skill['category_id'])]
+        s.append(new_skill)
+        c.user.set_pref('skills', s)
+        flash('Your skills list was successfully updated!')
+        redirect('.')
+
+    @expose()
+    @require_post()
+    @validate(F.remove_skill_form, error_handler=index)
+    def remove_skill(self, **kw):
+        trove_id = int(kw.get('categoryid'))
+        category = M.TroveCategory.query.get(trove_cat_id=trove_id)
+
+        s = [skill for skill in c.user.skills
+             if str(skill.category_id) != str(category._id)]
+        c.user.set_pref('skills', s)
+        flash('Your skills list was successfully updated!')
+        redirect('.')
+
+
+class UserContactsController(BaseController):
+
+    def _check_security(self):
+        require_authenticated()
+
+    @with_trailing_slash
+    @expose('jinja:allura:templates/user_contacts.html')
+    def index(self, **kw):
+        provider = plugin.AuthenticationProvider.get(request)
+        menu = provider.account_navigation()
+        return dict(menu=menu)
+
+    @expose()
+    @require_post()
+    @validate(F.add_socialnetwork_form, error_handler=index)
+    def add_social_network(self, **kw):
+        require_authenticated()
+        c.user.add_socialnetwork(kw['socialnetwork'], kw['accounturl'])
+        flash('Your personal contacts were successfully updated!')
+        redirect('.')
+
+    @expose()
+    @require_post()
+    @validate(F.remove_socialnetwork_form, error_handler=index)
+    def remove_social_network(self, **kw):
+        require_authenticated()
+        c.user.remove_socialnetwork(kw['socialnetwork'], kw['account'])
+        flash('Your personal contacts were successfully updated!')
+        redirect('.')
+
+    @expose()
+    @require_post()
+    @validate(F.add_telnumber_form, error_handler=index)
+    def add_telnumber(self, **kw):
+        require_authenticated()
+        c.user.add_telephonenumber(kw['newnumber'])
+        flash('Your personal contacts were successfully updated!')
+        redirect('.')
+
+    @expose()
+    @require_post()
+    @validate(F.remove_textvalue_form, error_handler=index)
+    def remove_telnumber(self, **kw):
+        require_authenticated()
+        c.user.remove_telephonenumber(kw['oldvalue'])
+        flash('Your personal contacts were successfully updated!')
+        redirect('.')
+
+    @expose()
+    @require_post()
+    @validate(F.add_website_form, error_handler=index)
+    def add_webpage(self, **kw):
+        require_authenticated()
+        c.user.add_webpage(kw['newwebsite'])
+        flash('Your personal contacts were successfully updated!')
+        redirect('.')
+
+    @expose()
+    @require_post()
+    @validate(F.remove_textvalue_form, error_handler=index)
+    def remove_webpage(self, **kw):
+        require_authenticated()
+        c.user.remove_webpage(kw['oldvalue'])
+        flash('Your personal contacts were successfully updated!')
+        redirect('.')
+
+    @expose()
+    @require_post()
+    @validate(F.skype_account_form, error_handler=index)
+    def skype_account(self, **kw):
+        require_authenticated()
+        c.user.set_pref('skypeaccount', kw['skypeaccount'])
+        flash('Your personal contacts were successfully updated!')
+        redirect('.')
+
+
+class UserAvailabilityController(BaseController):
+
+    def _check_security(self):
+        require_authenticated()
+
+    @with_trailing_slash
+    @expose('jinja:allura:templates/user_availability.html')
+    def index(self, **kw):
+        provider = plugin.AuthenticationProvider.get(request)
+        menu = provider.account_navigation()
+        return dict(menu=menu)
+
+    @expose()
+    @require_post()
+    @validate(F.add_timeslot_form, error_handler=index)
+    def add_timeslot(self, **kw):
+        require_authenticated()
+        c.user.add_timeslot(kw['weekday'], kw['starttime'], kw['endtime'])
+        flash('Your availability timeslots were successfully updated!')
+        redirect('.')
+
+    @expose()
+    @require_post()
+    @validate(F.remove_timeslot_form, error_handler=index)
+    def remove_timeslot(self, **kw):
+        require_authenticated()
+        c.user.remove_timeslot(kw['weekday'], kw['starttime'], kw['endtime'])
+        flash('Your availability timeslots were successfully updated!')
+        redirect('.')
+
+    @expose()
+    @require_post()
+    @validate(F.add_inactive_period_form, error_handler=index)
+    def add_inactive_period(self, **kw):
+        require_authenticated()
+        c.user.add_inactive_period(kw['startdate'], kw['enddate'])
+        flash('Your inactivity periods were successfully updated!')
+        redirect('.')
+
+    @expose()
+    @require_post()
+    @validate(F.remove_inactive_period_form, error_handler=index)
+    def remove_inactive_period(self, **kw):
+        require_authenticated()
+        c.user.remove_inactive_period(kw['startdate'], kw['enddate'])
+        flash('Your availability timeslots were successfully updated!')
+        redirect('.')
+
+
+class SubscriptionsController(BaseController):
+    """ Gives users the ability to manage subscriptions to tools. """
+
+    def _check_security(self):
+        require_authenticated()
+
+    @with_trailing_slash
+    @expose('jinja:allura:templates/user_subs.html')
+    def index(self, **kw):
+        """ The subscription selection page in user preferences.
+
+        Builds up a list of dictionaries, each containing subscription
+        information about a tool.
+        """
+        c.form = F.subscription_form
+        c.revoke_access = F.oauth_revocation_form
+
+        subscriptions = []
+        mailboxes = list(M.Mailbox.query.find(
+            dict(user_id=c.user._id, is_flash=False)))
+        projects = dict(
+            (p._id, p) for p in M.Project.query.find(dict(
+                _id={'$in': [mb.project_id for mb in mailboxes]})))
+        app_index = dict(
+            (ac._id, ac) for ac in M.AppConfig.query.find(dict(
+                _id={'$in': [mb.app_config_id for mb in mailboxes]})))
+
+        # Add the tools that are already subscribed to by the user.
+        for mb in mailboxes:
+            project = projects.get(mb.project_id, None)
+            app_config = app_index.get(mb.app_config_id, None)
+            if project is None:
+                mb.query.delete()
+                continue
+            if app_config is None:
+                continue
+
+            subscriptions.append(dict(
+                subscription_id=mb._id,
+                project_id=project._id,
+                app_config_id=mb.app_config_id,
+                project_name=project.name,
+                mount_point=app_config.options['mount_point'],
+                artifact_title=dict(
+                    text=mb.artifact_title, href=mb.artifact_url),
+                topic=mb.topic,
+                type=mb.type,
+                frequency=mb.frequency.unit,
+                artifact=mb.artifact_index_id,
+                subscribed=True))
+
+        # Dictionary of all projects projects accessible based on a users credentials (user_roles).
+        my_projects = dict((p._id, p) for p in c.user.my_projects())
+
+        # Dictionary containing all tools (subscribed and un-subscribed).
+        my_tools = M.AppConfig.query.find(dict(
+            project_id={'$in': list(my_projects.keys())}))
+
+        # Dictionary containing all the currently subscribed tools for a given user.
+        my_tools_subscriptions = dict(
+            (mb.app_config_id, mb) for mb in M.Mailbox.query.find(dict(
+                user_id=c.user._id,
+                project_id={'$in': list(projects.keys())},
+                app_config_id={'$in': list(app_index.keys())},
+                artifact_index_id=None)))
+
+        # Add the remaining tools that are eligible for subscription.
+        for tool in my_tools:
+            if tool['_id'] in my_tools_subscriptions:
+                continue  # We have already subscribed to this tool.
+
+            subscriptions.append(
+                dict(tool_id=tool._id,
+                     user_id=c.user._id,
+                     project_id=tool.project_id,
+                     project_name=my_projects[tool.project_id].name,
+                     mount_point=tool.options['mount_point'],
+                     artifact_title='No subscription',
+                     topic=None,
+                     type=None,
+                     frequency=None,
+                     artifact=None))
+
+        subscriptions.sort(key=lambda d: (d['project_name'], d['mount_point']))
+        provider = plugin.AuthenticationProvider.get(request)
+        menu = provider.account_navigation()
+        return dict(
+            subscriptions=subscriptions,
+            menu=menu)
+
+    @h.vardec
+    @expose()
+    @require_post()
+    @validate(F.subscription_form, error_handler=index)
+    def update_subscriptions(self, subscriptions=None, email_format=None, **kw):
+        for s in subscriptions:
+            if s['subscribed']:
+                if s['tool_id'] and s['project_id']:
+                    M.Mailbox.subscribe(
+                        project_id=bson.ObjectId(s['project_id']),
+                        app_config_id=bson.ObjectId(s['tool_id']))
+            else:
+                if s['subscription_id'] is not None:
+                    s['subscription_id'].delete()
+        if email_format:
+            c.user.set_pref('email_format', email_format)
+
+        redirect(request.referer)
+
+
+class OAuthController(BaseController):
+
+    def _check_security(self):
+        require_authenticated()
+
+    @with_trailing_slash
+    @expose('jinja:allura:templates/oauth_applications.html')
+    def index(self, **kw):
+        c.form = F.oauth_application_form
+        consumer_tokens = M.OAuthConsumerToken.for_user(c.user)
+        access_tokens = M.OAuthAccessToken.for_user(c.user)
+        provider = plugin.AuthenticationProvider.get(request)
+        return dict(
+            menu=provider.account_navigation(),
+            consumer_tokens=consumer_tokens,
+            access_tokens=access_tokens,
+        )
+
+    @expose()
+    @require_post()
+    @validate(F.oauth_application_form, error_handler=index)
+    def register(self, application_name=None, application_description=None, **kw):
+        M.OAuthConsumerToken(name=application_name,
+                             description=application_description)
+        flash('OAuth Application registered')
+        redirect('.')
+
+    @expose()
+    @require_post()
+    def deregister(self, _id=None):
+        app = M.OAuthConsumerToken.query.get(_id=bson.ObjectId(_id))
+        if app is None:
+            flash('Invalid app ID', 'error')
+            redirect('.')
+        if app.user_id != c.user._id:
+            flash('Invalid app ID', 'error')
+            redirect('.')
+        M.OAuthRequestToken.query.remove({'consumer_token_id': app._id})
+        M.OAuthAccessToken.query.remove({'consumer_token_id': app._id})
+        app.delete()
+        flash('Application deleted')
+        redirect('.')
+
+    @expose()
+    @require_post()
+    def generate_access_token(self, _id):
+        """
+        Manually generate an OAuth access token for the given consumer.
+
+        NB: Manually generated access tokens are bearer tokens, which are
+        less secure (since they rely only on the token, which is transmitted
+        with each request, unlike the access token secret).
+        """
+        consumer_token = M.OAuthConsumerToken.query.get(_id=bson.ObjectId(_id))
+        if consumer_token is None:
+            flash('Invalid app ID', 'error')
+            redirect('.')
+        if consumer_token.user_id != c.user._id:
+            flash('Invalid app ID', 'error')
+            redirect('.')
+        request_token = M.OAuthRequestToken(
+            consumer_token_id=consumer_token._id,
+            user_id=c.user._id,
+            callback='manual',
+            validation_pin=h.nonce(20),
+            is_bearer=True,
+        )
+        access_token = M.OAuthAccessToken(
+            consumer_token_id=consumer_token._id,
+            request_token_id=c.user._id,
+            user_id=request_token.user_id,
+            is_bearer=True,
+        )
+        redirect('.')
+
+    @expose()
+    @require_post()
+    def revoke_access_token(self, _id):
+        access_token = M.OAuthAccessToken.query.get(_id=bson.ObjectId(_id))
+        if access_token is None:
+            flash('Invalid token ID', 'error')
+            redirect('.')
+        if access_token.user_id != c.user._id:
+            flash('Invalid token ID', 'error')
+            redirect('.')
+        access_token.delete()
+        flash('Token revoked')
+        redirect('.')
+
+
+class DisableAccountController(BaseController):
+
+    def _check_security(self):
+        require_authenticated()
+
+    @with_trailing_slash
+    @expose('jinja:allura:templates/user_disable_account.html')
+    def index(self, **kw):
+        provider = plugin.AuthenticationProvider.get(request)
+        menu = provider.account_navigation()
+        my_projects = c.user.my_projects_by_role_name('Admin').all()
+        return {
+            'menu': menu,
+            'my_projects': my_projects,
+            'form': F.disable_account_form,
+        }
+
+    @expose()
+    @require_post()
+    @validate(F.disable_account_form, error_handler=index)
+    def do_disable(self, password):
+        provider = plugin.AuthenticationProvider.get(request)
+        provider.disable_user(c.user)
+        flash('Your account was successfully disabled!')
+        redirect('/')

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/controllers/base.py
----------------------------------------------------------------------
diff --git a/controllers/base.py b/controllers/base.py
new file mode 100644
index 0000000..524d586
--- /dev/null
+++ b/controllers/base.py
@@ -0,0 +1,58 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+from tg import expose
+from webob import exc
+from tg.controllers.dispatcher import ObjectDispatcher
+
+
+class BaseController(object):
+
+    @expose()
+    def _lookup(self, name=None, *remainder):
+        """Provide explicit default lookup to avoid dispatching backtracking
+        and possible loops."""
+        raise exc.HTTPNotFound(name)
+
+
+class DispatchIndex(object):
+
+    """Rewrite default url dispatching for controller.
+
+    Catch url that ends with `index.*` and pass it to the `_lookup()`
+    controller method, instead of `index()` as by default.
+    Assumes that controller has `_lookup()` method.
+
+    Use default dispatching for other urls.
+
+    Use this class as a mixin to controller that needs such behaviour.
+    (see allura.controllers.repository.TreeBrowser for an example)
+    """
+    dispatcher = ObjectDispatcher()
+
+    def _dispatch(self, state, remainder):
+        dispatcher = self.dispatcher
+        if remainder and remainder[0] == 'index':
+            controller, new_remainder = self._lookup(*remainder)
+            state.add_controller(controller.__class__.__name__, controller)
+            dispatcher = getattr(controller, '_dispatch', dispatcher._dispatch)
+            return dispatcher(state, new_remainder)
+        return dispatcher._dispatch(state, remainder)

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/controllers/basetest_project_root.py
----------------------------------------------------------------------
diff --git a/controllers/basetest_project_root.py b/controllers/basetest_project_root.py
new file mode 100644
index 0000000..b99018d
--- /dev/null
+++ b/controllers/basetest_project_root.py
@@ -0,0 +1,219 @@
+# -*- coding: utf-8 -*-
+
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+"""Main Controller"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+import logging
+from urllib.parse import unquote
+
+from pylons import tmpl_context as c
+from pylons import request
+from webob import exc
+from tg import expose
+from paste.deploy.converters import asbool
+
+from allura.lib.base import WsgiDispatchController
+from allura.lib.security import require, require_authenticated, require_access, has_access
+from allura.lib import helpers as h
+from allura.lib import plugin
+from allura import model as M
+from .root import RootController
+from .project import ProjectController
+from .rest import RestController
+
+__all__ = ['RootController']
+
+log = logging.getLogger(__name__)
+
+
+class BasetestProjectRootController(WsgiDispatchController, ProjectController):
+    '''Root controller for testing -- it behaves just like a
+    ProjectController for test/ except that all tools are mounted,
+    on-demand, at the mount point that is the same as their entry point
+    name.
+
+    Also, the test-admin is perpetually logged in here.
+
+    The name of this controller is dictated by the override_root setting
+    in development.ini and the magical import rules of TurboGears.  The
+    override_root setting has to match the name of this file, which has
+    to match (less underscores, case changes, and the addition of
+    "Controller") the name of this class.  It will then be registered
+    as the root controller instead of allura.controllers.root.RootController.
+    '''
+
+    def __init__(self):
+        for n in M.Neighborhood.query.find():
+            if n.url_prefix.startswith('//'):
+                continue
+            n.bind_controller(self)
+            if n.url_prefix == '/p/':
+                self.p_nbhd = n
+
+        proxy_root = RootController()
+        self.dispatch = DispatchTest()
+        self.security = SecurityTests()
+        for attr in ('index', 'browse', 'auth', 'nf', 'error', 'categories'):
+            setattr(self, attr, getattr(proxy_root, attr))
+        self.gsearch = proxy_root.search
+        self.rest = RestController()
+        super(BasetestProjectRootController, self).__init__()
+
+    def _setup_request(self):
+        # This code fixes a race condition in our tests
+        c.project = M.Project.query.get(
+            shortname='test', neighborhood_id=self.p_nbhd._id)
+        count = 20
+        while c.project is None:
+            import time
+            time.sleep(0.5)
+            log.warning('Project "test" not found, retrying...')
+            c.project = M.Project.query.get(
+                shortname='test', neighborhood_id=self.p_nbhd._id)
+            count -= 1
+            assert count > 0, 'Timeout waiting for test project to appear'
+
+    def _cleanup_request(self):
+        pass
+
+    @expose()
+    def _lookup(self, name, *remainder):
+        if not h.re_project_name.match(name):
+            raise exc.HTTPNotFound(name)
+        subproject = M.Project.query.get(
+            shortname=c.project.shortname + '/' + name,
+            neighborhood_id=self.p_nbhd._id)
+        if subproject:
+            c.project = subproject
+            c.app = None
+            return ProjectController(), remainder
+        app = c.project.app_instance(name)
+        if app is None:
+            prefix = 'test-app-'
+            ep_name = name
+            if name.startswith('test-app-'):
+                ep_name = name[len(prefix):]
+            try:
+                c.project.install_app(ep_name, name)
+            except KeyError:
+                raise exc.HTTPNotFound(name)
+            app = c.project.app_instance(name)
+            if app is None:
+                raise exc.HTTPNotFound(name)
+        c.app = app
+        return app.root, remainder
+
+    def __call__(self, environ, start_response):
+        """ Called from a WebTest 'app' instance.
+
+
+        :param environ: Extra environment variables.
+        Example: self.app.get('/auth/', extra_environ={'disable_auth_magic': "True"})
+        """
+        c.app = None
+        c.project = M.Project.query.get(
+            shortname='test', neighborhood_id=self.p_nbhd._id)
+        auth = plugin.AuthenticationProvider.get(request)
+        if asbool(environ.get('disable_auth_magic')):
+            c.user = auth.authenticate_request()
+        else:
+            user = auth.by_username(environ.get('username', 'test-admin'))
+            if not user:
+                user = M.User.anonymous()
+            environ['beaker.session']['username'] = user.username
+            # save and persist, so that a creation time is set
+            environ['beaker.session'].save()
+            environ['beaker.session'].persist()
+            c.user = auth.authenticate_request()
+        return WsgiDispatchController.__call__(self, environ, start_response)
+
+
+class DispatchTest(object):
+    @expose()
+    def _lookup(self, *args):
+        if args:
+            return NamedController(args[0]), args[1:]
+        else:
+            raise exc.HTTPNotFound()
+
+
+class NamedController(object):
+    def __init__(self, name):
+        self.name = name
+
+    @expose()
+    def index(self, **kw):
+        return 'index ' + self.name
+
+    @expose()
+    def _default(self, *args):
+        return 'default(%s)(%r)' % (self.name, args)
+
+
+class SecurityTests(object):
+    @expose()
+    def _lookup(self, name, *args):
+        name = unquote(name)
+        if name == '*anonymous':
+            c.user = M.User.anonymous()
+        return SecurityTest(), args
+
+
+class SecurityTest(object):
+    def __init__(self):
+        from forgewiki import model as WM
+        c.app = c.project.app_instance('wiki')
+        self.page = WM.Page.query.get(
+            app_config_id=c.app.config._id, title='Home')
+
+    @expose()
+    def forbidden(self):
+        require(lambda: False, 'Never allowed')
+        return ''
+
+    @expose()
+    def needs_auth(self):
+        require_authenticated()
+        return ''
+
+    @expose()
+    def needs_project_access_fail(self):
+        require_access(c.project, 'no_such_permission')
+        return ''
+
+    @expose()
+    def needs_project_access_ok(self):
+        pred = has_access(c.project, 'read')
+        if not pred():
+            log.info('Inside needs_project_access, c.user = %s' % c.user)
+        require(pred)
+        return ''
+
+    @expose()
+    def needs_artifact_access_fail(self):
+        require_access(self.page, 'no_such_permission')
+        return ''
+
+    @expose()
+    def needs_artifact_access_ok(self):
+        require_access(self.page, 'read')
+        return ''

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/controllers/discuss.py
----------------------------------------------------------------------
diff --git a/controllers/discuss.py b/controllers/discuss.py
new file mode 100644
index 0000000..37937f5
--- /dev/null
+++ b/controllers/discuss.py
@@ -0,0 +1,533 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+from urllib.parse import unquote
+from datetime import datetime
+import logging
+
+from tg import expose, redirect, validate, request, flash
+from pylons import tmpl_context as c, app_globals as g
+from webob import exc
+
+from ming.base import Object
+from ming.utils import LazyProperty
+
+from allura import model as M
+from .base import BaseController
+from allura.lib import utils
+from allura.lib import helpers as h
+from allura.lib.decorators import require_post
+from allura.lib.security import require_access
+
+from allura.lib.widgets import discuss as DW
+from .attachments import AttachmentsController, AttachmentController
+from .feed import FeedArgs, FeedController
+
+log = logging.getLogger(__name__)
+
+
+class pass_validator(object):
+
+    def validate(self, v, s):
+        return v
+pass_validator = pass_validator()
+
+
+class ModelConfig(object):
+    Discussion = M.Discussion
+    Thread = M.Thread
+    Post = M.Post
+    Attachment = M.DiscussionAttachment
+
+
+class WidgetConfig(object):
+    # Forms
+    subscription_form = DW.SubscriptionForm()
+    edit_post = DW.EditPost()
+    moderate_thread = DW.ModerateThread()
+    moderate_post = DW.ModeratePost()
+    flag_post = DW.FlagPost()
+    post_filter = DW.PostFilter()
+    moderate_posts = DW.ModeratePosts()
+    # Other widgets
+    discussion = DW.Discussion()
+    thread = DW.Thread()
+    post = DW.Post()
+    thread_header = DW.ThreadHeader()
+    discussion_header = DW.DiscussionHeader()
+
+# Controllers
+
+
+class DiscussionController(BaseController, FeedController):
+    M = ModelConfig
+    W = WidgetConfig
+
+    def __init__(self):
+        if not hasattr(self, 'ThreadController'):
+            self.ThreadController = ThreadController
+        if not hasattr(self, 'PostController'):
+            self.PostController = PostController
+        if not hasattr(self, 'AttachmentController'):
+            self.AttachmentController = DiscussionAttachmentController
+        self.thread = ThreadsController(self)
+        if not hasattr(self, 'moderate'):
+            self.moderate = ModerationController(self)
+
+    @expose('jinja:allura:templates/discussion/index.html')
+    def index(self, threads=None, limit=None, page=0, count=0, **kw):
+        c.discussion = self.W.discussion
+        c.discussion_header = self.W.discussion_header
+        if threads is None:
+            threads = self.discussion.threads
+        return dict(discussion=self.discussion, limit=limit, page=page, count=count, threads=threads)
+
+    @h.vardec
+    @expose()
+    @validate(pass_validator, error_handler=index)
+    def subscribe(self, **kw):
+        threads = kw.pop('threads', [])
+        for t in threads:
+            thread = self.M.Thread.query.find(dict(_id=t['_id'])).first()
+            if 'subscription' in t:
+                thread['subscription'] = True
+            else:
+                thread['subscription'] = False
+            M.session.artifact_orm_session._get().skip_mod_date = True
+        redirect(request.referer)
+
+    def get_feed(self, project, app, user):
+        """Return a :class:`allura.controllers.feed.FeedArgs` object describing
+        the xml feed for this controller.
+
+        Overrides :meth:`allura.controllers.feed.FeedController.get_feed`.
+
+        """
+        return FeedArgs(
+            dict(ref_id={'$in': [t.index_id()
+                 for t in self.discussion.threads]}),
+            'Recent posts to %s' % self.discussion.name,
+            self.discussion.url())
+
+
+class AppDiscussionController(DiscussionController):
+
+    @LazyProperty
+    def discussion(self):
+        return self.M.Discussion.query.get(
+            shortname=c.app.config.options.mount_point,
+            app_config_id=c.app.config._id)
+
+
+class ThreadsController(BaseController, metaclass=h.ProxiedAttrMeta):
+    M = h.attrproxy('_discussion_controller', 'M')
+    W = h.attrproxy('_discussion_controller', 'W')
+    ThreadController = h.attrproxy(
+        '_discussion_controller', 'ThreadController')
+    PostController = h.attrproxy('_discussion_controller', 'PostController')
+    AttachmentController = h.attrproxy(
+        '_discussion_controller', 'AttachmentController')
+
+    def __init__(self, discussion_controller):
+        self._discussion_controller = discussion_controller
+
+    @expose()
+    def _lookup(self, id=None, *remainder):
+        if id:
+            id = unquote(id)
+            return self.ThreadController(self._discussion_controller, id), remainder
+        else:
+            raise exc.HTTPNotFound()
+
+
+class ThreadController(BaseController, FeedController, metaclass=h.ProxiedAttrMeta):
+    M = h.attrproxy('_discussion_controller', 'M')
+    W = h.attrproxy('_discussion_controller', 'W')
+    ThreadController = h.attrproxy(
+        '_discussion_controller', 'ThreadController')
+    PostController = h.attrproxy('_discussion_controller', 'PostController')
+    AttachmentController = h.attrproxy(
+        '_discussion_controller', 'AttachmentController')
+
+    def _check_security(self):
+        require_access(self.thread, 'read')
+        if self.thread.ref:
+            require_access(self.thread.ref.artifact, 'read')
+
+    def __init__(self, discussion_controller, thread_id):
+        self._discussion_controller = discussion_controller
+        self.discussion = discussion_controller.discussion
+        self.thread = self.M.Thread.query.get(_id=thread_id)
+        if not self.thread:
+            raise exc.HTTPNotFound
+
+    @expose()
+    def _lookup(self, id, *remainder):
+        id = unquote(id)
+        return self.PostController(self._discussion_controller, self.thread, id), remainder
+
+    @expose('jinja:allura:templates/discussion/thread.html')
+    def index(self, limit=None, page=0, count=0, **kw):
+        c.thread = self.W.thread
+        c.thread_header = self.W.thread_header
+        limit, page, start = g.handle_paging(limit, page)
+        self.thread.num_views += 1
+        # the update to num_views shouldn't affect it
+        M.session.artifact_orm_session._get().skip_mod_date = True
+        count = self.thread.query_posts(page=page, limit=int(limit)).count()
+        return dict(discussion=self.thread.discussion,
+                    thread=self.thread,
+                    page=int(page),
+                    count=int(count),
+                    limit=int(limit),
+                    show_moderate=kw.get('show_moderate'))
+
+    def error_handler(self, *args, **kwargs):
+        redirect(request.referer)
+
+    @h.vardec
+    @expose()
+    @require_post()
+    @validate(pass_validator, error_handler=error_handler)
+    @utils.AntiSpam.validate('Spambot protection engaged')
+    def post(self, **kw):
+        require_access(self.thread, 'post')
+        if self.thread.ref:
+            require_access(self.thread.ref.artifact, 'post')
+        kw = self.W.edit_post.to_python(kw, None)
+        if not kw['text']:
+            flash('Your post was not saved. You must provide content.',
+                  'error')
+            redirect(request.referer)
+
+        file_info = kw.get('file_info', None)
+        p = self.thread.add_post(**kw)
+        p.add_multiple_attachments(file_info)
+        if self.thread.artifact:
+            self.thread.artifact.mod_date = datetime.utcnow()
+        flash('Message posted')
+        redirect(request.referer)
+
+    @expose()
+    @require_post()
+    def tag(self, labels, **kw):
+        require_access(self.thread, 'post')
+        if self.thread.ref:
+            require_access(self.thread.ref.artifact, 'post')
+        self.thread.labels = labels.split(',')
+        redirect(request.referer)
+
+    @expose()
+    def flag_as_spam(self, **kw):
+        require_access(self.thread, 'moderate')
+        self.thread.spam()
+        flash('Thread flagged as spam.')
+        redirect(self.discussion.url())
+
+    def get_feed(self, project, app, user):
+        """Return a :class:`allura.controllers.feed.FeedArgs` object describing
+        the xml feed for this controller.
+
+        Overrides :meth:`allura.controllers.feed.FeedController.get_feed`.
+
+        """
+        return FeedArgs(
+            dict(ref_id=self.thread.index_id()),
+            'Recent posts to %s' % (self.thread.subject or '(no subject)'),
+            self.thread.url())
+
+
+class PostController(BaseController, metaclass=h.ProxiedAttrMeta):
+    M = h.attrproxy('_discussion_controller', 'M')
+    W = h.attrproxy('_discussion_controller', 'W')
+    ThreadController = h.attrproxy(
+        '_discussion_controller', 'ThreadController')
+    PostController = h.attrproxy('_discussion_controller', 'PostController')
+    AttachmentController = h.attrproxy(
+        '_discussion_controller', 'AttachmentController')
+
+    def _check_security(self):
+        require_access(self.post, 'read')
+
+    def __init__(self, discussion_controller, thread, slug):
+        self._discussion_controller = discussion_controller
+        self.thread = thread
+        self._post_slug = slug
+        self.attachment = DiscussionAttachmentsController(self.post)
+
+    @LazyProperty
+    def post(self):
+        post = self.M.Post.query.get(
+            slug=self._post_slug, thread_id=self.thread._id)
+        if post:
+            return post
+        else:
+            redirect('..')
+
+    @h.vardec
+    @expose('jinja:allura:templates/discussion/post.html')
+    @validate(pass_validator)
+    @utils.AntiSpam.validate('Spambot protection engaged')
+    def index(self, version=None, **kw):
+        c.post = self.W.post
+        if request.method == 'POST':
+            require_access(self.post, 'moderate')
+            post_fields = self.W.edit_post.to_python(kw, None)
+            file_info = post_fields.pop('file_info', None)
+            self.post.add_multiple_attachments(file_info)
+            for k, v in post_fields.items():
+                try:
+                    setattr(self.post, k, v)
+                except AttributeError:
+                    continue
+            self.post.edit_count = self.post.edit_count + 1
+            self.post.last_edit_date = datetime.utcnow()
+            self.post.last_edit_by_id = c.user._id
+            self.post.commit()
+            g.director.create_activity(c.user, 'modified', self.post,
+                                       target=self.post.thread.artifact or self.post.thread,
+                                       related_nodes=[self.post.app_config.project],
+                                       tags=['comment'])
+            redirect(request.referer)
+        elif request.method == 'GET':
+            if self.post.deleted:
+                raise exc.HTTPNotFound
+            if version is not None:
+                HC = self.post.__mongometa__.history_class
+                ss = HC.query.find(
+                    {'artifact_id': self.post._id, 'version': int(version)}).first()
+                if not ss:
+                    raise exc.HTTPNotFound
+                post = Object(
+                    ss.data,
+                    acl=self.post.acl,
+                    author=self.post.author,
+                    url=self.post.url,
+                    thread=self.post.thread,
+                    reply_subject=self.post.reply_subject,
+                    attachments=self.post.attachments,
+                    related_artifacts=self.post.related_artifacts,
+                    parent_security_context=lambda: None,
+                )
+            else:
+                post = self.post
+            return dict(discussion=self.post.discussion,
+                        post=post)
+
+    def error_handler(self, *args, **kwargs):
+        redirect(request.referer)
+
+    @h.vardec
+    @expose()
+    @require_post()
+    @validate(pass_validator, error_handler=error_handler)
+    @utils.AntiSpam.validate('Spambot protection engaged')
+    @require_post(redir='.')
+    def reply(self, file_info=None, **kw):
+        require_access(self.thread, 'post')
+        kw = self.W.edit_post.to_python(kw, None)
+        p = self.thread.add_post(parent_id=self.post._id, **kw)
+        p.add_multiple_attachments(file_info)
+        redirect(request.referer)
+
+    @h.vardec
+    @expose()
+    @require_post()
+    @validate(pass_validator, error_handler=error_handler)
+    def moderate(self, **kw):
+        require_access(self.post.thread, 'moderate')
+        if kw.pop('delete', None):
+            self.post.delete()
+        elif kw.pop('spam', None):
+            self.post.spam()
+        elif kw.pop('approve', None):
+            if self.post.status != 'ok':
+                self.post.approve(notify=False)
+                g.spam_checker.submit_ham(
+                    self.post.text, artifact=self.post, user=c.user)
+                self.post.thread.post_to_feed(self.post)
+        self.thread.update_stats()
+        return dict(result='success')
+
+    @h.vardec
+    @expose()
+    @require_post()
+    @validate(pass_validator, error_handler=error_handler)
+    def flag(self, **kw):
+        self.W.flag_post.to_python(kw, None)
+        if c.user._id not in self.post.flagged_by:
+            self.post.flagged_by.append(c.user._id)
+            self.post.flags += 1
+        redirect(request.referer)
+
+    @h.vardec
+    @expose()
+    @require_post()
+    def attach(self, file_info=None):
+        require_access(self.post, 'moderate')
+        self.post.add_multiple_attachments(file_info)
+        redirect(request.referer)
+
+    @expose()
+    def _lookup(self, id, *remainder):
+        id = unquote(id)
+        return self.PostController(
+            self._discussion_controller,
+            self.thread, self._post_slug + '/' + id), remainder
+
+
+class DiscussionAttachmentController(AttachmentController):
+    AttachmentClass = M.DiscussionAttachment
+    edit_perm = 'moderate'
+
+
+class DiscussionAttachmentsController(AttachmentsController):
+    AttachmentControllerClass = DiscussionAttachmentController
+
+
+class ModerationController(BaseController, metaclass=h.ProxiedAttrMeta):
+    PostModel = M.Post
+    M = h.attrproxy('_discussion_controller', 'M')
+    W = h.attrproxy('_discussion_controller', 'W')
+    ThreadController = h.attrproxy(
+        '_discussion_controller', 'ThreadController')
+    PostController = h.attrproxy('_discussion_controller', 'PostController')
+    AttachmentController = h.attrproxy(
+        '_discussion_controller', 'AttachmentController')
+
+    def _check_security(self):
+        require_access(self.discussion, 'moderate')
+
+    def __init__(self, discussion_controller):
+        self._discussion_controller = discussion_controller
+
+    @LazyProperty
+    def discussion(self):
+        return self._discussion_controller.discussion
+
+    @h.vardec
+    @expose('jinja:allura:templates/discussion/moderate.html')
+    @validate(pass_validator)
+    def index(self, **kw):
+        kw = WidgetConfig.post_filter.validate(kw, None)
+        page = kw.pop('page', 0)
+        limit = kw.pop('limit', 50)
+        status = kw.pop('status', 'pending')
+        flag = kw.pop('flag', None)
+        c.post_filter = WidgetConfig.post_filter
+        c.moderate_posts = WidgetConfig.moderate_posts
+        query = dict(
+            discussion_id=self.discussion._id,
+            deleted=False)
+        if status != '-':
+            query['status'] = status
+        if flag:
+            query['flags'] = {'$gte': int(flag)}
+        q = self.PostModel.query.find(query)
+        count = q.count()
+        if not page:
+            page = 0
+        page = int(page)
+        limit = int(limit)
+        q = q.skip(page)
+        q = q.limit(limit)
+        pgnum = (page // limit) + 1
+        pages = (count // limit) + 1
+        return dict(discussion=self.discussion,
+                    posts=q, page=page, limit=limit,
+                    status=status, flag=flag,
+                    pgnum=pgnum, pages=pages)
+
+    @h.vardec
+    @expose()
+    @require_post()
+    def save_moderation(self, post=[], delete=None, spam=None, approve=None, **kw):
+        for p in post:
+            if 'checked' in p:
+                posted = self.PostModel.query.get(
+                    _id=p['_id'],
+                    # make sure nobody hacks the HTML form to moderate other
+                    # posts
+                    discussion_id=self.discussion._id,
+                )
+                if posted:
+                    if delete:
+                        posted.delete()
+                        # If we just deleted the last post in the
+                        # thread, delete the thread.
+                        if posted.thread and posted.thread.num_replies == 0:
+                            posted.thread.delete()
+                    elif spam and posted.status != 'spam':
+                        posted.spam()
+                    elif approve and posted.status != 'ok':
+                        posted.status = 'ok'
+                        g.spam_checker.submit_ham(
+                            posted.text, artifact=posted, user=c.user)
+                        posted.thread.last_post_date = max(
+                            posted.thread.last_post_date,
+                            posted.mod_date)
+                        posted.thread.num_replies += 1
+                        posted.thread.post_to_feed(posted)
+        redirect(request.referer)
+
+
+class PostRestController(PostController):
+
+    @expose('json:')
+    def index(self, **kw):
+        return dict(post=self.post)
+
+    @h.vardec
+    @expose()
+    @require_post()
+    @validate(pass_validator, error_handler=h.json_validation_error)
+    def reply(self, **kw):
+        require_access(self.thread, 'post')
+        kw = self.W.edit_post.to_python(kw, None)
+        post = self.thread.post(parent_id=self.post._id, **kw)
+        self.thread.num_replies += 1
+        redirect(post.slug.split('/')[-1] + '/')
+
+
+class ThreadRestController(ThreadController):
+
+    @expose('json:')
+    def index(self, **kw):
+        return dict(thread=self.thread)
+
+    @h.vardec
+    @expose()
+    @require_post()
+    @validate(pass_validator, error_handler=h.json_validation_error)
+    def new(self, **kw):
+        require_access(self.thread, 'post')
+        kw = self.W.edit_post.to_python(kw, None)
+        p = self.thread.add_post(**kw)
+        redirect(p.slug + '/')
+
+
+class AppDiscussionRestController(AppDiscussionController):
+    ThreadController = ThreadRestController
+    PostController = PostRestController
+
+    @expose('json:')
+    def index(self, **kw):
+        return dict(discussion=self.discussion)

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/controllers/error.py
----------------------------------------------------------------------
diff --git a/controllers/error.py b/controllers/error.py
new file mode 100644
index 0000000..86b1ea4
--- /dev/null
+++ b/controllers/error.py
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+"""Error controller"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+
+from tg import request, expose
+
+__all__ = ['ErrorController']
+
+
+class ErrorController(object):
+
+    @expose('jinja:allura:templates/error.html')
+    def document(self, *args, **kwargs):
+        """Render the error document"""
+        resp = request.environ.get('pylons.original_response')
+        code = -1
+        if resp:
+            code = resp.status_int
+        default_message = ("<p>We're sorry but we weren't able to process "
+                           " this request.</p>")
+        message = request.environ.get('error_message', default_message)
+        message += '<pre>%r</pre>' % resp
+        return dict(code=code, message=message)

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/controllers/feed.py
----------------------------------------------------------------------
diff --git a/controllers/feed.py b/controllers/feed.py
new file mode 100644
index 0000000..408b875
--- /dev/null
+++ b/controllers/feed.py
@@ -0,0 +1,115 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+from tg import expose, validate, request, response
+from tg.decorators import without_trailing_slash
+from formencode import validators as V
+from pylons import tmpl_context as c
+from webob import exc
+
+from allura import model as M
+from allura.lib import helpers as h
+
+
+class FeedArgs(object):
+
+    """A facade for the arguments required by
+    :meth:`allura.model.artifact.Feed.feed`.
+
+    Used by :meth:`FeedController.feed` to create a real feed.
+
+    """
+
+    def __init__(self, query, title, url, description=None):
+        self.query = query
+        self.title = title
+        self.url = url
+        self.description = description or title
+
+
+class FeedController(object):
+
+    """Mixin class which adds RSS and Atom feed endpoints to an existing
+    controller.
+
+    Feeds will be accessible at the following URLs:
+
+        http://host/path/to/controller/feed -> RSS
+        http://host/path/to/controller/feed.rss -> RSS
+        http://host/path/to/controller/feed.atom -> Atom
+
+    A default feed is provided by :meth:`get_feed`. Subclasses that need
+    a customized feed should override :meth:`get_feed`.
+
+    """
+    FEED_TYPES = ['.atom', '.rss']
+    FEED_NAMES = ['feed{0}'.format(typ) for typ in FEED_TYPES]
+
+    def __getattr__(self, name):
+        if name in self.FEED_NAMES:
+            return self.feed
+        raise AttributeError(name)
+
+    def _get_feed_type(self, request):
+        for typ in self.FEED_TYPES:
+            if request.environ['PATH_INFO'].endswith(typ):
+                return typ.lstrip('.')
+        return 'rss'
+
+    @without_trailing_slash
+    @expose()
+    @validate(dict(
+        since=h.DateTimeConverter(if_empty=None, if_invalid=None),
+        until=h.DateTimeConverter(if_empty=None, if_invalid=None),
+        page=V.Int(if_empty=None, if_invalid=None),
+        limit=V.Int(if_empty=None, if_invalid=None)))
+    def feed(self, since=None, until=None, page=None, limit=None, **kw):
+        """Return a utf8-encoded XML feed (RSS or Atom) to the browser.
+        """
+        feed_def = self.get_feed(c.project, c.app, c.user)
+        if not feed_def:
+            raise exc.HTTPNotFound
+        feed = M.Feed.feed(
+            feed_def.query,
+            self._get_feed_type(request),
+            feed_def.title,
+            feed_def.url,
+            feed_def.description,
+            since, until, page, limit)
+        response.headers['Content-Type'] = ''
+        response.content_type = 'application/xml'
+        return feed.writeString('utf-8')
+
+    def get_feed(self, project, app, user):
+        """Return a default :class:`FeedArgs` for this controller.
+
+        Subclasses should override to customize the feed.
+
+        :param project: :class:`allura.model.project.Project`
+        :param app: :class:`allura.app.Application`
+        :param user: :class:`allura.model.auth.User`
+        :rtype: :class:`FeedArgs`
+
+        """
+        return FeedArgs(
+            dict(project_id=project._id, app_config_id=app.config._id),
+            'Recent changes to %s' % app.config.options.mount_point,
+            app.url)


Mime
View raw message