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('.')
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, ) M.OAuthAccessToken( consumer_token_id=consumer_token._id, request_token_id=c.user._id, user_id=request_token.user_id, is_bearer=True, ) redirect('.')
class ApiTicket(MappedClass, ApiAuthMixIn): class __mongometa__: name = 'api_ticket' session = main_orm_session PREFIX = 'tck' _id = FieldProperty(S.ObjectId) user_id = ForeignIdProperty('User') api_key = FieldProperty(str, if_missing=lambda: ApiTicket.PREFIX + h.nonce(20)) secret_key = FieldProperty(str, if_missing=h.cryptographic_nonce) expires = FieldProperty(datetime, if_missing=None) capabilities = FieldProperty({str: None}) mod_date = FieldProperty(datetime, if_missing=datetime.utcnow) user = RelationProperty('User') @classmethod def get(cls, api_ticket): if not api_ticket.startswith(cls.PREFIX): return None return cls.query.get(api_key=api_ticket) def authenticate_request(self, path, params): if self.expires and datetime.utcnow() > self.expires: return False return ApiAuthMixIn.authenticate_request(self, path, params) def get_capability(self, key): return self.capabilities.get(key)
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) 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) text = ''' To reset your password on %s, please visit the following URL: %s/auth/forgotten_password/%s ''' % (config['site_name'], config['base_url'], hash) allura.tasks.mail_tasks.sendmail.post( destinations=[email], fromaddr=config['forgemail.return_path'], reply_to=config['forgemail.return_path'], subject='Password recovery', message_id=h.gen_message_id(), text=text) flash('Email with instructions has been sent.') redirect('/')
def _update_mounts(self, subproject=None, tool=None, new=None, **kw): if subproject is None: subproject = [] if tool is None: tool = [] for sp in subproject: p = M.Project.query.get(shortname=sp['shortname'], neighborhood_id=c.project.neighborhood_id) if sp.get('delete'): require_access(c.project, 'admin') M.AuditLog.log('delete subproject %s', sp['shortname']) h.log_action(log, 'delete subproject').info( 'delete subproject %s', sp['shortname'], meta=dict(name=sp['shortname'])) p.removal = 'deleted' plugin.ProjectRegistrationProvider.get().delete_project( p, c.user) elif not new: M.AuditLog.log('update subproject %s', sp['shortname']) p.name = sp['name'] p.ordinal = int(sp['ordinal']) for p in tool: if p.get('delete'): require_access(c.project, 'admin') M.AuditLog.log('uninstall tool %s', p['mount_point']) h.log_action(log, 'uninstall tool').info( 'uninstall tool %s', p['mount_point'], meta=dict(mount_point=p['mount_point'])) c.project.uninstall_app(p['mount_point']) elif not new: M.AuditLog.log('update tool %s', p['mount_point']) options = c.project.app_config(p['mount_point']).options options.mount_label = p['mount_label'] options.ordinal = int(p['ordinal']) if new and new.get('install'): ep_name = new.get('ep_name', None) if not ep_name: require_access(c.project, 'create') mount_point = new['mount_point'].lower() or h.nonce() M.AuditLog.log('create subproject %s', mount_point) h.log_action(log, 'create subproject').info( 'create subproject %s', mount_point, meta=dict(mount_point=mount_point, name=new['mount_label'])) sp = c.project.new_subproject(mount_point) sp.name = new['mount_label'] sp.ordinal = int(new['ordinal']) else: require_access(c.project, 'admin') installable_tools = AdminApp.installable_tools_for(c.project) if not ep_name.lower() in [t['name'].lower() for t in installable_tools]: flash('Installation limit exceeded.', 'error') return mount_point = new['mount_point'] or ep_name M.AuditLog.log('install tool %s', mount_point) h.log_action(log, 'install tool').info( 'install tool %s', mount_point, meta=dict(tool_type=ep_name, mount_point=mount_point, mount_label=new['mount_label'])) c.project.install_app( ep_name, mount_point, mount_label=new['mount_label'], ordinal=new['ordinal']) g.post_event('project_updated')
def update_configuration(self, divs=None, layout_class=None, new_div=None, **kw): require_access(c.project, 'update') config = M.PortalConfig.current() config.layout_class = layout_class # Handle updated and deleted divs if divs is None: divs = [] new_divs = [] for div in divs: log.info('Got div update:%s', pformat(div)) if div.get('del'): continue new_divs.append(div) # Handle new divs if new_div: new_divs.append(dict(name=h.nonce(), content=[])) config.layout = [] for div in new_divs: content = [] for w in div.get('content', []): if w.get('del'): continue mp,wn = w['widget'].split('/') content.append(dict(mount_point=mp, widget_name=wn)) if div.get('new_widget'): content.append(dict(mount_point='profile', widget_name='welcome')) config.layout.append(dict( name=div['name'], content=content)) redirect('configuration')
def test_bearer_token_valid(self, request): user = M.User.by_username('test-admin') consumer_token = M.OAuthConsumerToken( name='foo', description='foo app', ) request_token = M.OAuthRequestToken( consumer_token_id=consumer_token._id, user_id=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=request_token._id, user_id=user._id, is_bearer=True, ) ThreadLocalODMSession.flush_all() request.headers = {} request.params = {'access_token': access_token.api_key} request.scheme = 'https' r = self.api_post('/rest/p/test/wiki', access_token='foo') assert_equal(r.status_int, 200)
def test_bearer_token_valid_via_headers(self, request): user = M.User.by_username('test-admin') consumer_token = M.OAuthConsumerToken( name='foo', description='foo app', ) request_token = M.OAuthRequestToken( consumer_token_id=consumer_token._id, user_id=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=request_token._id, user_id=user._id, is_bearer=True, ) ThreadLocalODMSession.flush_all() token = access_token.api_key request.headers = { 'Authorization': 'Bearer {}'.format(token) } request.scheme = 'https' r = self.api_post('/rest/p/test/wiki', access_token='foo', status=200) # reverse proxy situation request.scheme = 'http' request.environ['paste.testing'] = False request.environ['HTTP_X_FORWARDED_PROTOx'] = 'https' r = self.api_post('/rest/p/test/wiki', access_token='foo', status=200)
def test_bearer_token_valid_via_headers(self, request): user = M.User.by_username('test-admin') consumer_token = M.OAuthConsumerToken( name='foo', description='foo app', ) request_token = M.OAuthRequestToken( consumer_token_id=consumer_token._id, user_id=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=request_token._id, user_id=user._id, is_bearer=True, ) ThreadLocalODMSession.flush_all() token = access_token.api_key request.headers = {'Authorization': 'Bearer {}'.format(token)} request.scheme = 'https' r = self.api_post('/rest/p/test/wiki', access_token='foo', status=200) # reverse proxy situation request.scheme = 'http' request.environ['paste.testing'] = False request.environ['HTTP_X_FORWARDED_PROTOx'] = 'https' r = self.api_post('/rest/p/test/wiki', access_token='foo', status=200)
def token(self, username): if self._use_token: return self._use_token # only create token once, else ming gets dupe key error if username not in self._token_cache: user = M.User.query.get(username=username) consumer_token = M.OAuthConsumerToken( name='test-%s' % str(user._id), description='test-app-%s' % str(user._id), user_id=user._id) request_token = M.OAuthRequestToken( consumer_token_id=consumer_token._id, user_id=user._id, callback='manual', validation_pin=h.nonce(20)) token = M.OAuthAccessToken(consumer_token_id=consumer_token._id, request_token_id=request_token._id, user_id=user._id, is_bearer=True) ming.orm.session(consumer_token).flush() ming.orm.session(request_token).flush() ming.orm.session(token).flush() self._token_cache[username] = token return self._token_cache[username]
def token(self, username): if self._use_token: return self._use_token # only create token once, else ming gets dupe key error if username not in self._token_cache: user = M.User.query.get(username=username) consumer_token = M.OAuthConsumerToken( name='test-%s' % str(user._id), description='test-app-%s' % str(user._id), user_id=user._id) request_token = M.OAuthRequestToken( consumer_token_id=consumer_token._id, user_id=user._id, callback='manual', validation_pin=h.nonce(20)) token = M.OAuthAccessToken( consumer_token_id=consumer_token._id, request_token_id=request_token._id, user_id=user._id, is_bearer=True) ming.orm.session(consumer_token).flush() ming.orm.session(request_token).flush() ming.orm.session(token).flush() self._token_cache[username] = token return self._token_cache[username]
def do_authorize(self, yes=None, no=None, oauth_token=None): security.require_authenticated() rtok = M.OAuthRequestToken.query.get(api_key=oauth_token) if no: rtok.delete() flash("%s NOT AUTHORIZED" % rtok.consumer_token.name, "error") redirect("/auth/oauth/") if rtok.callback == "oob": rtok.validation_pin = h.nonce(6) return dict(rtok=rtok) rtok.validation_pin = h.nonce(20) if "?" in rtok.callback: url = rtok.callback + "&" else: url = rtok.callback + "?" url += "oauth_token=%s&oauth_verifier=%s" % (rtok.api_key, rtok.validation_pin) redirect(url)
def make_password_reset_url(self): hash = h.nonce(42) self.set_tool_data('AuthPasswordReset', hash=hash, hash_expiry=datetime.utcnow() + timedelta(seconds=int(config.get('auth.recovery_hash_expiry_period', 600)))) reset_url = h.absurl('/auth/forgotten_password/{}'.format(hash)) return reset_url
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('/')
def update_mounts(self, subproject=None, tool=None, new=None, **kw): if subproject is None: subproject = [] if tool is None: tool = [] for sp in subproject: p = M.Project.query.get(shortname=sp["shortname"], neighborhood_id=c.project.neighborhood_id) if sp.get("delete"): require_access(c.project, "admin") M.AuditLog.log("delete subproject %s", sp["shortname"]) h.log_action(log, "delete subproject").info( "delete subproject %s", sp["shortname"], meta=dict(name=sp["shortname"]) ) p.removal = "deleted" plugin.ProjectRegistrationProvider.get().delete_project(p, c.user) elif not new: M.AuditLog.log("update subproject %s", sp["shortname"]) p.name = sp["name"] p.ordinal = int(sp["ordinal"]) for p in tool: if p.get("delete"): require_access(c.project, "admin") M.AuditLog.log("uninstall tool %s", p["mount_point"]) h.log_action(log, "uninstall tool").info( "uninstall tool %s", p["mount_point"], meta=dict(mount_point=p["mount_point"]) ) c.project.uninstall_app(p["mount_point"]) elif not new: M.AuditLog.log("update tool %s", p["mount_point"]) options = c.project.app_config(p["mount_point"]).options options.mount_label = p["mount_label"] options.ordinal = int(p["ordinal"]) try: if new and new.get("install"): ep_name = new.get("ep_name", None) if not ep_name: require_access(c.project, "create") mount_point = new["mount_point"].lower() or h.nonce() M.AuditLog.log("create subproject %s", mount_point) h.log_action(log, "create subproject").info( "create subproject %s", mount_point, meta=dict(mount_point=mount_point, name=new["mount_label"]) ) sp = c.project.new_subproject(mount_point) sp.name = new["mount_label"] sp.ordinal = int(new["ordinal"]) else: require_access(c.project, "admin") mount_point = new["mount_point"].lower() or ep_name.lower() M.AuditLog.log("install tool %s", mount_point) h.log_action(log, "install tool").info( "install tool %s", mount_point, meta=dict(tool_type=ep_name, mount_point=mount_point, mount_label=new["mount_label"]), ) c.project.install_app(ep_name, mount_point, mount_label=new["mount_label"], ordinal=new["ordinal"]) except forge_exc.ForgeError, exc: flash("%s: %s" % (exc.__class__.__name__, exc.args[0]), "error")
def do_authorize(self, yes=None, no=None, oauth_token=None): security.require_authenticated() rtok = M.OAuthRequestToken.query.get(api_key=oauth_token) if no: rtok.delete() flash('%s NOT AUTHORIZED' % rtok.consumer_token.name, 'error') redirect('/auth/oauth/') if rtok.callback == 'oob': rtok.validation_pin = h.nonce(6) return dict(rtok=rtok) rtok.validation_pin = h.nonce(20) if '?' in rtok.callback: url = rtok.callback + '&' else: url = rtok.callback + '?' url += 'oauth_token=%s&oauth_verifier=%s' % ( rtok.api_key, rtok.validation_pin) redirect(url)
def update_mounts(self, subproject=None, tool=None, new=None, **kw): if subproject is None: subproject = [] if tool is None: tool = [] for sp in subproject: p = M.Project.query.get(shortname=sp['shortname'], neighborhood_id=c.project.neighborhood_id) if sp.get('delete'): require_access(c.project, 'admin') M.AuditLog.log('delete subproject %s', sp['shortname']) h.log_action(log, 'delete subproject').info( 'delete subproject %s', sp['shortname'], meta=dict(name=sp['shortname'])) p.removal = 'deleted' plugin.ProjectRegistrationProvider.get().delete_project(p, c.user) elif not new: M.AuditLog.log('update subproject %s', sp['shortname']) p.name = sp['name'] p.ordinal = int(sp['ordinal']) for p in tool: if p.get('delete'): require_access(c.project, 'admin') M.AuditLog.log('uninstall tool %s', p['mount_point']) h.log_action(log, 'uninstall tool').info( 'uninstall tool %s', p['mount_point'], meta=dict(mount_point=p['mount_point'])) c.project.uninstall_app(p['mount_point']) elif not new: M.AuditLog.log('update tool %s', p['mount_point']) options = c.project.app_config(p['mount_point']).options options.mount_label = p['mount_label'] options.ordinal = int(p['ordinal']) try: if new and new.get('install'): ep_name = new.get('ep_name', None) if not ep_name: require_access(c.project, 'create') mount_point = new['mount_point'].lower() or h.nonce() M.AuditLog.log('create subproject %s', mount_point) h.log_action(log, 'create subproject').info( 'create subproject %s', mount_point, meta=dict(mount_point=mount_point,name=new['mount_label'])) sp = c.project.new_subproject(mount_point) sp.name = new['mount_label'] sp.ordinal = int(new['ordinal']) else: require_access(c.project, 'admin') mount_point = new['mount_point'] or ep_name M.AuditLog.log('install tool %s', mount_point) h.log_action(log, 'install tool').info( 'install tool %s', mount_point, meta=dict(tool_type=ep_name, mount_point=mount_point, mount_label=new['mount_label'])) c.project.install_app(ep_name, mount_point, mount_label=new['mount_label'], ordinal=new['ordinal']) except forge_exc.ForgeError, exc: flash('%s: %s' % (exc.__class__.__name__, exc.args[0]), 'error')
def make_slugs(cls, parent=None, timestamp=None): part = h.nonce() if timestamp is None: timestamp = datetime.utcnow() dt = timestamp.strftime("%Y%m%d%H%M%S") slug = part full_slug = dt + ":" + part if parent: return (parent.slug + "/" + slug, parent.full_slug + "/" + full_slug) else: return slug, full_slug
def make_slugs(cls, parent=None, timestamp=None): part = h.nonce() if timestamp is None: timestamp = datetime.utcnow() dt = timestamp.strftime('%Y%m%d%H%M%S') slug = part full_slug = dt + ':' + part if parent: return (parent.slug + '/' + slug, parent.full_slug + '/' + full_slug) else: return slug, full_slug
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("/")
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('/')
class ApiToken(MappedClass, ApiAuthMixIn): class __mongometa__: name='api_token' session = main_orm_session unique_indexes = [ 'user_id' ] _id = FieldProperty(S.ObjectId) user_id = ForeignIdProperty('User') api_key = FieldProperty(str, if_missing=lambda:h.nonce(20)) secret_key = FieldProperty(str, if_missing=h.cryptographic_nonce) user = RelationProperty('User') @classmethod def get(cls, api_key): return cls.query.get(api_key=api_key)
def send_password_reset_email(self, email_address=None, subject_tmpl=u'{site_name} Password recovery'): if email_address is None: email_address = self.get_pref('email_address') hash = h.nonce(42) self.set_tool_data('AuthPasswordReset', hash=hash, hash_expiry=datetime.utcnow() + timedelta(seconds=int(config.get('auth.recovery_hash_expiry_period', 600)))) log.info('Sending password recovery link to %s', email_address) subject = subject_tmpl.format(site_name=config['site_name']) text = g.jinja2_env.get_template('allura:templates/mail/forgot_password.txt').render(dict( user=self, config=config, hash=hash, )) allura.tasks.mail_tasks.send_system_mail_to_user(email_address, subject, text)
def test_bearer_token_valid(self, request): user = M.User.by_username("test-admin") consumer_token = M.OAuthConsumerToken(name="foo", description="foo app") request_token = M.OAuthRequestToken( consumer_token_id=consumer_token._id, user_id=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=request_token._id, user_id=user._id, is_bearer=True ) ThreadLocalODMSession.flush_all() request.params = {"access_token": access_token.api_key} request.scheme = "https" r = self.api_post("/rest/p/test/wiki", access_token="foo") assert_equal(r.status_int, 200)
class OAuthToken(MappedClass): class __mongometa__: session = main_orm_session name = 'oauth_token' indexes = ['api_key'] polymorphic_on = 'type' polymorphic_identity = None _id = FieldProperty(S.ObjectId) type = FieldProperty(str) api_key = FieldProperty(str, if_missing=lambda: h.nonce(20)) secret_key = FieldProperty(str, if_missing=h.cryptographic_nonce) def to_string(self): return oauth.Token(self.api_key, self.secret_key).to_string() def as_token(self): return oauth.Token(self.api_key, self.secret_key)
class Thread(Artifact, ActivityObject): class __mongometa__: name = 'thread' indexes = [ ('artifact_id', ), ('ref_id', ), (('app_config_id', pymongo.ASCENDING), ('last_post_date', pymongo.DESCENDING), ('mod_date', pymongo.DESCENDING)), ('discussion_id', ), ] type_s = 'Thread' _id = FieldProperty(str, if_missing=lambda: h.nonce(8)) discussion_id = ForeignIdProperty(Discussion) ref_id = ForeignIdProperty('ArtifactReference') subject = FieldProperty(str, if_missing='') num_replies = FieldProperty(int, if_missing=0) num_views = FieldProperty(int, if_missing=0) subscriptions = FieldProperty({str: bool}) first_post_id = ForeignIdProperty('Post') last_post_date = FieldProperty(datetime, if_missing=datetime(1970, 1, 1)) artifact_reference = FieldProperty(schema.Deprecated) artifact_id = FieldProperty(schema.Deprecated) discussion = RelationProperty(Discussion) posts = RelationProperty('Post', via='thread_id') first_post = RelationProperty('Post', via='first_post_id') ref = RelationProperty('ArtifactReference') def __json__(self, limit=None, page=None): return dict( _id=self._id, discussion_id=str(self.discussion_id), subject=self.subject, posts=[ dict(slug=p.slug, text=p.text, subject=p.subject, author=p.author().username, timestamp=p.timestamp, attachments=[ dict(bytes=attach.length, url=h.absurl(attach.url())) for attach in p.attachments ]) for p in self.query_posts( status='ok', style='chronological', limit=limit, page=page) ]) @property def activity_name(self): return 'thread %s' % self.subject def parent_security_context(self): return self.discussion @classmethod def new(cls, **props): '''Creates a new Thread instance, ensuring a unique _id.''' for i in range(5): try: thread = cls(**props) session(thread).flush(thread) return thread except DuplicateKeyError as err: log.warning( 'Got DuplicateKeyError: attempt #%s, trying again. %s', i, err) if i == 4: raise session(thread).expunge(thread) continue @classmethod def discussion_class(cls): return cls.discussion.related @classmethod def post_class(cls): return cls.posts.related @classmethod def attachment_class(cls): return DiscussionAttachment @property def artifact(self): if self.ref is None: return self.discussion return self.ref.artifact # Use wisely - there's .num_replies also @property def post_count(self): return Post.query.find( dict(discussion_id=self.discussion_id, thread_id=self._id, status={'$in': ['ok', 'pending']})).count() def primary(self): if self.ref is None: return self return self.ref.artifact def add_post(self, **kw): """Helper function to avoid code duplication.""" p = self.post(**kw) p.commit(update_stats=False) self.num_replies += 1 if not self.first_post: self.first_post_id = p._id link = None if self.app.tool_label.lower() == 'tickets': link = p.url_paginated() if self.ref: Feed.post(self.primary(), title=p.subject, description=p.text, link=link) return p def is_spam(self, post): if c.user in c.project.users_with_role('Admin', 'Developer'): return False else: return g.spam_checker.check(post.text, artifact=post, user=c.user) def post(self, text, message_id=None, parent_id=None, timestamp=None, ignore_security=False, **kw): if not ignore_security: require_access(self, 'post') if self.ref_id and self.artifact: self.artifact.subscribe() if message_id is None: message_id = h.gen_message_id() parent = parent_id and self.post_class().query.get(_id=parent_id) slug, full_slug = self.post_class().make_slugs(parent, timestamp) kwargs = dict(discussion_id=self.discussion_id, full_slug=full_slug, slug=slug, thread_id=self._id, parent_id=parent_id, text=text, status='pending') if timestamp is not None: kwargs['timestamp'] = timestamp if message_id is not None: kwargs['_id'] = message_id post = self.post_class()(**kwargs) if ignore_security or not self.is_spam(post) and has_access( self, 'unmoderated_post')(): log.info('Auto-approving message from %s', c.user.username) file_info = kw.get('file_info', None) post.approve(file_info, notify=kw.get('notify', True)) else: self.notify_moderators(post) return post def notify_moderators(self, post): ''' Notify moderators that a post needs approval [#2963] ''' artifact = self.artifact or self subject = '[%s:%s] Moderation action required' % ( c.project.shortname, c.app.config.options.mount_point) author = post.author() url = self.discussion_class().query.get(_id=self.discussion_id).url() text = ('The following submission requires approval at %s before ' 'it can be approved for posting:\n\n%s' % (h.absurl(url + 'moderate'), post.text)) n = Notification(ref_id=artifact.index_id(), topic='message', link=artifact.url(), _id=artifact.url() + post._id, from_address=str(author._id) if author != User.anonymous() else None, reply_to_address=u'*****@*****.**', subject=subject, text=text, in_reply_to=post.parent_id, author_id=author._id, pubdate=datetime.utcnow()) users = self.app_config.project.users() for u in users: if (has_access(self, 'moderate', u) and Mailbox.subscribed(user_id=u._id, app_config_id=post.app_config_id)): n.send_direct(str(u._id)) def update_stats(self): self.num_replies = self.post_class().query.find( dict(thread_id=self._id, status='ok')).count() - 1 @property def last_post(self): q = self.post_class().query.find(dict(thread_id=self._id)).sort( 'timestamp', pymongo.DESCENDING) return q.first() def create_post_threads(self, posts): result = [] post_index = {} for p in sorted(posts, key=lambda p: p.full_slug): pi = dict(post=p, children=[]) post_index[p._id] = pi if p.parent_id in post_index: post_index[p.parent_id]['children'].append(pi) else: result.append(pi) return result def query_posts(self, page=None, limit=None, timestamp=None, style='threaded', status=None): if timestamp: terms = dict(discussion_id=self.discussion_id, thread_id=self._id, status={'$in': ['ok', 'pending']}, timestamp=timestamp) else: terms = dict(discussion_id=self.discussion_id, thread_id=self._id, status={'$in': ['ok', 'pending']}) if status: terms['status'] = status q = self.post_class().query.find(terms) if style == 'threaded': q = q.sort('full_slug') else: q = q.sort('timestamp') if limit is not None: limit = int(limit) if page is not None: q = q.skip(page * limit) q = q.limit(limit) return q def find_posts(self, page=None, limit=None, timestamp=None, style='threaded'): return self.query_posts(page=page, limit=limit, timestamp=timestamp, style=style).all() def url(self): # Can't use self.discussion because it might change during the req discussion = self.discussion_class().query.get(_id=self.discussion_id) return discussion.url() + 'thread/' + str(self._id) + '/' def shorthand_id(self): return self._id def index(self): result = Artifact.index(self) result.update(title=self.subject or '(no subject)', name_s=self.subject, views_i=self.num_views, text=self.subject) return result def _get_subscription(self): return self.subscriptions.get(str(c.user._id)) def _set_subscription(self, value): if value: self.subscriptions[str(c.user._id)] = True else: self.subscriptions.pop(str(c.user._id), None) subscription = property(_get_subscription, _set_subscription) def delete(self): for p in self.post_class().query.find(dict(thread_id=self._id)): p.delete() self.attachment_class().remove(dict(thread_id=self._id)) super(Thread, self).delete() def spam(self): """Mark this thread as spam.""" for p in self.post_class().query.find(dict(thread_id=self._id)): p.spam()
class Feed(MappedClass): """ Used to generate rss/atom feeds. This does not need to be extended; all feed items go into the same collection """ class __mongometa__: session = project_orm_session name = 'artifact_feed' indexes = [ 'pubdate', ('artifact_ref.project_id', 'artifact_ref.mount_point'), (('ref_id', pymongo.ASCENDING), ('pubdate', pymongo.DESCENDING)), (('project_id', pymongo.ASCENDING), ('app_config_id', pymongo.ASCENDING), ('pubdate', pymongo.DESCENDING)), 'author_link', # used in ext/user_profile/user_main.py for user feeds ] _id = FieldProperty(S.ObjectId) ref_id = ForeignIdProperty('ArtifactReference') neighborhood_id = ForeignIdProperty('Neighborhood') project_id = ForeignIdProperty('Project') app_config_id = ForeignIdProperty('AppConfig') tool_name=FieldProperty(str) title=FieldProperty(str) link=FieldProperty(str) pubdate = FieldProperty(datetime, if_missing=datetime.utcnow) description = FieldProperty(str) unique_id = FieldProperty(str, if_missing=lambda:h.nonce(40)) author_name = FieldProperty(str, if_missing=lambda:c.user.get_pref('display_name') if hasattr(c, 'user') else None) author_link = FieldProperty(str, if_missing=lambda:c.user.url() if hasattr(c, 'user') else None) artifact_reference = FieldProperty(S.Deprecated) @classmethod def post(cls, artifact, title=None, description=None, author=None, author_link=None, author_name=None, pubdate=None, link=None, **kw): """ Create a Feed item. Returns the item. But if anon doesn't have read access, create does not happen and None is returned """ # TODO: fix security system so we can do this correctly and fast from allura import model as M anon = M.User.anonymous() if not security.has_access(artifact, 'read', user=anon): return if not security.has_access(c.project, 'read', user=anon): return idx = artifact.index() if author is None: author = c.user if author_name is None: author_name = author.get_pref('display_name') if title is None: title='%s modified by %s' % (h.get_first(idx, 'title'), author_name) if description is None: description = title if pubdate is None: pubdate = datetime.utcnow() if link is None: link=artifact.url() item = cls( ref_id=artifact.index_id(), neighborhood_id=artifact.app_config.project.neighborhood_id, project_id=artifact.app_config.project_id, app_config_id=artifact.app_config_id, tool_name=artifact.app_config.tool_name, title=title, description=g.markdown.convert(description), link=link, pubdate=pubdate, author_name=author_name, author_link=author_link or author.url()) unique_id = kw.pop('unique_id', None) if unique_id: item.unique_id = unique_id return item @classmethod def feed(cls, q, feed_type, title, link, description, since=None, until=None, offset=None, limit=None): "Produces webhelper.feedgenerator Feed" d = dict(title=title, link=h.absurl(link), description=description, language=u'en') if feed_type == 'atom': feed = FG.Atom1Feed(**d) elif feed_type == 'rss': feed = FG.Rss201rev2Feed(**d) query = defaultdict(dict) query.update(q) if since is not None: query['pubdate']['$gte'] = since if until is not None: query['pubdate']['$lte'] = until cur = cls.query.find(query) cur = cur.sort('pubdate', pymongo.DESCENDING) if limit is None: limit = 10 query = cur.limit(limit) if offset is not None: query = cur.offset(offset) for r in cur: feed.add_item(title=r.title, link=h.absurl(r.link.encode('utf-8')), pubdate=r.pubdate, description=r.description, unique_id=h.absurl(r.unique_id), author_name=r.author_name, author_link=h.absurl(r.author_link)) return feed
class Notification(MappedClass): class __mongometa__: session = main_orm_session name = 'notification' indexes = [ ('neighborhood_id', 'tool_name', 'pubdate'), ('author_id', ), # used in ext/user_profile/user_main.py for user feeds ] _id = FieldProperty(str, if_missing=h.gen_message_id) # Classify notifications neighborhood_id = ForeignIdProperty( 'Neighborhood', if_missing=lambda: c.project.neighborhood._id) project_id = ForeignIdProperty('Project', if_missing=lambda: c.project._id) app_config_id = ForeignIdProperty('AppConfig', if_missing=lambda: c.app.config._id) tool_name = FieldProperty(str, if_missing=lambda: c.app.config.tool_name) ref_id = ForeignIdProperty('ArtifactReference') topic = FieldProperty(str) unique_id = FieldProperty(str, if_missing=lambda: h.nonce(40)) # Notification Content in_reply_to = FieldProperty(str) from_address = FieldProperty(str) reply_to_address = FieldProperty(str) subject = FieldProperty(str) text = FieldProperty(str) link = FieldProperty(str) author_id = ForeignIdProperty('User') feed_meta = FieldProperty(S.Deprecated) artifact_reference = FieldProperty(S.Deprecated) pubdate = FieldProperty(datetime, if_missing=datetime.utcnow) ref = RelationProperty('ArtifactReference') view = jinja2.Environment( loader=jinja2.PackageLoader('allura', 'templates')) def author(self): return User.query.get(_id=self.author_id) or User.anonymous() @classmethod def post(cls, artifact, topic, **kw): '''Create a notification and send the notify message''' import allura.tasks.notification_tasks n = cls._make_notification(artifact, topic, **kw) if n: allura.tasks.notification_tasks.notify.post( n._id, artifact.index_id(), topic) return n @classmethod def post_user(cls, user, artifact, topic, **kw): '''Create a notification and deliver directly to a user's flash mailbox''' try: mbox = Mailbox(user_id=user._id, is_flash=True, project_id=None, app_config_id=None) session(mbox).flush(mbox) except pymongo.errors.DuplicateKeyError: session(mbox).expunge(mbox) mbox = Mailbox.query.get(user_id=user._id, is_flash=True) n = cls._make_notification(artifact, topic, **kw) if n: mbox.queue.append(n._id) return n @classmethod def _make_notification(cls, artifact, topic, **kwargs): from allura.model import Project idx = artifact.index() subject_prefix = '[%s:%s] ' % (c.project.shortname, c.app.config.options.mount_point) if topic == 'message': post = kwargs.pop('post') text = post.text file_info = kwargs.pop('file_info', None) if file_info is not None: file_info.file.seek(0, 2) bytecount = file_info.file.tell() file_info.file.seek(0) text = "%s\n\n\nAttachment: %s (%s; %s)" % ( text, file_info.filename, h.do_filesizeformat(bytecount), file_info.type) subject = post.subject or '' if post.parent_id and not subject.lower().startswith('re:'): subject = 'Re: ' + subject author = post.author() d = dict( _id=artifact.url() + post._id, from_address=str(author._id) if author != User.anonymous() else None, reply_to_address='"%s" <%s>' % (subject_prefix, getattr(artifact, 'email_address', u'*****@*****.**')), subject=subject_prefix + subject, text=text, in_reply_to=post.parent_id, author_id=author._id, pubdate=datetime.utcnow()) else: subject = kwargs.pop( 'subject', '%s modified by %s' % (idx['title_s'], c.user.get_pref('display_name'))) reply_to = '"%s" <%s>' % (idx['title_s'], getattr(artifact, 'email_address', u'*****@*****.**')) d = dict(from_address=reply_to, reply_to_address=reply_to, subject=subject_prefix + subject, text=kwargs.pop('text', subject), author_id=c.user._id, pubdate=datetime.utcnow()) if c.user.get_pref('email_address'): d['from_address'] = '"%s" <%s>' % (c.user.get_pref( 'display_name'), c.user.get_pref('email_address')) elif c.user.email_addresses: d['from_address'] = '"%s" <%s>' % ( c.user.get_pref('display_name'), c.user.email_addresses[0]) if not d.get('text'): d['text'] = '' try: ''' Add addional text to the notification e-mail based on the artifact type ''' template = cls.view.get_template('mail/' + artifact.type_s + '.txt') d['text'] += template.render( dict(c=c, g=g, config=config, data=artifact)) except jinja2.TemplateNotFound: pass except: ''' Catch any errors loading or rendering the template, but the notification still gets sent if there is an error ''' log.warn('Could not render notification template %s' % artifact.type_s, exc_info=True) assert d['reply_to_address'] is not None project = Project.query.get(_id=d.get('project_id', c.project._id)) if project.notifications_disabled: log.info( 'Notifications disabled for project %s, not sending %s(%r)', project.shortname, topic, artifact) return None n = cls(ref_id=artifact.index_id(), topic=topic, link=kwargs.pop('link', artifact.url()), **d) return n @classmethod def feed(cls, q, feed_type, title, link, description, since=None, until=None, offset=None, limit=None): """Produces webhelper.feedgenerator Feed""" d = dict(title=title, link=h.absurl(link), description=description, language=u'en') if feed_type == 'atom': feed = FG.Atom1Feed(**d) elif feed_type == 'rss': feed = FG.Rss201rev2Feed(**d) query = defaultdict(dict) query.update(q) if since is not None: query['pubdate']['$gte'] = since if until is not None: query['pubdate']['$lte'] = until cur = cls.query.find(query) cur = cur.sort('pubdate', pymongo.DESCENDING) if limit is None: limit = 10 query = cur.limit(limit) if offset is not None: query = cur.offset(offset) for r in cur: feed.add_item(title=r.subject, link=h.absurl(r.link.encode('utf-8')), pubdate=r.pubdate, description=r.text, unique_id=r.unique_id, author_name=r.author().display_name, author_link=h.absurl(r.author().url())) return feed def footer(self): template = self.view.get_template('mail/footer.txt') return template.render( dict(notification=self, prefix=config.get('forgemail.url', 'https://sourceforge.net'))) def send_simple(self, toaddr): allura.tasks.mail_tasks.sendsimplemail.post( toaddr=toaddr, fromaddr=self.from_address, reply_to=self.reply_to_address, subject=self.subject, message_id=self._id, in_reply_to=self.in_reply_to, text=(self.text or '') + self.footer()) def send_direct(self, user_id): user = User.query.get(_id=ObjectId(user_id)) artifact = self.ref.artifact # Don't send if user doesn't have read perms to the artifact if user and artifact and \ not security.has_access(artifact, 'read', user)(): log.debug("Skipping notification - User %s doesn't have read " "access to artifact %s" % (user_id, str(self.ref_id))) return allura.tasks.mail_tasks.sendmail.post(destinations=[str(user_id)], fromaddr=self.from_address, reply_to=self.reply_to_address, subject=self.subject, message_id=self._id, in_reply_to=self.in_reply_to, text=(self.text or '') + self.footer()) @classmethod def send_digest(self, user_id, from_address, subject, notifications, reply_to_address=None): if not notifications: return # Filter out notifications for which the user doesn't have read # permissions to the artifact. user = User.query.get(_id=ObjectId(user_id)) artifact = self.ref.artifact def perm_check(notification): return not (user and artifact) or \ security.has_access(artifact, 'read', user)() notifications = filter(perm_check, notifications) if reply_to_address is None: reply_to_address = from_address text = ['Digest of %s' % subject] for n in notifications: text.append('From: %s' % n.from_address) text.append('Subject: %s' % (n.subject or '(no subject)')) text.append('Message-ID: %s' % n._id) text.append('') text.append(n.text or '-no text-') text.append(n.footer()) text = '\n'.join(text) allura.tasks.mail_tasks.sendmail.post(destinations=[str(user_id)], fromaddr=from_address, reply_to=reply_to_address, subject=subject, message_id=h.gen_message_id(), text=text) @classmethod def send_summary(self, user_id, from_address, subject, notifications): if not notifications: return text = ['Digest of %s' % subject] for n in notifications: text.append('From: %s' % n.from_address) text.append('Subject: %s' % (n.subject or '(no subject)')) text.append('Message-ID: %s' % n._id) text.append('') text.append(h.text.truncate(n.text or '-no text-', 128)) text.append(n.footer()) text = '\n'.join(text) allura.tasks.mail_tasks.sendmail.post(destinations=[str(user_id)], fromaddr=from_address, reply_to=from_address, subject=subject, message_id=h.gen_message_id(), text=text)
class Feed(MappedClass): """ Used to generate rss/atom feeds. This does not need to be extended; all feed items go into the same collection """ class __mongometa__: session = project_orm_session name = 'artifact_feed' indexes = [ 'pubdate', ('artifact_ref.project_id', 'artifact_ref.mount_point'), (('ref_id', pymongo.ASCENDING), ('pubdate', pymongo.DESCENDING)), (('project_id', pymongo.ASCENDING), ('app_config_id', pymongo.ASCENDING), ('pubdate', pymongo.DESCENDING)), # used in ext/user_profile/user_main.py for user feeds 'author_link', # used in project feed (('project_id', pymongo.ASCENDING), ('pubdate', pymongo.DESCENDING)), ] _id = FieldProperty(S.ObjectId) ref_id = ForeignIdProperty('ArtifactReference') neighborhood_id = ForeignIdProperty('Neighborhood') project_id = ForeignIdProperty('Project') app_config_id = ForeignIdProperty('AppConfig') tool_name = FieldProperty(str) title = FieldProperty(str) link = FieldProperty(str) pubdate = FieldProperty(datetime, if_missing=datetime.utcnow) description = FieldProperty(str) description_cache = FieldProperty(MarkdownCache) unique_id = FieldProperty(str, if_missing=lambda: h.nonce(40)) author_name = FieldProperty(str, if_missing=lambda: c.user.get_pref( 'display_name') if hasattr(c, 'user') else None) author_link = FieldProperty( str, if_missing=lambda: c.user.url() if hasattr(c, 'user') else None) artifact_reference = FieldProperty(S.Deprecated) def clear_user_data(self): """ Redact author data """ self.author_name = "" self.author_link = "" title_parts = self.title.partition(" modified by ") self.title = u"".join(title_parts[0:2]) + (u"<REDACTED>" if title_parts[2] else '') @classmethod def from_username(cls, username): return cls.query.find({'author_link': u"/u/{}/".format(username)}).all() @classmethod def has_access(cls, artifact): # Enable only for development. # return True from allura import model as M anon = M.User.anonymous() if not security.has_access(artifact, 'read', user=anon): return False if not security.has_access(c.project, 'read', user=anon): return False return True @classmethod def post(cls, artifact, title=None, description=None, author=None, author_link=None, author_name=None, pubdate=None, link=None, **kw): """ Create a Feed item. Returns the item. But if anon doesn't have read access, create does not happen and None is returned. """ if not Feed.has_access(artifact): return idx = artifact.index() if author is None: author = c.user if author_name is None: author_name = author.get_pref('display_name') if title is None: title = '%s modified by %s' % ( h.get_first(idx, 'title'), author_name) if description is None: description = title if pubdate is None: pubdate = datetime.utcnow() if link is None: link = artifact.url() item = cls( ref_id=artifact.index_id(), neighborhood_id=artifact.app_config.project.neighborhood_id, project_id=artifact.app_config.project_id, app_config_id=artifact.app_config_id, tool_name=artifact.app_config.tool_name, title=title, description=g.markdown.convert(description), link=link, pubdate=pubdate, author_name=author_name, author_link=author_link or author.url()) unique_id = kw.pop('unique_id', None) if unique_id: item.unique_id = unique_id return item @classmethod def feed(cls, q, feed_type, title, link, description, since=None, until=None, page=None, limit=None): "Produces webhelper.feedgenerator Feed" d = dict(title=title, link=h.absurl(link), description=description, language=u'en', feed_url=request.url) if feed_type == 'atom': feed = FG.Atom1Feed(**d) elif feed_type == 'rss': feed = RssFeed(**d) limit, page = h.paging_sanitizer(limit or 10, page) query = defaultdict(dict) if callable(q): q = q(since, until, page, limit) query.update(q) if since is not None: query['pubdate']['$gte'] = since if until is not None: query['pubdate']['$lte'] = until cur = cls.query.find(query) cur = cur.sort('pubdate', pymongo.DESCENDING) cur = cur.limit(limit) cur = cur.skip(limit * page) for r in cur: feed.add_item(title=r.title, link=h.absurl(r.link.encode('utf-8')), pubdate=r.pubdate, description=r.description, unique_id=h.absurl(r.unique_id), author_name=r.author_name, author_link=h.absurl(r.author_link)) return feed
def _update_mounts(self, subproject=None, tool=None, new=None, **kw): ''' Returns the new App or Subproject, if one was installed. Returns None otherwise. ''' if subproject is None: subproject = [] if tool is None: tool = [] new_app = None for sp in subproject: p = M.Project.query.get(shortname=sp['shortname'], neighborhood_id=c.project.neighborhood_id) if sp.get('delete'): require_access(c.project, 'admin') M.AuditLog.log('delete subproject %s', sp['shortname']) p.removal = 'deleted' plugin.ProjectRegistrationProvider.get().delete_project( p, c.user) elif not new: M.AuditLog.log('update subproject %s', sp['shortname']) p.name = sp['name'] p.ordinal = int(sp['ordinal']) for p in tool: if p.get('delete'): require_access(c.project, 'admin') M.AuditLog.log('uninstall tool %s', p['mount_point']) c.project.uninstall_app(p['mount_point']) elif not new: M.AuditLog.log('update tool %s', p['mount_point']) options = c.project.app_config(p['mount_point']).options options.mount_label = p['mount_label'] options.ordinal = int(p['ordinal']) if new and new.get('install'): ep_name = new.get('ep_name', None) if not ep_name: require_access(c.project, 'create') mount_point = new['mount_point'].lower() or h.nonce() M.AuditLog.log('create subproject %s', mount_point) sp = c.project.new_subproject(mount_point) sp.name = new['mount_label'] if 'ordinal' in new: sp.ordinal = int(new['ordinal']) else: sp.ordinal = c.project.last_ordinal_value() + 1 new_app = sp else: require_access(c.project, 'admin') installable_tools = AdminApp.installable_tools_for(c.project) if not ep_name.lower() in [t['name'].lower() for t in installable_tools]: flash('Installation limit exceeded.', 'error') return mount_point = new['mount_point'] or ep_name M.AuditLog.log('install tool %s', mount_point) App = g.entry_points['tool'][ep_name] # pass only options which app expects config_on_install = { k: v for (k, v) in six.iteritems(kw) if k in [o.name for o in App.options_on_install()] } new_app = c.project.install_app( ep_name, mount_point, mount_label=new['mount_label'], ordinal=int(new['ordinal']) if 'ordinal' in new else None, **config_on_install) g.post_event('project_updated') g.post_event('project_menu_updated') return new_app
class Thread(Artifact, ActivityObject): class __mongometa__: name = 'thread' indexes = [ ('artifact_id',), ('ref_id',), (('app_config_id', pymongo.ASCENDING), ('last_post_date', pymongo.DESCENDING), ('mod_date', pymongo.DESCENDING)), ('discussion_id',), ] type_s = 'Thread' _id = FieldProperty(str, if_missing=lambda: h.nonce(8)) discussion_id = ForeignIdProperty(Discussion) ref_id = ForeignIdProperty('ArtifactReference') subject = FieldProperty(str, if_missing='') num_replies = FieldProperty(int, if_missing=0) num_views = FieldProperty(int, if_missing=0) subscriptions = FieldProperty({str: bool}) first_post_id = ForeignIdProperty('Post') last_post_date = FieldProperty(datetime, if_missing=datetime(1970, 1, 1)) artifact_reference = FieldProperty(schema.Deprecated) artifact_id = FieldProperty(schema.Deprecated) discussion = RelationProperty(Discussion) posts = RelationProperty('Post', via='thread_id') first_post = RelationProperty('Post', via='first_post_id') ref = RelationProperty('ArtifactReference') def should_update_index(self, old_doc, new_doc): """Skip index update if only `num_views` has changed. Value of `num_views` is updated whenever user loads thread page. This generates a lot of unnecessary `add_artifacts` tasks. """ old_doc.pop('num_views', None) new_doc.pop('num_views', None) return old_doc != new_doc def attachment_for_export(self, page): return [dict(bytes=attach.length, url=h.absurl(attach.url()), path=os.path.join( self.artifact.app_config.options.mount_point, str(self.artifact._id), self._id, page.slug, os.path.basename(attach.filename)) ) for attach in page.attachments] def attachments_for_json(self, page): return [dict(bytes=attach.length, url=h.absurl(attach.url())) for attach in page.attachments] def __json__(self, limit=None, page=None, is_export=False): return dict( _id=self._id, discussion_id=str(self.discussion_id), subject=self.subject, limit=limit, page=page, posts=[dict(slug=p.slug, text=p.text, subject=p.subject, author=p.author().username, author_icon_url=h.absurl(p.author().icon_url()), timestamp=p.timestamp, last_edited=p.last_edit_date, attachments=self.attachment_for_export(p) if is_export else self.attachments_for_json(p)) for p in self.query_posts(status='ok', style='chronological', limit=limit, page=page) ] ) @property def activity_name(self): return 'thread %s' % self.subject def parent_security_context(self): return self.discussion @classmethod def new(cls, **props): '''Creates a new Thread instance, ensuring a unique _id.''' for i in range(5): try: thread = cls(**props) session(thread).flush(thread) return thread except DuplicateKeyError as err: log.warning( 'Got DuplicateKeyError: attempt #%s, trying again. %s', i, err) if i == 4: raise session(thread).expunge(thread) continue @classmethod def discussion_class(cls): return cls.discussion.related @classmethod def post_class(cls): return cls.posts.related @classmethod def attachment_class(cls): return DiscussionAttachment @property def artifact(self): # Threads attached to a wiki page, ticket, etc will have a .ref.artifact pointing to that WikiPage etc # Threads that are part of a forum will not have that if self.ref is None: return self.discussion return self.ref.artifact # Use wisely - there's .num_replies also @property def post_count(self): return Post.query.find(dict( discussion_id=self.discussion_id, thread_id=self._id, status={'$in': ['ok', 'pending']}, deleted=False, )).count() def primary(self): if self.ref is None: return self return self.ref.artifact def post_to_feed(self, post): if post.status == 'ok': Feed.post( self.primary(), title=post.subject, description=post.text, link=post.url_paginated(), pubdate=post.mod_date, ) def add_post(self, **kw): """Helper function to avoid code duplication.""" p = self.post(**kw) p.commit(update_stats=False) session(self).flush(self) self.update_stats() if not self.first_post: self.first_post_id = p._id self.post_to_feed(p) return p def include_subject_in_spam_check(self, post): return (post.primary() == post # only artifacts where the discussion is the main thing i.e. ForumPost and self.num_replies == 0) # only first post in thread def is_spam(self, post): roles = [r.name for r in c.project.named_roles] spam_check_text = post.text if self.include_subject_in_spam_check(post): spam_check_text = self.subject + u'\n' + spam_check_text spammy = g.spam_checker.check(spam_check_text, artifact=post, user=c.user) if c.user in c.project.users_with_role(*roles): # always run the check, so it's logged. But don't act on it for admins/developers of their own project return False else: return spammy def post(self, text, message_id=None, parent_id=None, notify=True, notification_text=None, timestamp=None, ignore_security=False, is_meta=False, **kw): if not ignore_security: require_access(self, 'post') if self.ref_id and self.artifact: self.artifact.subscribe() if message_id is None: message_id = h.gen_message_id() parent = parent_id and self.post_class().query.get(_id=parent_id) slug, full_slug = self.post_class().make_slugs(parent, timestamp) kwargs = dict( discussion_id=self.discussion_id, full_slug=full_slug, slug=slug, thread_id=self._id, parent_id=parent_id, text=text, status='pending', is_meta=is_meta) if timestamp is not None: kwargs['timestamp'] = timestamp if message_id is not None: kwargs['_id'] = message_id post = self.post_class()(**kwargs) # unmoderated post -> autoapprove # unmoderated post but is spammy -> don't approve it, it goes into moderation # moderated post -> moderation # moderated post but is spammy -> mark as spam spammy = self.is_spam(post) if ignore_security or (not spammy and has_access(self, 'unmoderated_post')): log.info('Auto-approving message from %s', c.user.username) file_info = kw.get('file_info', None) post.approve(file_info, notify=notify, notification_text=notification_text) elif not has_access(self, 'unmoderated_post') and spammy: post.spam(submit_spam_feedback=False) # no feedback since we're marking as spam automatically not manually else: self.notify_moderators(post) return post def notify_moderators(self, post): ''' Notify moderators that a post needs approval [#2963] ''' artifact = self.artifact or self subject = '[%s:%s] Moderation action required' % ( c.project.shortname, c.app.config.options.mount_point) author = post.author() url = self.discussion_class().query.get(_id=self.discussion_id).url() text = ('The following submission requires approval at %s before ' 'it can be approved for posting:\n\n%s' % (h.absurl(url + 'moderate'), post.text)) n = Notification( ref_id=artifact.index_id(), topic='message', link=artifact.url(), _id=artifact.url() + post._id, from_address=str(author._id) if author != User.anonymous() else None, reply_to_address=g.noreply, subject=subject, text=text, in_reply_to=post.parent_id, author_id=author._id, pubdate=datetime.utcnow()) users = self.app_config.project.users() for u in users: if (has_access(self, 'moderate', u) and Mailbox.subscribed(user_id=u._id, app_config_id=post.app_config_id)): n.send_direct(str(u._id)) def update_stats(self): self.num_replies = self.post_class().query.find( dict(thread_id=self._id, status='ok', deleted=False)).count() @LazyProperty def last_post(self): q = self.post_class().query.find(dict( thread_id=self._id, deleted=False, )).sort('timestamp', pymongo.DESCENDING) return q.first() def create_post_threads(self, posts): result = [] post_index = {} for p in sorted(posts, key=lambda p: p.full_slug): pi = dict(post=p, children=[]) post_index[p._id] = pi if p.parent_id in post_index: post_index[p.parent_id]['children'].append(pi) else: result.append(pi) return result def query_posts(self, page=None, limit=None, timestamp=None, style='threaded', status=None): if timestamp: terms = dict(discussion_id=self.discussion_id, thread_id=self._id, status={'$in': ['ok', 'pending']}, timestamp=timestamp) else: terms = dict(discussion_id=self.discussion_id, thread_id=self._id, status={'$in': ['ok', 'pending']}) if status: terms['status'] = status terms['deleted'] = False q = self.post_class().query.find(terms) if style == 'threaded': q = q.sort('full_slug') else: q = q.sort('timestamp') if limit is not None: limit = int(limit) if page is not None: q = q.skip(page * limit) q = q.limit(limit) return q def find_posts(self, page=None, limit=None, timestamp=None, style='threaded'): return self.query_posts(page=page, limit=limit, timestamp=timestamp, style=style).all() def url(self): # Can't use self.discussion because it might change during the req discussion = self.discussion_class().query.get(_id=self.discussion_id) return discussion.url() + 'thread/' + str(self._id) + '/' def shorthand_id(self): return self._id def index(self): result = Artifact.index(self) result.update( title=self.subject or '(no subject)', name_s=self.subject, views_i=self.num_views, text=self.subject) return result def delete(self): for p in self.post_class().query.find(dict(thread_id=self._id)): p.delete() self.attachment_class().remove(dict(thread_id=self._id)) super(Thread, self).delete() def spam(self): """Mark this thread as spam.""" for p in self.post_class().query.find(dict(thread_id=self._id)): p.spam()