diff --git a/data/plans.py b/data/plans.py new file mode 100644 index 000000000..95e1ca731 --- /dev/null +++ b/data/plans.py @@ -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 diff --git a/endpoints/api.py b/endpoints/api.py index 53099d030..cf51418bc 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -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,18 +219,45 @@ def get_organization(orgname): return jsonify(org_view(organization, teams)) +@app.route('/api/organization//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(): owner = current_user.db_user() - namespace_name = owner.username - repository_name = request.get_json()['repository'] - visibility = request.get_json()['visibility'] + # TODO(jake): Verify that the user can create a repo in this namespace. + json = request.get_json() + namespace_name = json['namespace'] if 'namespace' in json else owner.username + repository_name = json['repository'] + visibility = json['visibility'] repo = model.create_repository(namespace_name, repository_name, owner, visibility) - repo.description = request.get_json()['description'] + repo.description = json['description'] repo.save() return jsonify({ @@ -784,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({ diff --git a/static/css/quay.css b/static/css/quay.css index 9f5001fc4..54f6a431c 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -2,6 +2,13 @@ font-family: 'Droid Sans', sans-serif; } +.namespace-selector-dropdown .namespace { + padding: 6px; + padding-left: 10px; + cursor: pointer; + font-size: 14px; +} + .user-notification { background: red; } diff --git a/static/directives/namespace-selector.html b/static/directives/namespace-selector.html new file mode 100644 index 000000000..de208f7c7 --- /dev/null +++ b/static/directives/namespace-selector.html @@ -0,0 +1,25 @@ + + {{user.username}} + +
+ + +
+
diff --git a/static/js/app.js b/static/js/app.js index 8510fcc40..f72ac7b24 100644 --- a/static/js/app.js +++ b/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 + }); }); }; @@ -231,6 +233,36 @@ quayApp.directive('repoCircle', function () { }); +quayApp.directive('namespaceSelector', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/namespace-selector.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'user': '=user', + 'namespace': '=namespace' + }, + controller: function($scope, $element) { + $scope.setNamespace = function(namespaceObj) { + if (!namespaceObj) { + namespaceObj = {'name': '', 'gravatar': ''}; + } + $scope.namespaceObj = namespaceObj; + $scope.namespace = namespaceObj.name || namespaceObj.username; + }; + $scope.setNamespace($scope.user); + + $scope.$watch('user', function(user) { + $scope.setNamespace(user); + }); + } + }; + return directiveDefinitionObject; +}); + + quayApp.directive('buildStatus', function () { var directiveDefinitionObject = { priority: 0, diff --git a/static/js/controllers.js b/static/js/controllers.js index 365c1dbca..7d0dbb6ff 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -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() { @@ -1078,6 +1098,7 @@ function NewRepoCtrl($scope, $location, $http, UserService, Restangular, PlanSer $scope.creating = true; var repo = $scope.repo; var data = { + 'namespace': repo.namespace, 'repository': repo.name, 'visibility': repo.is_public == '1' ? 'public' : 'private', 'description': repo.description @@ -1108,6 +1129,7 @@ function NewRepoCtrl($scope, $location, $http, UserService, Restangular, PlanSer $scope.planChanging = true; }, function(plan) { // Subscribed. + UserService.resetCurrentSubscription(); subscribedToPlan(plan); }, function() { // Failure. @@ -1115,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'; + } }); } diff --git a/static/partials/new-repo.html b/static/partials/new-repo.html index 30cdd2316..96a122ce3 100644 --- a/static/partials/new-repo.html +++ b/static/partials/new-repo.html @@ -28,8 +28,12 @@
- - {{user.username}} / + + + / + + +
@@ -68,13 +72,19 @@
-
+
In order to make this repository private, you’ll need to upgrade your plan from {{ subscribedPlan.title }} to {{ planRequired.title }}. This will cost ${{ planRequired.price / 100 }}/month.
Upgrade now
+ +
+
+ This organization has reached its private repository limit. Please contact your administrator. +
+