Add proper messaging if an organization has gone over the repo limit. This change also moves plan information into the server

This commit is contained in:
Joseph Schorr 2013-11-01 19:13:58 -04:00
parent 1f0b142535
commit 9fa77aaa48
6 changed files with 226 additions and 99 deletions

View file

@ -183,7 +183,8 @@ def lookup_organization(name, username=None):
if name == 'testorg': if name == 'testorg':
return dotdict({ return dotdict({
'username': 'testorg', 'username': 'testorg',
'email': 'testorg@quay.io' 'email': 'testorg@quay.io',
'stripe_id': ''
}) })
return None return None

47
data/plans.py Normal file
View file

@ -0,0 +1,47 @@
import json
USER_PLANS = [
{
'title': 'Open Source',
'price': 0,
'privateRepos': 0,
'stripeId': 'free',
'audience': 'Share with the world',
},
{
'title': 'Micro',
'price': 700,
'privateRepos': 5,
'stripeId': 'micro',
'audience': 'For smaller teams',
},
{
'title': 'Basic',
'price': 1200,
'privateRepos': 10,
'stripeId': 'small',
'audience': 'For your basic team',
},
{
'title': 'Medium',
'price': 2200,
'privateRepos': 20,
'stripeId': 'medium',
'audience': 'For medium-sized teams',
},
]
def getPlan(id):
""" Returns the plan with the given ID or None if none. """
for plan in USER_PLANS:
if plan['stripeId'] == id:
return plan
return None
def isPlanActive(stripe_subscription):
""" Returns whether the plan is active. """
# TODO: this.
return True

View file

@ -16,6 +16,7 @@ import storage
from data import model from data import model
from data.userfiles import UserRequestFiles from data.userfiles import UserRequestFiles
from data.queue import dockerfile_build_queue from data.queue import dockerfile_build_queue
from data.plans import USER_PLANS, getPlan, isPlanActive
from app import app from app import app
from util.email import send_confirmation_email, send_recovery_email from util.email import send_confirmation_email, send_recovery_email
from util.names import parse_repository_name from util.names import parse_repository_name
@ -45,11 +46,13 @@ def api_login_required(f):
def handle_dme(ex): def handle_dme(ex):
return make_response(ex.message, 400) return make_response(ex.message, 400)
@app.route('/api/') @app.route('/api/')
def welcome(): def welcome():
return make_response('welcome', 200) return make_response('welcome', 200)
@app.route('/api/plans/')
def plans_list():
return jsonify({ 'plans': USER_PLANS })
@app.route('/api/user/', methods=['GET']) @app.route('/api/user/', methods=['GET'])
def get_logged_in_user(): def get_logged_in_user():
@ -216,6 +219,31 @@ def get_organization(orgname):
return jsonify(org_view(organization, teams)) return jsonify(org_view(organization, teams))
@app.route('/api/organization/<orgname>/private', methods=['GET'])
def get_organization_private_allowed(orgname):
if current_user.is_anonymous():
abort(404)
user = current_user.db_user()
organization = model.lookup_organization(orgname, username = user.username)
if not organization:
abort(404)
private_repos = model.get_private_repo_count(organization.username)
if organization.stripe_id:
cus = stripe.Customer.retrieve(organization.stripe_id)
if cus.subscription and isPlanActive(cus.subscription):
repos_allowed = getPlan(cus.subscription.plan.id)
return jsonify({
'privateAllowed': (private_repos < repos_allowed)
})
return jsonify({
'privateAllowed': False
})
@app.route('/api/repository', methods=['POST']) @app.route('/api/repository', methods=['POST'])
@api_login_required @api_login_required
def create_repo_api(): def create_repo_api():
@ -786,7 +814,7 @@ def get_subscription():
if user.stripe_id: if user.stripe_id:
cus = stripe.Customer.retrieve(user.stripe_id) cus = stripe.Customer.retrieve(user.stripe_id)
if cus.subscription: if cus.subscription:
return jsonify(subscription_view(cus.subscription, private_repos)) return jsonify(subscription_view(cus.subscription, private_repos))
return jsonify({ return jsonify({

View file

@ -10,6 +10,7 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
} }
var userService = {} var userService = {}
var currentSubscription = null;
userService.load = function() { userService.load = function() {
var userFetch = Restangular.one('user/'); var userFetch = Restangular.one('user/');
@ -30,6 +31,20 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
}); });
}; };
userService.resetCurrentSubscription = function() {
currentSubscription = null;
};
userService.getCurrentSubscription = function(callback, failure) {
if (currentSubscription) { callback(currentSubscription); }
var getSubscription = Restangular.one('user/plan');
getSubscription.get().then(function(sub) {
currentSubscription = sub;
callback(sub);
}, failure);
};
userService.currentUser = function() { userService.currentUser = function() {
return userResponse; return userResponse;
} }
@ -55,62 +70,48 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
}]); }]);
$provide.factory('PlanService', ['Restangular', 'KeyService', function(Restangular, KeyService) { $provide.factory('PlanService', ['Restangular', 'KeyService', function(Restangular, KeyService) {
var plans = [ var plans = null;
{
title: 'Open Source',
price: 0,
privateRepos: 0,
stripeId: 'free',
audience: 'Share with the world',
},
{
title: 'Micro',
price: 700,
privateRepos: 5,
stripeId: 'micro',
audience: 'For smaller teams',
},
{
title: 'Basic',
price: 1200,
privateRepos: 10,
stripeId: 'small',
audience: 'For your basic team',
},
{
title: 'Medium',
price: 2200,
privateRepos: 20,
stripeId: 'medium',
audience: 'For medium-sized teams',
},
];
var planDict = {}; var planDict = {};
var i;
for(i = 0; i < plans.length; i++) {
planDict[plans[i].stripeId] = plans[i];
}
var planService = {} var planService = {}
planService.planList = function() { planService.verifyLoaded = function(callback) {
return plans; if (plans) {
}; callback(plans);
return;
planService.getPlan = function(planId) {
return planDict[planId];
};
planService.getMinimumPlan = function(privateCount) {
for (var i = 0; i < plans.length; i++) {
var plan = plans[i];
if (plan.privateRepos >= privateCount) {
return plan;
}
} }
return null; var getPlans = Restangular.one('plans');
getPlans.get().then(function(data) {
for(var i = 0; i < data.plans.length; i++) {
planDict[data.plans[i].stripeId] = data.plans[i];
}
plans = data.plans;
callback(plans);
}, function() { callback([]); });
};
planService.getPlanList = function(callback) {
planService.verifyLoaded(callback);
};
planService.getPlan = function(planId, callback) {
planService.verifyLoaded(function() {
callback(planDict[planId]);
});
};
planService.getMinimumPlan = function(privateCount, callback) {
planService.verifyLoaded(function() {
for (var i = 0; i < plans.length; i++) {
var plan = plans[i];
if (plan.privateRepos >= privateCount) {
callback(plan);
return;
}
}
callback(null);
});
}; };
planService.showSubscribeDialog = function($scope, planId, started, success, failed) { planService.showSubscribeDialog = function($scope, planId, started, success, failed) {
@ -132,16 +133,17 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
}); });
}; };
var planDetails = planService.getPlan(planId) planService.getPlan(planId, function(planDetails) {
StripeCheckout.open({ StripeCheckout.open({
key: KeyService.stripePublishableKey, key: KeyService.stripePublishableKey,
address: false, // TODO change to true address: false,
amount: planDetails.price, amount: planDetails.price,
currency: 'usd', currency: 'usd',
name: 'Quay ' + planDetails.title + ' Subscription', name: 'Quay ' + planDetails.title + ' Subscription',
description: 'Up to ' + planDetails.privateRepos + ' private repositories', description: 'Up to ' + planDetails.privateRepos + ' private repositories',
panelLabel: 'Subscribe', panelLabel: 'Subscribe',
token: submitToken token: submitToken
});
}); });
}; };

View file

@ -156,7 +156,11 @@ function SigninCtrl($scope, $location, $timeout, Restangular, KeyService, UserSe
}; };
function PlansCtrl($scope, UserService, PlanService) { function PlansCtrl($scope, UserService, PlanService) {
$scope.plans = PlanService.planList(); // Load the list of plans.
PlanService.getPlanList(function(plans) {
$scope.plans = plans;
$scope.status = 'ready';
});
$scope.$watch( function () { return UserService.currentUser(); }, function (currentUser) { $scope.$watch( function () { return UserService.currentUser(); }, function (currentUser) {
$scope.user = currentUser; $scope.user = currentUser;
@ -169,8 +173,6 @@ function PlansCtrl($scope, UserService, PlanService) {
$('#signinModal').modal({}); $('#signinModal').modal({});
} }
}; };
$scope.status = 'ready';
} }
function GuideCtrl($scope) { function GuideCtrl($scope) {
@ -743,7 +745,10 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) {
} }
function UserAdminCtrl($scope, $timeout, Restangular, PlanService, UserService, KeyService, $routeParams) { function UserAdminCtrl($scope, $timeout, Restangular, PlanService, UserService, KeyService, $routeParams) {
$scope.plans = PlanService.planList(); // Load the list of plans.
PlanService.getPlanList(function(plans) {
$scope.plans = plans;
});
$scope.$watch(function () { return UserService.currentUser(); }, function (currentUser) { $scope.$watch(function () { return UserService.currentUser(); }, function (currentUser) {
$scope.askForPassword = currentUser.askForPassword; $scope.askForPassword = currentUser.askForPassword;
@ -751,28 +756,29 @@ function UserAdminCtrl($scope, $timeout, Restangular, PlanService, UserService,
var subscribedToPlan = function(sub) { var subscribedToPlan = function(sub) {
$scope.subscription = sub; $scope.subscription = sub;
$scope.subscribedPlan = PlanService.getPlan(sub.plan); PlanService.getPlan(sub.plan, function(subscribedPlan) {
$scope.planUsagePercent = sub.usedPrivateRepos * 100 / $scope.subscribedPlan.privateRepos; $scope.subscribedPlan = subscribedPlan;
$scope.planUsagePercent = sub.usedPrivateRepos * 100 / $scope.subscribedPlan.privateRepos;
if (sub.usedPrivateRepos > $scope.subscribedPlan.privateRepos) { if (sub.usedPrivateRepos > $scope.subscribedPlan.privateRepos) {
$scope.errorMessage = 'You are using more private repositories than your plan allows, please upgrate your subscription to avoid disruptions in your service.'; $scope.errorMessage = 'You are using more private repositories than your plan allows, please upgrate your subscription to avoid disruptions in your service.';
} else { } else {
$scope.errorMessage = null; $scope.errorMessage = null;
} }
$scope.planLoading = false; $scope.planLoading = false;
$scope.planChanging = false; $scope.planChanging = false;
mixpanel.people.set({ mixpanel.people.set({
'plan': sub.plan 'plan': sub.plan
}); });
});
}; };
$scope.planLoading = true; $scope.planLoading = true;
var getSubscription = Restangular.one('user/plan'); UserService.getCurrentSubscription(subscribedToPlan, function() {
getSubscription.get().then(subscribedToPlan, function() {
// User has no subscription // User has no subscription
$scope.planLoading = false; $scope.planChanging = false;
}); });
$scope.planChanging = false; $scope.planChanging = false;
@ -782,6 +788,7 @@ function UserAdminCtrl($scope, $timeout, Restangular, PlanService, UserService,
$scope.planChanging = true; $scope.planChanging = true;
}, function(plan) { }, function(plan) {
// Subscribed. // Subscribed.
UserService.resetCurrentSubscription();
subscribedToPlan(plan); subscribedToPlan(plan);
}, function() { }, function() {
// Failure. // Failure.
@ -798,6 +805,7 @@ function UserAdminCtrl($scope, $timeout, Restangular, PlanService, UserService,
plan: planId, plan: planId,
}; };
UserService.resetCurrentSubscription();
var changeSubscriptionRequest = Restangular.one('user/plan'); var changeSubscriptionRequest = Restangular.one('user/plan');
changeSubscriptionRequest.customPUT(subscriptionDetails).then(subscribedToPlan, function() { changeSubscriptionRequest.customPUT(subscriptionDetails).then(subscribedToPlan, function() {
// Failure // Failure
@ -813,9 +821,11 @@ function UserAdminCtrl($scope, $timeout, Restangular, PlanService, UserService,
// Show the subscribe dialog if a plan was requested. // Show the subscribe dialog if a plan was requested.
var requested = $routeParams['plan'] var requested = $routeParams['plan']
if (requested !== undefined && requested !== 'free') { if (requested !== undefined && requested !== 'free') {
if (PlanService.getPlan(requested) !== undefined) { PlanService.getPlan(requested, function(found) {
$scope.subscribe(requested); if (found) {
} $scope.subscribe(requested);
}
});
} }
$scope.updatingUser = false; $scope.updatingUser = false;
@ -1039,11 +1049,21 @@ function NewRepoCtrl($scope, $location, $http, UserService, Restangular, PlanSer
var subscribedToPlan = function(sub) { var subscribedToPlan = function(sub) {
$scope.planChanging = false; $scope.planChanging = false;
$scope.subscription = sub; $scope.subscription = sub;
$scope.subscribedPlan = PlanService.getPlan(sub.plan);
$scope.planRequired = null; PlanService.getPlan(sub.plan, function(subscribedPlan) {
if ($scope.subscription.usedPrivateRepos >= $scope.subscribedPlan.privateRepos) { $scope.subscribedPlan = subscribedPlan;
$scope.planRequired = PlanService.getMinimumPlan($scope.subscription.usedPrivateRepos); $scope.planRequired = null;
}
// Check to see if the current plan allows for an additional private repository to
// be created.
var privateAllowed = $scope.subscription.usedPrivateRepos < $scope.subscribedPlan.privateRepos;
if (!privateAllowed) {
// If not, find the minimum repository that does.
PlanService.getMinimumPlan($scope.subscription.usedPrivateRepos + 1, function(minimum) {
$scope.planRequired = minimum;
});
}
});
}; };
$scope.editDescription = function() { $scope.editDescription = function() {
@ -1109,6 +1129,7 @@ function NewRepoCtrl($scope, $location, $http, UserService, Restangular, PlanSer
$scope.planChanging = true; $scope.planChanging = true;
}, function(plan) { }, function(plan) {
// Subscribed. // Subscribed.
UserService.resetCurrentSubscription();
subscribedToPlan(plan); subscribedToPlan(plan);
}, function() { }, function() {
// Failure. // Failure.
@ -1116,15 +1137,37 @@ function NewRepoCtrl($scope, $location, $http, UserService, Restangular, PlanSer
$scope.planChanging = false; $scope.planChanging = false;
}); });
}; };
// Watch the namespace on the repo. If it changes, we update the plan and the public/private
// accordingly.
$scope.$watch('repo.namespace', function(namespace) {
// Note: Can initially be undefined.
if (!namespace) { return; }
var isUserNamespace = (namespace == $scope.user.username);
$scope.plans = PlanService.planList(); $scope.planRequired = null;
$scope.isUserNamespace = isUserNamespace;
// Load the user's subscription information in case they want to create a private if (isUserNamespace) {
// repository. // Load the user's subscription information in case they want to create a private
var getSubscription = Restangular.one('user/plan'); // repository.
getSubscription.get().then(subscribedToPlan, function() { UserService.getCurrentSubscription(subscribedToPlan, function() {
// User has no subscription PlanService.getMinimumPlan(1, function(minimum) { $scope.planRequired = minimum; });
$scope.planRequired = PlanService.getMinimumPlan(1); });
} else {
$scope.planRequired = null;
var checkPrivateAllowed = Restangular.one('organization/' + namespace + '/private');
checkPrivateAllowed.get().then(function(resp) {
$scope.planRequired = resp.privateAllowed ? null : {};
}, function() {
$scope.planRequired = {};
});
// Auto-set to private repo.
$scope.repo.is_public = '0';
}
}); });
} }

View file

@ -72,13 +72,19 @@
</div> </div>
<!-- Payment --> <!-- Payment -->
<div class="required-plan" ng-show="repo.is_public == '0' && planRequired"> <div class="required-plan" ng-show="repo.is_public == '0' && planRequired && isUserNamespace">
<div class="alert alert-warning"> <div class="alert alert-warning">
In order to make this repository private, youll need to upgrade your plan from <b>{{ subscribedPlan.title }}</b> to <b>{{ planRequired.title }}</b>. This will cost $<span>{{ planRequired.price / 100 }}</span>/month. In order to make this repository private, youll need to upgrade your plan from <b>{{ subscribedPlan.title }}</b> to <b>{{ planRequired.title }}</b>. This will cost $<span>{{ planRequired.price / 100 }}</span>/month.
</div> </div>
<a class="btn btn-primary" ng-click="upgradePlan()" ng-show="!planChanging">Upgrade now</a> <a class="btn btn-primary" ng-click="upgradePlan()" ng-show="!planChanging">Upgrade now</a>
<i class="fa fa-spinner fa-spin fa-3x" ng-show="planChanging"></i> <i class="fa fa-spinner fa-spin fa-3x" ng-show="planChanging"></i>
</div> </div>
<div class="required-plan" ng-show="repo.is_public == '0' && planRequired && !isUserNamespace">
<div class="alert alert-warning">
This organization has reached its private repository limit. Please contact your administrator.
</div>
</div>
</div> </div>
</div> </div>
</div> </div>