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:
		
							parent
							
								
									1f0b142535
								
							
						
					
					
						commit
						9fa77aaa48
					
				
					 6 changed files with 226 additions and 99 deletions
				
			
		|  | @ -183,7 +183,8 @@ def lookup_organization(name, username=None): | |||
|   if name == 'testorg': | ||||
|     return dotdict({ | ||||
|       'username': 'testorg', | ||||
|       'email': 'testorg@quay.io' | ||||
|       'email': 'testorg@quay.io', | ||||
|       'stripe_id': '' | ||||
|     }) | ||||
| 
 | ||||
|   return None | ||||
|  |  | |||
							
								
								
									
										47
									
								
								data/plans.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								data/plans.py
									
										
									
									
									
										Normal 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 | ||||
|  | @ -16,6 +16,7 @@ import storage | |||
| from data import model | ||||
| from data.userfiles import UserRequestFiles | ||||
| from data.queue import dockerfile_build_queue | ||||
| from data.plans import USER_PLANS, getPlan, isPlanActive | ||||
| from app import app | ||||
| from util.email import send_confirmation_email, send_recovery_email | ||||
| from util.names import parse_repository_name | ||||
|  | @ -45,11 +46,13 @@ def api_login_required(f): | |||
| def handle_dme(ex): | ||||
|   return make_response(ex.message, 400) | ||||
| 
 | ||||
| 
 | ||||
| @app.route('/api/') | ||||
| def welcome(): | ||||
|   return make_response('welcome', 200) | ||||
| 
 | ||||
| @app.route('/api/plans/') | ||||
| def plans_list(): | ||||
|   return jsonify({ 'plans': USER_PLANS }) | ||||
| 
 | ||||
| @app.route('/api/user/', methods=['GET']) | ||||
| def get_logged_in_user(): | ||||
|  | @ -216,6 +219,31 @@ def get_organization(orgname): | |||
|   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']) | ||||
| @api_login_required | ||||
| def create_repo_api(): | ||||
|  | @ -786,7 +814,7 @@ def get_subscription(): | |||
|   if 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({ | ||||
|  |  | |||
							
								
								
									
										124
									
								
								static/js/app.js
									
										
									
									
									
								
							
							
						
						
									
										124
									
								
								static/js/app.js
									
										
									
									
									
								
							|  | @ -10,6 +10,7 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics', | |||
|       } | ||||
| 
 | ||||
|       var userService = {} | ||||
|       var currentSubscription = null; | ||||
| 
 | ||||
|       userService.load = function() { | ||||
|         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() { | ||||
|         return userResponse; | ||||
|       } | ||||
|  | @ -55,62 +70,48 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics', | |||
|     }]); | ||||
| 
 | ||||
|     $provide.factory('PlanService', ['Restangular', 'KeyService', function(Restangular, KeyService) { | ||||
|       var 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', | ||||
|         }, | ||||
|       ]; | ||||
| 
 | ||||
|       var plans = null; | ||||
|       var planDict = {}; | ||||
|       var i; | ||||
|       for(i = 0; i < plans.length; i++) { | ||||
|         planDict[plans[i].stripeId] = plans[i]; | ||||
|       } | ||||
| 
 | ||||
|       var planService = {} | ||||
| 
 | ||||
|       planService.planList = function() { | ||||
|         return plans; | ||||
|       }; | ||||
| 
 | ||||
|       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; | ||||
|           } | ||||
|       planService.verifyLoaded = function(callback) { | ||||
|         if (plans) { | ||||
|           callback(plans); | ||||
|           return; | ||||
|         } | ||||
| 
 | ||||
|         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) { | ||||
|  | @ -132,16 +133,17 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics', | |||
|           }); | ||||
|         }; | ||||
| 
 | ||||
|         var planDetails = planService.getPlan(planId) | ||||
|         StripeCheckout.open({ | ||||
|           key:         KeyService.stripePublishableKey, | ||||
|           address:     false, // TODO change to true
 | ||||
|           amount:      planDetails.price, | ||||
|           currency:    'usd', | ||||
|           name:        'Quay ' + planDetails.title + ' Subscription', | ||||
|           description: 'Up to ' + planDetails.privateRepos + ' private repositories', | ||||
|           panelLabel:  'Subscribe', | ||||
|           token:       submitToken | ||||
|         planService.getPlan(planId, function(planDetails) { | ||||
|           StripeCheckout.open({ | ||||
|             key:         KeyService.stripePublishableKey, | ||||
|             address:     false, | ||||
|             amount:      planDetails.price, | ||||
|             currency:    'usd', | ||||
|             name:        'Quay ' + planDetails.title + ' Subscription', | ||||
|             description: 'Up to ' + planDetails.privateRepos + ' private repositories', | ||||
|             panelLabel:  'Subscribe', | ||||
|             token:       submitToken | ||||
|           }); | ||||
|         }); | ||||
|       }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -156,7 +156,11 @@ function SigninCtrl($scope, $location, $timeout, Restangular, KeyService, UserSe | |||
| }; | ||||
| 
 | ||||
| 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.user = currentUser; | ||||
|  | @ -169,8 +173,6 @@ function PlansCtrl($scope, UserService, PlanService) { | |||
|       $('#signinModal').modal({}); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   $scope.status = 'ready'; | ||||
| } | ||||
| 
 | ||||
| function GuideCtrl($scope) { | ||||
|  | @ -743,7 +745,10 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) { | |||
| } | ||||
| 
 | ||||
| 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.askForPassword = currentUser.askForPassword; | ||||
|  | @ -751,28 +756,29 @@ function UserAdminCtrl($scope, $timeout, Restangular, PlanService, UserService, | |||
| 
 | ||||
|   var subscribedToPlan = function(sub) { | ||||
|     $scope.subscription = sub; | ||||
|     $scope.subscribedPlan = PlanService.getPlan(sub.plan); | ||||
|     $scope.planUsagePercent = sub.usedPrivateRepos * 100 / $scope.subscribedPlan.privateRepos; | ||||
|     PlanService.getPlan(sub.plan, function(subscribedPlan) { | ||||
|       $scope.subscribedPlan = subscribedPlan; | ||||
|       $scope.planUsagePercent = sub.usedPrivateRepos * 100 / $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.'; | ||||
|     } else { | ||||
|       $scope.errorMessage = null; | ||||
|     } | ||||
|       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.'; | ||||
|       } else { | ||||
|         $scope.errorMessage = null; | ||||
|       } | ||||
| 
 | ||||
|     $scope.planLoading = false; | ||||
|     $scope.planChanging = false; | ||||
|       $scope.planLoading = false; | ||||
|       $scope.planChanging = false; | ||||
| 
 | ||||
|     mixpanel.people.set({ | ||||
|       'plan': sub.plan | ||||
|     }); | ||||
|       mixpanel.people.set({ | ||||
|         'plan': sub.plan | ||||
|       }); | ||||
|     });     | ||||
|   }; | ||||
| 
 | ||||
|   $scope.planLoading = true; | ||||
|   var getSubscription = Restangular.one('user/plan'); | ||||
|   getSubscription.get().then(subscribedToPlan, function() { | ||||
|   UserService.getCurrentSubscription(subscribedToPlan, function() { | ||||
|     // User has no subscription
 | ||||
|     $scope.planLoading = false; | ||||
|     $scope.planChanging = false; | ||||
|   }); | ||||
| 
 | ||||
|   $scope.planChanging = false; | ||||
|  | @ -782,6 +788,7 @@ function UserAdminCtrl($scope, $timeout, Restangular, PlanService, UserService, | |||
|       $scope.planChanging = true; | ||||
|     }, function(plan) { | ||||
|       // Subscribed.
 | ||||
|       UserService.resetCurrentSubscription(); | ||||
|       subscribedToPlan(plan); | ||||
|     }, function() { | ||||
|       // Failure.
 | ||||
|  | @ -798,6 +805,7 @@ function UserAdminCtrl($scope, $timeout, Restangular, PlanService, UserService, | |||
|       plan: planId, | ||||
|     }; | ||||
| 
 | ||||
|     UserService.resetCurrentSubscription(); | ||||
|     var changeSubscriptionRequest = Restangular.one('user/plan'); | ||||
|     changeSubscriptionRequest.customPUT(subscriptionDetails).then(subscribedToPlan, function() { | ||||
|       // Failure
 | ||||
|  | @ -813,9 +821,11 @@ function UserAdminCtrl($scope, $timeout, Restangular, PlanService, UserService, | |||
|   // Show the subscribe dialog if a plan was requested.
 | ||||
|   var requested = $routeParams['plan'] | ||||
|   if (requested !== undefined && requested !== 'free') { | ||||
|     if (PlanService.getPlan(requested) !== undefined) { | ||||
|       $scope.subscribe(requested); | ||||
|     } | ||||
|     PlanService.getPlan(requested, function(found) { | ||||
|       if (found) { | ||||
|         $scope.subscribe(requested); | ||||
|       } | ||||
|     });       | ||||
|   } | ||||
| 
 | ||||
|   $scope.updatingUser = false; | ||||
|  | @ -1039,11 +1049,21 @@ function NewRepoCtrl($scope, $location, $http, UserService, Restangular, PlanSer | |||
|   var subscribedToPlan = function(sub) { | ||||
|     $scope.planChanging = false; | ||||
|     $scope.subscription = sub; | ||||
|     $scope.subscribedPlan = PlanService.getPlan(sub.plan); | ||||
|     $scope.planRequired = null; | ||||
|     if ($scope.subscription.usedPrivateRepos >= $scope.subscribedPlan.privateRepos) { | ||||
|       $scope.planRequired = PlanService.getMinimumPlan($scope.subscription.usedPrivateRepos); | ||||
|     } | ||||
| 
 | ||||
|     PlanService.getPlan(sub.plan, function(subscribedPlan) { | ||||
|       $scope.subscribedPlan = subscribedPlan; | ||||
|       $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() { | ||||
|  | @ -1109,6 +1129,7 @@ function NewRepoCtrl($scope, $location, $http, UserService, Restangular, PlanSer | |||
|       $scope.planChanging = true; | ||||
|     }, function(plan) { | ||||
|       // Subscribed.
 | ||||
|       UserService.resetCurrentSubscription(); | ||||
|       subscribedToPlan(plan); | ||||
|     }, function() { | ||||
|       // Failure.
 | ||||
|  | @ -1116,15 +1137,37 @@ function NewRepoCtrl($scope, $location, $http, UserService, Restangular, PlanSer | |||
|       $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
 | ||||
|   // repository.
 | ||||
|   var getSubscription = Restangular.one('user/plan'); | ||||
|   getSubscription.get().then(subscribedToPlan, function() { | ||||
|     // User has no subscription
 | ||||
|     $scope.planRequired = PlanService.getMinimumPlan(1); | ||||
|     if (isUserNamespace) { | ||||
|       // Load the user's subscription information in case they want to create a private
 | ||||
|       // repository.
 | ||||
|       UserService.getCurrentSubscription(subscribedToPlan, function() { | ||||
|         PlanService.getMinimumPlan(1, function(minimum) { $scope.planRequired = minimum; }); | ||||
|       }); | ||||
|     } 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'; | ||||
|     } | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -72,13 +72,19 @@ | |||
|         </div> | ||||
| 
 | ||||
|         <!-- 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"> | ||||
|           In order to make this repository private, you’ll 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> | ||||
|           <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> | ||||
|         </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> | ||||
|  |  | |||
		Reference in a new issue