diff --git a/README.md b/README.md index 818fc5024..5a43f8325 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ running: ``` sudo nginx -c `pwd`/nginx.conf -STACK=prod gunicorn -D --workers 4 -b unix:/tmp/gunicorn.sock --worker-class eventlet -t 500 application:application +STACK=prod gunicorn -D --workers 4 -b unix:/tmp/gunicorn.sock --worker-class eventlet -t 2000 application:application ``` set up the snapshot script: diff --git a/application.py b/application.py index d3feefcb0..706c64a8c 100644 --- a/application.py +++ b/application.py @@ -11,6 +11,7 @@ import endpoints.api import endpoints.web import endpoints.tags import endpoints.registry +import endpoints.webhooks logger = logging.getLogger(__name__) diff --git a/data/database.py b/data/database.py index fc5a68454..1dd51788a 100644 --- a/data/database.py +++ b/data/database.py @@ -34,6 +34,7 @@ class User(BaseModel): verified = BooleanField(default=False) stripe_id = CharField(index=True, null=True) organization = BooleanField(default=False, index=True) + invoice_email = BooleanField(default=False) class TeamRole(BaseModel): diff --git a/data/model.py b/data/model.py index cfbb9340c..ea42ef7c9 100644 --- a/data/model.py +++ b/data/model.py @@ -296,6 +296,12 @@ def get_user(username): return None +def get_user_or_org_by_customer_id(customer_id): + try: + return User.get(User.stripe_id == customer_id) + except User.DoesNotExist: + return None + def get_matching_teams(team_prefix, organization): query = Team.select().where(Team.name ** (team_prefix + '%'), Team.organization == organization) @@ -491,6 +497,11 @@ def change_password(user, new_password): user.save() +def change_invoice_email(user, invoice_email): + user.invoice_email = invoice_email + user.save() + + def update_email(user, new_email): user.email = new_email user.verified = False diff --git a/endpoints/api.py b/endpoints/api.py index 8f93bd0d8..062ad69e1 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -71,8 +71,7 @@ def plans_list(): }) -@app.route('/api/user/', methods=['GET']) -def get_logged_in_user(): +def user_view(user): def org_view(o): admin_org = AdministerOrganizationPermission(o.username) return { @@ -82,16 +81,9 @@ def get_logged_in_user(): 'can_create_repo': admin_org.can() or CreateRepositoryPermission(o.username).can() } - if current_user.is_anonymous(): - return jsonify({'anonymous': True}) - - user = current_user.db_user() - if not user or user.organization: - return jsonify({'anonymous': True}) - organizations = model.get_user_organizations(user.username) - return jsonify({ + return { 'verified': user.verified, 'anonymous': False, 'username': user.username, @@ -99,8 +91,21 @@ def get_logged_in_user(): 'gravatar': compute_hash(user.email), 'askForPassword': user.password_hash is None, 'organizations': [org_view(o) for o in organizations], - 'can_create_repo': True - }) + 'can_create_repo': True, + 'invoice_email': user.invoice_email + } + + +@app.route('/api/user/', methods=['GET']) +def get_logged_in_user(): + if current_user.is_anonymous(): + return jsonify({'anonymous': True}) + + user = current_user.db_user() + if not user or user.organization: + return jsonify({'anonymous': True}) + + return jsonify(user_view(user)) @app.route('/api/user/convert', methods=['POST']) @@ -150,6 +155,11 @@ def change_user_details(): if 'password' in user_data: logger.debug('Changing password for user: %s', user.username) model.change_password(user, user_data['password']) + + if 'invoice_email' in user_data: + logger.debug('Changing invoice_email for user: %s', user.username) + model.change_invoice_email(user, user_data['invoice_email']) + except model.InvalidPasswordException, ex: error_resp = jsonify({ 'message': ex.message, @@ -157,14 +167,7 @@ def change_user_details(): error_resp.status_code = 400 return error_resp - return jsonify({ - 'verified': user.verified, - 'anonymous': False, - 'username': user.username, - 'email': user.email, - 'gravatar': compute_hash(user.email), - 'askForPassword': user.password_hash is None, - }) + return jsonify(user_view(user)) @app.route('/api/user/', methods=['POST']) @@ -340,6 +343,23 @@ def create_organization_api(): return error_resp +def org_view(o, teams): + admin_org = AdministerOrganizationPermission(o.username) + is_admin = admin_org.can() + view = { + 'name': o.username, + 'email': o.email if is_admin else '', + 'gravatar': compute_hash(o.email), + 'teams': {t.name : team_view(o.username, t) for t in teams}, + 'is_admin': is_admin + } + + if is_admin: + view['invoice_email'] = o.invoice_email + + return view + + @app.route('/api/organization/', methods=['GET']) @api_login_required def get_organization(orgname): @@ -347,17 +367,6 @@ def get_organization(orgname): if permission.can(): user = current_user.db_user() - def org_view(o, teams): - admin_org = AdministerOrganizationPermission(orgname) - is_admin = admin_org.can() - return { - 'name': o.username, - 'email': o.email if is_admin else '', - 'gravatar': compute_hash(o.email), - 'teams': {t.name : team_view(orgname, t) for t in teams}, - 'is_admin': is_admin - } - try: org = model.get_organization(orgname) except model.InvalidOrganizationException: @@ -368,6 +377,28 @@ def get_organization(orgname): abort(403) + +@app.route('/api/organization/', methods=['PUT']) +@api_login_required +def change_organization_details(orgname): + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + try: + org = model.get_organization(orgname) + except model.InvalidOrganizationException: + abort(404) + + org_data = request.get_json(); + if 'invoice_email' in org_data: + logger.debug('Changing invoice_email for organization: %s', org.username) + model.change_invoice_email(org, org_data['invoice_email']) + + teams = model.get_teams_within_org(org) + return jsonify(org_view(org, teams)) + + abort(403) + + @app.route('/api/organization//members', methods=['GET']) @api_login_required def get_organization_members(orgname): @@ -1187,6 +1218,80 @@ def subscription_view(stripe_subscription, used_repos): } +@app.route('/api/user/card', methods=['GET']) +@api_login_required +def get_user_card_api(): + user = current_user.db_user() + return jsonify(get_card(user)) + + +@app.route('/api/organization//card', methods=['GET']) +@api_login_required +def get_org_card_api(orgname): + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + organization = model.get_organization(orgname) + return jsonify(get_card(organization)) + + abort(403) + + +@app.route('/api/user/card', methods=['POST']) +@api_login_required +def set_user_card_api(): + user = current_user.db_user() + token = request.get_json()['token'] + return jsonify(set_card(user, token)) + + +@app.route('/api/organization//card', methods=['POST']) +@api_login_required +def set_org_card_api(orgname): + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + organization = model.get_organization(orgname) + token = request.get_json()['token'] + return jsonify(set_card(organization, token)) + + abort(403) + + +def set_card(user, token): + print token + + if user.stripe_id: + cus = stripe.Customer.retrieve(user.stripe_id) + if cus: + cus.card = token + cus.save() + + return get_card(user) + + +def get_card(user): + card_info = { + 'is_valid': False + } + + if user.stripe_id: + cus = stripe.Customer.retrieve(user.stripe_id) + if cus and cus.default_card: + # Find the default card. + default_card = None + for card in cus.cards.data: + if card.id == cus.default_card: + default_card = card + break + + if default_card: + card_info = { + 'owner': card.name, + 'type': card.type, + 'last4': card.last4 + } + + return {'card': card_info} + @app.route('/api/user/plan', methods=['PUT']) @api_login_required def subscribe_api(): @@ -1218,7 +1323,7 @@ def subscribe(user, plan, token, accepted_plans): if not user.stripe_id: # Check if a non-paying user is trying to subscribe to a free plan if not plan_found['price'] == 0: - # They want a real paying plan, create the customerand plan + # They want a real paying plan, create the customer and plan # simultaneously card = token cus = stripe.Customer.create(email=user.email, plan=plan, card=card) diff --git a/endpoints/index.py b/endpoints/index.py index 6b0cde6d8..0fb1ad990 100644 --- a/endpoints/index.py +++ b/endpoints/index.py @@ -157,10 +157,15 @@ def create_repository(namespace, repository): response = make_response('Created', 201) + extra_params = { + 'repository': '%s/%s' % (namespace, repository), + } + if get_authenticated_user(): - mixpanel.track(get_authenticated_user().username, 'push_repo') + mixpanel.track(get_authenticated_user().username, 'push_repo', + extra_params) else: - mixpanel.track(get_validated_token().code, 'push_repo') + mixpanel.track(get_validated_token().code, 'push_repo', extra_params) return response @@ -220,7 +225,10 @@ def get_repository_images(namespace, repository): if get_authenticated_user(): pull_username = get_authenticated_user().username - mixpanel.track(pull_username, 'pull_repo') + extra_params = { + 'repository': '%s/%s' % (namespace, repository), + } + mixpanel.track(pull_username, 'pull_repo', extra_params) return resp diff --git a/endpoints/webhooks.py b/endpoints/webhooks.py new file mode 100644 index 000000000..9675fe000 --- /dev/null +++ b/endpoints/webhooks.py @@ -0,0 +1,42 @@ +import logging +import requests +import stripe + +from flask import (abort, redirect, request, url_for, render_template, + make_response) +from flask.ext.login import login_user, UserMixin, login_required +from flask.ext.principal import identity_changed, Identity, AnonymousIdentity + +from data import model +from app import app, login_manager, mixpanel +from auth.permissions import QuayDeferredPermissionUser +from data.plans import USER_PLANS, BUSINESS_PLANS, get_plan +from util.invoice import renderInvoiceToHtml +from util.email import send_invoice_email + +logger = logging.getLogger(__name__) + + +@app.route('/webhooks/stripe', methods=['POST']) +def stripe_webhook(): + request_data = request.get_json() + logger.debug('Stripe webhook call: %s' % request_data) + + event_type = request_data['type'] if 'type' in request_data else None + if event_type == 'charge.succeeded': + data = request_data['data'] if 'data' in request_data else {} + obj = data['object'] if 'object' in data else {} + invoice_id = obj['invoice'] if 'invoice' in obj else None + customer_id = obj['customer'] if 'customer' in obj else None + + if invoice_id and customer_id: + # Find the user associated with the customer ID. + user = model.get_user_or_org_by_customer_id(customer_id) + if user and user.invoice_email: + # Lookup the invoice. + invoice = stripe.Invoice.retrieve(invoice_id) + if invoice: + invoice_html = renderInvoiceToHtml(invoice, user) + send_invoice_email(user.email, invoice_html) + + return make_response('Okay') diff --git a/nginx.conf b/nginx.conf index ed4f1a279..1e28e8a09 100644 --- a/nginx.conf +++ b/nginx.conf @@ -33,7 +33,7 @@ http { server { listen 443 default; - client_max_body_size 4G; + client_max_body_size 8G; server_name _; keepalive_timeout 5; @@ -63,6 +63,7 @@ http { proxy_buffering off; proxy_pass http://app_server; + proxy_read_timeout 2000; } } } diff --git a/static/css/quay.css b/static/css/quay.css index 72d6f41d5..dcf702505 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -6,6 +6,50 @@ visibility: hidden; } +.billing-options-element .current-card { + font-size: 16px; + margin-bottom: 20px; +} + + +.billing-options-element .current-card .no-card-outline { + display: inline-block; + width: 73px; + height: 44px; + vertical-align: middle; + margin-right: 10px; + border: 1px dashed #aaa; + border-radius: 4px; +} + +.billing-options-element .current-card .last4 { + color: #aaa; +} + +.billing-options-element .current-card .last4 b { + color: black; +} + +.billing-options-element .current-card img { + margin-right: 10px; + vertical-align: middle; +} + +.settings-option { + padding: 4px; + font-size: 18px; + margin-bottom: 10px; +} + +.settings-option label { + margin-left: 6px; +} + +.settings-option .settings-description { + font-size: 12px; + color: #aaa; +} + .organization-header-element { padding: 20px; margin-bottom: 20px; diff --git a/static/directives/billing-options.html b/static/directives/billing-options.html new file mode 100644 index 000000000..1543e5239 --- /dev/null +++ b/static/directives/billing-options.html @@ -0,0 +1,39 @@ +
+ +
+
+ Credit Card +
+
+ +
+ + + + ****-****-****-{{ currentCard.last4 }} + No credit card found +
+ + +
+
+ + +
+
+ Billing Options + +
+
+
+ + +
+ If checked, a receipt email will be sent to {{ obj.email }} on every successful charge +
+
+
+
+
diff --git a/static/img/creditcards/amex.png b/static/img/creditcards/amex.png new file mode 100644 index 000000000..492d40aff Binary files /dev/null and b/static/img/creditcards/amex.png differ diff --git a/static/img/creditcards/credit.png b/static/img/creditcards/credit.png new file mode 100644 index 000000000..0b8f49aa7 Binary files /dev/null and b/static/img/creditcards/credit.png differ diff --git a/static/img/creditcards/dankort.png b/static/img/creditcards/dankort.png new file mode 100644 index 000000000..400f57c95 Binary files /dev/null and b/static/img/creditcards/dankort.png differ diff --git a/static/img/creditcards/diners.png b/static/img/creditcards/diners.png new file mode 100644 index 000000000..72e1808fb Binary files /dev/null and b/static/img/creditcards/diners.png differ diff --git a/static/img/creditcards/discover.png b/static/img/creditcards/discover.png new file mode 100644 index 000000000..7e36dd715 Binary files /dev/null and b/static/img/creditcards/discover.png differ diff --git a/static/img/creditcards/forbru.png b/static/img/creditcards/forbru.png new file mode 100644 index 000000000..2668cdb3d Binary files /dev/null and b/static/img/creditcards/forbru.png differ diff --git a/static/img/creditcards/google.png b/static/img/creditcards/google.png new file mode 100644 index 000000000..ab9566bde Binary files /dev/null and b/static/img/creditcards/google.png differ diff --git a/static/img/creditcards/jcb.png b/static/img/creditcards/jcb.png new file mode 100644 index 000000000..9bb0cbafd Binary files /dev/null and b/static/img/creditcards/jcb.png differ diff --git a/static/img/creditcards/laser.png b/static/img/creditcards/laser.png new file mode 100644 index 000000000..2f2417942 Binary files /dev/null and b/static/img/creditcards/laser.png differ diff --git a/static/img/creditcards/maestro.png b/static/img/creditcards/maestro.png new file mode 100644 index 000000000..38fdb4b7d Binary files /dev/null and b/static/img/creditcards/maestro.png differ diff --git a/static/img/creditcards/mastercard.png b/static/img/creditcards/mastercard.png new file mode 100644 index 000000000..af9b1d701 Binary files /dev/null and b/static/img/creditcards/mastercard.png differ diff --git a/static/img/creditcards/money.png b/static/img/creditcards/money.png new file mode 100644 index 000000000..a99814aa1 Binary files /dev/null and b/static/img/creditcards/money.png differ diff --git a/static/img/creditcards/paypa.png b/static/img/creditcards/paypa.png new file mode 100644 index 000000000..4fad9d864 Binary files /dev/null and b/static/img/creditcards/paypa.png differ diff --git a/static/img/creditcards/shopify.png b/static/img/creditcards/shopify.png new file mode 100644 index 000000000..f40146b1b Binary files /dev/null and b/static/img/creditcards/shopify.png differ diff --git a/static/img/creditcards/solo.png b/static/img/creditcards/solo.png new file mode 100644 index 000000000..c236ecc0f Binary files /dev/null and b/static/img/creditcards/solo.png differ diff --git a/static/img/creditcards/visa.png b/static/img/creditcards/visa.png new file mode 100644 index 000000000..ffd17c5a2 Binary files /dev/null and b/static/img/creditcards/visa.png differ diff --git a/static/js/app.js b/static/js/app.js index 2a0d08236..26d7aa5da 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -122,8 +122,22 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics', $provide.factory('PlanService', ['Restangular', 'KeyService', 'UserService', function(Restangular, KeyService, UserService) { var plans = null; var planDict = {}; - var planService = {} + var planService = {}; + var listeners = []; + planService.registerListener = function(obj, callback) { + listeners.push({'obj': obj, 'callback': callback}); + }; + + planService.unregisterListener = function(obj) { + for (var i = 0; i < listeners.length; ++i) { + if (listeners[i].obj == obj) { + listeners.splice(i, 1); + break; + } + } + }; + planService.verifyLoaded = function(callback) { if (plans) { callback(plans); @@ -192,8 +206,8 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics', }); }; - planService.getSubscription = function(organization, success, failure) { - var url = planService.getSubscriptionUrl(organization); + planService.getSubscription = function(orgname, success, failure) { + var url = planService.getSubscriptionUrl(orgname); var getSubscription = Restangular.one(url); getSubscription.get().then(success, failure); }; @@ -213,19 +227,93 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics', var url = planService.getSubscriptionUrl(orgname); var createSubscriptionRequest = Restangular.one(url); - createSubscriptionRequest.customPUT(subscriptionDetails).then(success, failure); + createSubscriptionRequest.customPUT(subscriptionDetails).then(function(resp) { + success(resp); + planService.getPlan(planId, function(plan) { + for (var i = 0; i < listeners.length; ++i) { + listeners[i]['callback'](plan); + } + }); + }, failure); }; - planService.changePlan = function($scope, orgname, planId, hasExistingSubscription, callbacks) { - if (!hasExistingSubscription) { - planService.showSubscribeDialog($scope, orgname, planId, callbacks); - return; - } - + planService.getCardInfo = function(orgname, callback) { + var url = orgname ? getRestUrl('organization', orgname, 'card') : 'user/card'; + var getCard = Restangular.one(url); + getCard.customGET().then(function(resp) { + callback(resp.card); + }, function() { + callback({'is_valid': false}); + }); + }; + + planService.changePlan = function($scope, orgname, planId, callbacks) { if (callbacks['started']) { - callbacks['started'](); + callbacks['started'](); } - planService.setSubscription(orgname, planId, callbacks['success'], callbacks['failure']); + + planService.getPlan(planId, function(plan) { + planService.getCardInfo(orgname, function(cardInfo) { + if (plan.price > 0 && !cardInfo.last4) { + planService.showSubscribeDialog($scope, orgname, planId, callbacks); + return; + } + + planService.setSubscription(orgname, planId, callbacks['success'], callbacks['failure']); + }); + }); + }; + + planService.changeCreditCard = function($scope, orgname, callbacks) { + if (callbacks['opening']) { + callbacks['opening'](); + } + + var submitToken = function(token) { + $scope.$apply(function() { + if (callbacks['started']) { + callbacks['started'](); + } + + var cardInfo = { + 'token': token.id + }; + + var url = orgname ? getRestUrl('organization', orgname, 'card') : 'user/card'; + var changeCardRequest = Restangular.one(url); + changeCardRequest.customPOST(cardInfo).then(callbacks['success'], callbacks['failure']); + }); + }; + + var email = planService.getEmail(orgname); + StripeCheckout.open({ + key: KeyService.stripePublishableKey, + address: false, + email: email, + currency: 'usd', + name: 'Update credit card', + description: 'Enter your credit card number', + panelLabel: 'Update', + token: submitToken, + image: 'static/img/quay-icon-stripe.png', + opened: function() { $scope.$apply(function() { callbacks['opened']() }); }, + closed: function() { $scope.$apply(function() { callbacks['closed']() }); } + }); + }; + + planService.getEmail = function(orgname) { + var email = null; + if (UserService.currentUser()) { + email = UserService.currentUser().email; + + if (orgname) { + org = UserService.getOrganization(orgname); + if (org) { + emaiil = org.email; + } + } + } + return email; }; planService.showSubscribeDialog = function($scope, orgname, planId, callbacks) { @@ -240,23 +328,12 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics', if (callbacks['started']) { callbacks['started'](); } - planService.setSubscription(orgname, planId, callbacks['success'], callbacks['failure']); + planService.setSubscription(orgname, planId, callbacks['success'], callbacks['failure'], token); }); }; planService.getPlan(planId, function(planDetails) { - var email = null; - if (UserService.currentUser()) { - email = UserService.currentUser().email; - - if (orgname) { - org = UserService.getOrganization(orgname); - if (org) { - emaiil = org.email; - } - } - } - + var email = planService.getEmail(orgname); StripeCheckout.open({ key: KeyService.stripePublishableKey, address: false, @@ -621,6 +698,106 @@ quayApp.directive('roleGroup', function () { }); +quayApp.directive('billingOptions', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/billing-options.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'user': '=user', + 'organization': '=organization' + }, + controller: function($scope, $element, PlanService, Restangular) { + $scope.invoice_email = false; + $scope.currentCard = null; + + // Listen to plan changes. + PlanService.registerListener(this, function(plan) { + if (plan && plan.price > 0) { + update(); + } + }); + + $scope.$on('$destroy', function() { + PlanService.unregisterListener(this); + }); + + $scope.changeCard = function() { + $scope.changingCard = true; + var callbacks = { + 'opened': function() { $scope.changingCard = true; }, + 'closed': function() { $scope.changingCard = false; }, + 'started': function() { $scope.currentCard = null; }, + 'success': function(resp) { + $scope.currentCard = resp.card; + $scope.changingCard = false; + }, + 'failure': function() { + $('#couldnotchangecardModal').modal({}); + $scope.changingCard = false; + } + }; + + PlanService.changeCreditCard($scope, $scope.organization ? $scope.organization.name : null, callbacks); + }; + + $scope.getCreditImage = function(creditInfo) { + if (!creditInfo || !creditInfo.type) { return 'credit.png'; } + + var kind = creditInfo.type.toLowerCase() || 'credit'; + var supported = { + 'american express': 'amex', + 'credit': 'credit', + 'diners club': 'diners', + 'discover': 'discover', + 'jcb': 'jcb', + 'mastercard': 'mastercard', + 'visa': 'visa' + }; + + kind = supported[kind] || 'credit'; + return kind + '.png'; + }; + + var update = function() { + if (!$scope.user && !$scope.organization) { return; } + $scope.obj = $scope.user ? $scope.user : $scope.organization; + $scope.invoice_email = $scope.obj.invoice_email; + + // Load the credit card information. + PlanService.getCardInfo($scope.organization ? $scope.organization.name : null, function(card) { + $scope.currentCard = card; + }); + }; + + var save = function() { + $scope.working = true; + var url = $scope.organization ? getRestUrl('organization', $scope.organization.name) : 'user/'; + var conductSave = Restangular.one(url); + conductSave.customPUT($scope.obj).then(function(resp) { + $scope.working = false; + }); + }; + + var checkSave = function() { + if (!$scope.obj) { return; } + if ($scope.obj.invoice_email != $scope.invoice_email) { + $scope.obj.invoice_email = $scope.invoice_email; + save(); + } + }; + + $scope.$watch('invoice_email', checkSave); + $scope.$watch('organization', update); + $scope.$watch('user', update); + } + }; + return directiveDefinitionObject; +}); + + quayApp.directive('planManager', function () { var directiveDefinitionObject = { priority: 0, @@ -631,7 +808,8 @@ quayApp.directive('planManager', function () { scope: { 'user': '=user', 'organization': '=organization', - 'readyForPlan': '&readyForPlan' + 'readyForPlan': '&readyForPlan', + 'planChanged': '&planChanged' }, controller: function($scope, $element, PlanService, Restangular) { var hasSubscription = false; @@ -652,7 +830,7 @@ quayApp.directive('planManager', function () { 'failure': function() { $scope.planChanging = false; } }; - PlanService.changePlan($scope, $scope.organization, planId, hasSubscription, callbacks); + PlanService.changePlan($scope, $scope.organization, planId, callbacks); }; $scope.cancelSubscription = function() { @@ -669,6 +847,10 @@ quayApp.directive('planManager', function () { PlanService.getPlan(sub.plan, function(subscribedPlan) { $scope.subscribedPlan = subscribedPlan; $scope.planUsagePercent = sub.usedPrivateRepos * 100 / $scope.subscribedPlan.privateRepos; + + if ($scope.planChanged) { + $scope.planChanged({ 'plan': subscribedPlan }); + } if (sub.usedPrivateRepos > $scope.subscribedPlan.privateRepos) { $scope.limit = 'over'; diff --git a/static/js/controllers.js b/static/js/controllers.js index e4e3d5d4f..2d475f5f7 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -728,6 +728,10 @@ function UserAdminCtrl($scope, $timeout, $location, Restangular, PlanService, Us $('.form-change-pw').popover(); + $scope.planChanged = function(plan) { + $scope.hasPaidPlan = plan && plan.price > 0; + }; + $scope.showConvertForm = function() { PlanService.getMatchingBusinessPlan(function(plan) { $scope.org.plan = plan; @@ -1206,6 +1210,10 @@ function OrgAdminCtrl($rootScope, $scope, Restangular, $routeParams, UserService $scope.membersFound = null; $scope.invoiceLoading = true; + $scope.planChanged = function(plan) { + $scope.hasPaidPlan = plan && plan.price > 0; + }; + $scope.loadInvoices = function() { if ($scope.invoices) { return; } $scope.invoiceLoading = true; diff --git a/static/partials/org-admin.html b/static/partials/org-admin.html index bba531439..46e3af4ea 100644 --- a/static/partials/org-admin.html +++ b/static/partials/org-admin.html @@ -15,7 +15,8 @@ @@ -24,10 +25,15 @@
-
+
- + +
+
+
+ +
@@ -52,7 +58,8 @@ Paid - Thank you! - Payment failed - Will retry soon + Payment failed + Payment failed - Will retry soon Payment pending @@ -69,7 +76,6 @@
Plan
{{ plan_map[invoice.plan].title }} - {{ plan_map[invoice.plan].price / 100 }}
diff --git a/static/partials/repo-admin.html b/static/partials/repo-admin.html index d0a0e0d4f..ada422d74 100644 --- a/static/partials/repo-admin.html +++ b/static/partials/repo-admin.html @@ -11,164 +11,189 @@
-
+

{{repo.namespace}} / {{repo.name}}

-
+
- -
-
User and Team Access Permissions - - +
+ + -
- - - - - - - - - - - - - - - - - - - - - - - +
+ +
User/TeamPermissions
- - {{name}} - - - - - - - -
- - {{name}} - - -
- + + +
+ +
+ +
+ +
+
User and Team Access Permissions + +
-
- - - - -
+ + + + + + + + + + + + + + + + + + + + + - - - -
User/TeamPermissions
+ + {{name}} + + + + + + + +
+ + {{name}} + + +
+ +
+
+ + + + +
- -
-
-
+ + + + + + +
+
- -
-
Access Token Permissions + +
+
Access Token Permissions - -
-
-
- - - - - - - - - - - - - - - - - - -
Token DescriptionPermissions
- - {{ token.friendlyName }} - -
- - + +
+
+ + + + + + + + + + + + + + + + + + + + +
Token DescriptionPermissions
+ + {{ token.friendlyName }} + +
+ + +
+
+ + + + +
+ + + +
+ +
+ + + + + +
+ +
+
+
+
+ + This repository is currently private. Only users on the permissions list may view and interact with it. + +
+ +
-
- - - - -
- - - -
- -
-
+
+
- -
-
Repository Settings
-
-
-
+ This repository is currently public and is visible to all users, and may be pulled by all users. - This repository is currently private. Only users on the above access list may view and interact with it. - -
- +
+ +
+
+
+
-
-
-
- - This repository is currently public and is visible to all users, and may be pulled by all users. - -
- + +
+ +
+
+
+
Deleting a repository cannot be undone. Here be dragons!
+ +
+
+
-
- -
-
Delete Repository
-
-
-
Deleting a repository cannot be undone. Here be dragons!
- -
-
-
- + - + diff --git a/static/partials/user-admin.html b/static/partials/user-admin.html index 0cfb27e2c..e6c7123e6 100644 --- a/static/partials/user-admin.html +++ b/static/partials/user-admin.html @@ -27,6 +27,7 @@
@@ -37,7 +38,7 @@
-
+
@@ -57,6 +58,11 @@
+ + +
+
+
diff --git a/test/data/test.db b/test/data/test.db index 20ed3819d..e7e752706 100644 Binary files a/test/data/test.db and b/test/data/test.db differ diff --git a/util/email.py b/util/email.py index 2698ccbed..79cd2dd0f 100644 --- a/util/email.py +++ b/util/email.py @@ -39,3 +39,11 @@ def send_recovery_email(email, token): recipients=[email]) msg.html = RECOVERY_MESSAGE % (token, token) mail.send(msg) + + +def send_invoice_email(email, contents): + msg = Message('Quay.io payment received - Thank you!', + sender='support@quay.io', # Why do I need this? + recipients=[email]) + msg.html = contents + mail.send(msg) diff --git a/util/invoice.py b/util/invoice.py new file mode 100644 index 000000000..773c428f1 --- /dev/null +++ b/util/invoice.py @@ -0,0 +1,36 @@ +from datetime import datetime +from jinja2 import Environment, FileSystemLoader + +jinja_options = { + "loader": FileSystemLoader('util'), +} + +env = Environment(**jinja_options) + +def renderInvoiceToHtml(invoice, user): + """ Renders a nice HTML display for the given invoice. """ + def get_price(price): + if not price: + return '$0' + + return '$' + '{0:.2f}'.format(float(price) / 100) + + def get_range(line): + if line.period and line.period.start and line.period.end: + return ': ' + format_date(line.period.start) + ' - ' + format_date(line.period.end) + return '' + + def format_date(timestamp): + return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d') + + data = { + 'user': user.username, + 'invoice': invoice, + 'invoice_date': format_date(invoice.date), + 'getPrice': get_price, + 'getRange': get_range + } + + template = env.get_template('invoice.tmpl') + rendered = template.render(data) + return rendered diff --git a/util/invoice.tmpl b/util/invoice.tmpl new file mode 100644 index 000000000..95cb5a23a --- /dev/null +++ b/util/invoice.tmpl @@ -0,0 +1,63 @@ + + + + + + + + +
+ Quay.io + +

Quay.io

+

+ DevTable, LLC
+ https://devtable.com
+ PO Box 48
+ New York, NY 10009 +

+
+

RECEIPT

+ + + +
Date:{{ invoice_date }}
Invoice #:{{ invoice.id }}
+
+ +
+ + + + + + + +{%- for line in invoice.lines.data -%} + + + + +{%- endfor -%} + + + + + + + +
DescriptionLine Total
{{ line.description or ('Plan Subscription' + getRange(line)) }}{{ getPrice(line.amount) }}
+ + + + + + +
Subtotal: {{ getPrice(invoice.subtotal) }}
Total: {{ getPrice(invoice.total) }}
Paid: {{ getPrice(invoice.total) if invoice.paid else 0 }}
Total Due:{{ getPrice(invoice.ending_balance) }}
+
+ +
+ We thank you for your continued business! +
+ + + diff --git a/util/validation.py b/util/validation.py index bbe02e017..895767d98 100644 --- a/util/validation.py +++ b/util/validation.py @@ -1,5 +1,4 @@ import re -import urllib INVALID_PASSWORD_MESSAGE = 'Invalid password, password must be at least ' + \ '8 characters and contain no whitespace.' @@ -12,9 +11,9 @@ def validate_email(email_address): def validate_username(username): # Minimum length of 2, maximum length of 255, no url unsafe characters - return (urllib.quote(username, safe='') == username and - len(username) > 1 and - len(username) < 256) + return (re.search(r'[^a-z0-9_]', username) is None and + len(username) >= 4 and + len(username) <= 30) def validate_password(password):