Permalink
Comparing changes
Open a pull request
- 12 commits
- 12 files changed
- 0 commit comments
- 4 contributors
Unified
Split
Showing
with
236 additions
and 31 deletions.
- +1 −0 .travis.yml
- +12 −0 h/accounts/models.py
- +185 −0 h/claim/invite.py
- +5 −5 h/static/scripts/app.coffee
- +9 −6 h/static/scripts/directive/annotation.coffee
- +12 −2 h/static/scripts/directive/test/annotation-test.coffee
- +1 −1 h/static/scripts/test/widget-controller-test.coffee
- +5 −4 h/static/scripts/widget-controller.coffee
- +0 −8 h/static/styles/base.scss
- +3 −1 h/static/styles/forms.scss
- +1 −4 h/templates/pattern_library.html
- +2 −0 setup.py
| @@ -6,6 +6,7 @@ install: | ||
| - gem install sass | ||
| - gem install compass | ||
| - pip install coveralls | ||
| - pip install mandrill | ||
| - pip install prospector | ||
| - make | ||
| services: | ||
| @@ -90,6 +90,7 @@ def get_by_username_or_email(cls, request, username, email): | ||
| ) | ||
| ).first() | ||
| # TODO: remove all this status bitfield stuff | ||
| @property | ||
| def email_confirmed(self): | ||
| return bool((self.status or 0) & 0b001) | ||
| @@ -123,6 +124,17 @@ def subscriptions(self, value): | ||
| else: | ||
| self.status = (self.status or 0) & ~0b100 | ||
| @property | ||
| def invited(self): | ||
| return bool((self.status or 0) & 0b1000) | ||
| @invited.setter | ||
| def invited(self, value): | ||
| if value: | ||
| self.status = (self.status or 0) | 0b1000 | ||
| else: | ||
| self.status = (self.status or 0) & ~0b1000 | ||
| def _username_to_uid(username): | ||
| # We normalise usernames by dots and case in order to discourage attempts | ||
| @@ -0,0 +1,185 @@ | ||
| # -*- coding: utf-8 -*- | ||
| """ | ||
| :mod:`h.claim.invite` is a utility to invite users to claim accounts | ||
| and is exposed as the command-line utility hypothesis-invite. | ||
| """ | ||
| import argparse | ||
| import logging | ||
| import os | ||
| import sys | ||
| import time | ||
| import mandrill | ||
| from pyramid import paster | ||
| from pyramid.request import Request | ||
| import transaction | ||
| from h.accounts import models | ||
| from h.claim.util import generate_claim_url | ||
| log = logging.getLogger('h.claim.invite') | ||
| def get_env(config_uri): | ||
| """Return a preconfigured paste environment object.""" | ||
| env = paster.bootstrap(config_uri) | ||
| return env | ||
| parser = argparse.ArgumentParser( | ||
| 'hypothesis-invite', | ||
| description='Send invitation emails to users.' | ||
| ) | ||
| parser.add_argument('config_uri', help='paster configuration URI') | ||
| parser.add_argument( | ||
| '--base', | ||
| help='base URL', | ||
| default='http://localhost:5000', | ||
| metavar='URL' | ||
| ) | ||
| parser.add_argument( | ||
| '-n', | ||
| '--dry-run', | ||
| help='dry run (log but do not send email)', | ||
| action='store_true', | ||
| ) | ||
| parser.add_argument( | ||
| '-l', | ||
| '--limit', | ||
| type=int, | ||
| metavar='N', | ||
| help='maximum users to invite', | ||
| ) | ||
| parser.add_argument( | ||
| '-k', | ||
| '--key', | ||
| metavar='KEY', | ||
| help='Mandrill API key (defaults to MANDRILL_APIKEY variable)', | ||
| default=os.environ.get('MANDRILL_APIKEY'), | ||
| ) | ||
| def get_users(session, limit=None): | ||
| return ( | ||
| session | ||
| .query(models.User) | ||
| .filter( | ||
| models.User.password == u'', | ||
| models.User.status.op('&')(0b1000) == 0 # noqa | ||
| ) | ||
| .limit(limit) | ||
| .all() | ||
| ) | ||
| def get_merge_vars(request, users): | ||
| for user in users: | ||
| userid = 'acct:{}@{}'.format(user.username, request.host_url) | ||
| claim = generate_claim_url(request, userid) | ||
| recipient = user.email | ||
| merge_vars = [ | ||
| { | ||
| 'name': 'USERNAME', | ||
| 'content': user.username, | ||
| }, | ||
| { | ||
| 'name': 'CLAIM_URL', | ||
| 'content': claim, | ||
| }, | ||
| ] | ||
| yield { | ||
| 'rcpt': recipient, | ||
| 'vars': merge_vars | ||
| } | ||
| def get_recipients(users): | ||
| for user in users: | ||
| yield { | ||
| 'email': user.email, | ||
| 'name': user.username, | ||
| } | ||
| def send_invitations(request, api_key, users): | ||
| log.info('Collecting merge vars and recipients.') | ||
| merge_vars = list(get_merge_vars(request, users)) | ||
| recipients = list(get_recipients(users)) | ||
| try: | ||
| results = mandrill.Mandrill(api_key).messages.send_template( | ||
| template_content=[], | ||
| template_name='activation-email-to-reserved-usernames', | ||
| message={ | ||
| 'merge_vars': merge_vars, | ||
| 'to': recipients, | ||
| } | ||
| ) | ||
| except mandrill.Error: | ||
| log.exception('Error sending invitations.') | ||
| sys.exit(1) | ||
| return group_users_by_result(users, results) | ||
| def mark_invited(session, users): | ||
| for user in users: | ||
| user.invited = True | ||
| session.add(user) | ||
| transaction.commit() | ||
| def group_users_by_result(users, results): | ||
| users_by_email = {user.email: user for user in users} | ||
| success = [] | ||
| error = [] | ||
| for row in results: | ||
| user = users_by_email[row['email']] | ||
| if row['status'] in ['queued', 'sent']: | ||
| success.append(user) | ||
| else: | ||
| error.append(user) | ||
| return success, error | ||
| def main(): | ||
| args = parser.parse_args() | ||
| request = Request.blank('', base_url=args.base) | ||
| env = paster.bootstrap(args.config_uri, request=request) | ||
| request.root = env['root'] | ||
| paster.setup_logging(args.config_uri) | ||
| if not args.dry_run: | ||
| if args.key is None: | ||
| print 'No Mandrill API key specified.' | ||
| parser.print_help() | ||
| sys.exit(1) | ||
| # Provide an opportunity to bail out. | ||
| log.warning('Changes will be made and mail will be sent.') | ||
| log.info('Waiting five seconds.') | ||
| time.sleep(5) | ||
| log.info('Collecting reserved users.') | ||
| session = models.get_session(request) | ||
| users = get_users(session, limit=args.limit) | ||
| if args.dry_run: | ||
| log.info('Skipping actions ignored by dry run.') | ||
| success, error = users, [] | ||
| else: | ||
| log.info('Sending invitations to %d users.', len(users)) | ||
| success, error = send_invitations(request, args.key, users) | ||
| log.info('Marking users as invited.') | ||
| mark_invited(session, success) | ||
| log.info('%d succeeded / %d failed', len(success), len(error)) | ||
| sys.exit(0) | ||
| if __name__ == '__main__': | ||
| main() |
| @@ -86,11 +86,6 @@ module.exports = angular.module('h', [ | ||
| 'toastr' | ||
| ]) | ||
| .config(configureDocument) | ||
| .config(configureLocation) | ||
| .config(configureRoutes) | ||
| .config(configureTemplates) | ||
| .controller('AppController', require('./app-controller')) | ||
| .controller('AnnotationUIController', require('./annotation-ui-controller')) | ||
| .controller('AnnotationViewerController', require('./annotation-viewer-controller')) | ||
| @@ -151,6 +146,11 @@ module.exports = angular.module('h', [ | ||
| .value('AnnotationUISync', require('./annotation-ui-sync')) | ||
| .value('Discovery', require('./discovery')) | ||
| .config(configureDocument) | ||
| .config(configureLocation) | ||
| .config(configureRoutes) | ||
| .config(configureTemplates) | ||
| .run(setupCrossFrame) | ||
| .run(setupStreamer) | ||
| .run(setupHost) | ||
| @@ -211,15 +211,15 @@ AnnotationController = [ | ||
| @annotationURI = new URL("/a/#{@annotation.id}", this.baseURI).href | ||
| # Extract the document metadata. | ||
| uri = model.uri | ||
| domain = new URL(uri).hostname | ||
| if model.document | ||
| uri = model.uri | ||
| if uri.indexOf("urn") is 0 | ||
| # This URI is not clickable, see if we have something better | ||
| for link in model.document.link when link.href.indexOf("urn") | ||
| uri = link.href | ||
| break | ||
| domain = new URL(uri).hostname | ||
| documentTitle = if Array.isArray(model.document.title) | ||
| model.document.title[0] | ||
| else | ||
| @@ -229,11 +229,14 @@ AnnotationController = [ | ||
| uri: uri | ||
| domain: domain | ||
| title: documentTitle or domain | ||
| if @document.title.length > 30 | ||
| @document.title = @document.title[0..29] + '…' | ||
| else | ||
| @document = null | ||
| @document = | ||
| uri: uri | ||
| domain: domain | ||
| title: domain | ||
| if @document.title.length > 30 | ||
| @document.title = @document.title[0..29] + '…' | ||
| # Form the tags for ngTagsInput. | ||
| @annotation.tags = ({text} for text in (model.tags or [])) | ||
| @@ -209,10 +209,20 @@ describe 'annotation', -> | ||
| controller.render() | ||
| assert.equal(controller.document.title, 'example.com') | ||
| it 'skips the document object if no document is present on the annotation', -> | ||
| it 'still sets the uri correctly if the annotation has no document', -> | ||
| delete annotation.document | ||
| controller.render() | ||
| assert.isNull(controller.document) | ||
| assert(controller.document.uri == $scope.annotation.uri) | ||
| it 'still sets the domain correctly if the annotation has no document', -> | ||
| delete annotation.document | ||
| controller.render() | ||
| assert(controller.document.domain == 'example.com') | ||
| it 'uses the domain for the title when the annotation has no document', -> | ||
| delete annotation.document | ||
| controller.render() | ||
| assert(controller.document.title == 'example.com') | ||
| describe 'when there are no targets', -> | ||
| beforeEach -> | ||
| @@ -59,7 +59,6 @@ describe 'WidgetController', -> | ||
| $provide.value 'annotationMapper', fakeAnnotationMapper | ||
| $provide.value 'annotationUI', fakeAnnotationUI | ||
| $provide.value 'auth', fakeAuth | ||
| $provide.value 'crossframe', fakeCrossFrame | ||
| $provide.value 'store', fakeStore | ||
| $provide.value 'streamer', fakeStreamer | ||
| @@ -75,6 +74,7 @@ describe 'WidgetController', -> | ||
| describe 'loadAnnotations', -> | ||
| it 'loads all annotation for a provider', -> | ||
| viewer.chunkSize = 20 | ||
| fakeCrossFrame.providers.push {entities: ['http://example.com']} | ||
| $scope.$digest() | ||
| loadSpy = fakeAnnotationMapper.loadAnnotations | ||
| @@ -4,21 +4,22 @@ angular = require('angular') | ||
| module.exports = class WidgetController | ||
| this.$inject = [ | ||
| '$scope', 'annotationUI', 'crossframe', 'annotationMapper', | ||
| 'auth', 'streamer', 'streamFilter', 'store' | ||
| 'streamer', 'streamFilter', 'store' | ||
| ] | ||
| constructor: ( | ||
| $scope, annotationUI, crossframe, annotationMapper, | ||
| auth, streamer, streamFilter, store | ||
| streamer, streamFilter, store | ||
| ) -> | ||
| # Tells the view that these annotations are embedded into the owner doc | ||
| $scope.isEmbedded = true | ||
| $scope.isStream = true | ||
| @chunkSize = 200 | ||
| loaded = [] | ||
| _loadAnnotationsFrom = (query, offset) -> | ||
| _loadAnnotationsFrom = (query, offset) => | ||
| queryCore = | ||
| limit: 20 | ||
| limit: @chunkSize | ||
| offset: offset | ||
| sort: 'created' | ||
| order: 'asc' | ||
| @@ -33,11 +33,3 @@ | ||
| display: none; | ||
| visibility: hidden; | ||
| } | ||
| // Icons | ||
| [class^="h-icon-"], [class*=" h-icon-"] { | ||
| vertical-align: middle; | ||
| &:before { | ||
| font-size: 130%; | ||
| } | ||
| } | ||
| @@ -257,7 +257,9 @@ | ||
| // Positions the icon nicely within the button. | ||
| .btn-icon { | ||
| vertical-align: middle; | ||
| font-size: 1.4em; | ||
| margin-right: .25em; | ||
| vertical-align: top; | ||
| } | ||
| // Absolutely positions a message/icon to the left of a button. | ||
| @@ -383,7 +383,7 @@ <h1>Buttons</h1> | ||
| <div class="form-field"> | ||
| <button class="btn js-disabled" type="submit" name=""> | ||
| <span class="btn-icon spinner"><span><span></span></span></span> | ||
| <span style="position: relative; bottom: 3px; left: 1px;">Button Loading</span> | ||
| Button Loading | ||
| </button> | ||
| </div> | ||
| </div> | ||
| @@ -511,9 +511,6 @@ <h1>Simple Search</h1> | ||
| <form class="simple-search-form"> | ||
| <input class="simple-search-input" type="text" name="searchText" placeholder="Search…" /> | ||
| <i class="simple-search-icon h-icon-search"></i> | ||
| <button class="simple-search-clear" type="reset"> | ||
| <i class="h-icon-clear"></i> | ||
| </button> | ||
| </form> | ||
| </div> | ||
| </div> | ||
Oops, something went wrong.