1
0
mirror of https://github.com/quay/quay.git synced 2025-07-28 20:22:05 +03:00

billing: marketplace UI (PROJQUAY-6551) (#2595)

* billing: marketplace UI

adds UI in billing section for managing user and org-bound skus

add more unit tests for org binding

changed endpoint for bulk attaching skus to orgs
This commit is contained in:
Marcus Kok
2024-01-11 11:48:38 -05:00
committed by GitHub
parent 27cceb1bb4
commit 2a4ac09306
14 changed files with 455 additions and 38 deletions

View File

@ -1,5 +1,6 @@
import random import random
import string import string
import sys
from calendar import timegm from calendar import timegm
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Dict from typing import Any, Dict
@ -292,6 +293,7 @@ PLANS = [
"price": 45000, "price": 45000,
"privateRepos": 250, "privateRepos": 250,
"rh_sku": "MW00589MO", "rh_sku": "MW00589MO",
"billing_enabled": False,
"stripeId": "bus-xlarge-2018", "stripeId": "bus-xlarge-2018",
"audience": "For extra large businesses", "audience": "For extra large businesses",
"bus_features": True, "bus_features": True,
@ -305,6 +307,7 @@ PLANS = [
"price": 85000, "price": 85000,
"privateRepos": 500, "privateRepos": 500,
"rh_sku": "MW00590MO", "rh_sku": "MW00590MO",
"billing_enabled": False,
"stripeId": "bus-500-2018", "stripeId": "bus-500-2018",
"audience": "For huge business", "audience": "For huge business",
"bus_features": True, "bus_features": True,
@ -354,13 +357,21 @@ PLANS = [
"plans_page_hidden": False, "plans_page_hidden": False,
}, },
{ {
"title": "subscriptionwatch", "title": "premium",
"privateRepos": 100, "privateRepos": 100,
"stripeId": "not_a_stripe_plan", "stripeId": "not_a_stripe_plan",
"rh_sku": "MW02701", "rh_sku": "MW02701",
"sku_billing": True, "sku_billing": True,
"plans_page_hidden": True, "plans_page_hidden": True,
}, },
{
"title": "selfsupport",
"privateRepos": sys.maxsize,
"stripeId": "not_a_stripe_plan",
"rh_sku": "MW02702",
"sku_billing": True,
"plans_page_hidden": True,
},
] ]
RH_SKUS = [ RH_SKUS = [
@ -389,6 +400,8 @@ def get_plan_using_rh_sku(sku):
""" """
Returns the plan with given sku or None if none. Returns the plan with given sku or None if none.
""" """
if sku is None:
return None
for plan in PLANS: for plan in PLANS:
if plan.get("rh_sku") == sku: if plan.get("rh_sku") == sku:
return plan return plan

View File

@ -955,9 +955,11 @@ class OrganizationRhSku(ApiResource):
if query: if query:
subscriptions = list(query.dicts()) subscriptions = list(query.dicts())
for subscription in subscriptions: for subscription in subscriptions:
subscription["sku"] = marketplace_subscriptions.get_subscription_sku( subscription_sku = marketplace_subscriptions.get_subscription_sku(
subscription["subscription_id"] subscription["subscription_id"]
) )
subscription["sku"] = subscription_sku
subscription["metadata"] = get_plan_using_rh_sku(subscription_sku)
return subscriptions return subscriptions
else: else:
return [] return []
@ -971,35 +973,71 @@ class OrganizationRhSku(ApiResource):
""" """
permission = AdministerOrganizationPermission(orgname) permission = AdministerOrganizationPermission(orgname)
request_data = request.get_json() request_data = request.get_json()
subscription_id = request_data["subscription_id"] organization = model.organization.get_organization(orgname)
subscriptions = request_data["subscriptions"]
if permission.can(): if permission.can():
organization = model.organization.get_organization(orgname) for subscription in subscriptions:
user = get_authenticated_user() subscription_id = subscription.get("subscription_id")
account_number = marketplace_users.get_account_number(user) if subscription_id is None:
subscriptions = marketplace_subscriptions.get_list_of_subscriptions(account_number) break
user = get_authenticated_user()
account_number = marketplace_users.get_account_number(user)
subscriptions = marketplace_subscriptions.get_list_of_subscriptions(account_number)
if subscriptions is None: if subscriptions is None:
abort(401, message="no valid subscriptions present") abort(401, message="no valid subscriptions present")
user_subscription_ids = [int(subscription["id"]) for subscription in subscriptions] user_subscription_ids = [int(subscription["id"]) for subscription in subscriptions]
if int(subscription_id) in user_subscription_ids: if int(subscription_id) in user_subscription_ids:
try: try:
model.organization_skus.bind_subscription_to_org( model.organization_skus.bind_subscription_to_org(
user_id=user.id, subscription_id=subscription_id, org_id=organization.id user_id=user.id, subscription_id=subscription_id, org_id=organization.id
)
except model.OrgSubscriptionBindingAlreadyExists:
abort(400, message="subscription is already bound to an org")
else:
abort(
401,
message=f"subscription {subscription_id} does not belong to {user.username}",
) )
return "Okay", 201
except model.OrgSubscriptionBindingAlreadyExists:
abort(400, message="subscription is already bound to an org")
else:
abort(401, message=f"subscription does not belong to {user.username}")
return "Okay", 201
abort(401)
@resource("/v1/organization/<orgname>/marketplace/batchremove")
@path_param("orgname", "The name of the organization")
@show_if(features.BILLING)
class OrganizationRhSkuBatchRemoval(ApiResource):
@require_scope(scopes.ORG_ADMIN)
@nickname("batchRemoveSku")
def post(self, orgname):
"""
Batch remove skus from org
"""
permission = AdministerOrganizationPermission(orgname)
request_data = request.get_json()
subscriptions = request_data["subscriptions"]
if permission.can():
try:
organization = model.organization.get_organization(orgname)
except InvalidOrganizationException:
return ("Organization not valid", 400)
for subscription in subscriptions:
subscription_id = int(subscription.get("subscription_id"))
if subscription_id is None:
break
model.organization_skus.remove_subscription_from_org(
organization.id, subscription_id
)
return ("Deleted", 204)
abort(401) abort(401)
@resource("/v1/organization/<orgname>/marketplace/<subscription_id>") @resource("/v1/organization/<orgname>/marketplace/<subscription_id>")
@path_param("orgname", "The name of the organization") @path_param("orgname", "The name of the organization")
@path_param("subscription_id", "Marketplace subscription id") @path_param("subscription_id", "Marketplace subscription id")
@related_user_resource(UserPlan)
@show_if(features.BILLING) @show_if(features.BILLING)
class OrganizationRhSkuSubscriptionField(ApiResource): class OrganizationRhSkuSubscriptionField(ApiResource):
""" """
@ -1007,6 +1045,7 @@ class OrganizationRhSkuSubscriptionField(ApiResource):
""" """
@require_scope(scopes.ORG_ADMIN) @require_scope(scopes.ORG_ADMIN)
@nickname("removeSkuFromOrg")
def delete(self, orgname, subscription_id): def delete(self, orgname, subscription_id):
""" """
Remove sku from an org Remove sku from an org
@ -1054,4 +1093,6 @@ class UserSkuList(ApiResource):
else: else:
subscription["assigned_to_org"] = None subscription["assigned_to_org"] = None
subscription["metadata"] = get_plan_using_rh_sku(subscription["sku"])
return user_subscriptions return user_subscriptions

View File

@ -6047,7 +6047,7 @@ SECURITY_TESTS: List[
OrganizationRhSku, OrganizationRhSku,
"POST", "POST",
{"orgname": "buynlarge"}, {"orgname": "buynlarge"},
{"subscription_id": 12345}, {"subscriptions": [{"subscription_id": 12345}]},
None, None,
401, 401,
), ),
@ -6059,6 +6059,14 @@ SECURITY_TESTS: List[
None, None,
401, 401,
), ),
(
OrganizationRhSkuBatchRemoval,
"POST",
{"orgname": "buynlarge"},
{"subscriptions": [{"subscription_id": 12345}]},
None,
401,
),
(OrgAutoPrunePolicies, "GET", {"orgname": "buynlarge"}, None, None, 401), (OrgAutoPrunePolicies, "GET", {"orgname": "buynlarge"}, None, None, 401),
(OrgAutoPrunePolicies, "GET", {"orgname": "buynlarge"}, None, "devtable", 200), (OrgAutoPrunePolicies, "GET", {"orgname": "buynlarge"}, None, "devtable", 200),
(OrgAutoPrunePolicies, "GET", {"orgname": "unknown"}, None, "devtable", 403), (OrgAutoPrunePolicies, "GET", {"orgname": "unknown"}, None, "devtable", 403),

View File

@ -0,0 +1,11 @@
.org-binding-settings-element .btn {
margin-top: 10px;
}
.org-binding-settings-element .form-control {
margin-top: 10px;
}
.org-binding-settings-element td.add-remove-section {
margin: 10px;
}

View File

@ -5,16 +5,16 @@
<tr> <tr>
<td>Current Plan:</td> <td>Current Plan:</td>
<td> <td>
<div class="sub-usage" ng-if="subscription.usedPrivateRepos > currentPlan.privateRepos"> <div class="sub-usage" ng-if="subscription.usedPrivateRepos > (currentPlan.privateRepos + currentMarketplace)">
<i class="fa fa-exclamation-triangle red"></i> <strong>{{ subscription.usedPrivateRepos }}</strong> private repositories exceeds the amount allowed by your plan. Upgrade your plan to avoid service disruptions. <i class="fa fa-exclamation-triangle red"></i> <strong>{{ subscription.usedPrivateRepos }}</strong> private repositories exceeds the amount allowed by your plan. Upgrade your plan to avoid service disruptions.
</div> </div>
<div class="sub-usage" ng-if="subscription.usedPrivateRepos == currentPlan.privateRepos"> <div class="sub-usage" ng-if="subscription.usedPrivateRepos == (currentPlan.privateRepos + currentMarketplace)">
<i class="fa fa-exclamation-triangle yellow"></i> <strong>{{ subscription.usedPrivateRepos }}</strong> private repositories is the maximum allowed by your plan. Upgrade your plan to create more private repositories. <i class="fa fa-exclamation-triangle yellow"></i> <strong>{{ subscription.usedPrivateRepos }}</strong> private repositories is the maximum allowed by your plan. Upgrade your plan to create more private repositories.
</div> </div>
<a class="co-modify-link" ng-href="{{ getEntityPrefix() }}/billing">{{ currentPlan.privateRepos }} private repositories</a> <a class="co-modify-link" ng-href="{{ getEntityPrefix() }}/billing">{{ currentPlan.privateRepos }} private repositories</a>
<div class="help-text">Up to {{ currentPlan.privateRepos }} private repositories, unlimited public repositories</div> <div class="help-text">Up to {{ currentPlan.privateRepos + currentMarketplace}} private repositories, unlimited public repositories</div>
</td> </td>
</tr> </tr>
<tr ng-show="currentCard"> <tr ng-show="currentCard">
@ -77,4 +77,4 @@
</form> </form>
</div> </div>
</div> </div>

View File

@ -0,0 +1,53 @@
<div class="org-binding-settings-element" >
<span><h3>Monthly Subscriptions From Red Hat Customer Portal</h3></span>
<div class="cor-loader-inline" ng-show="marketplaceLoading"></div>
<span ng-show="!organization && !marketplaceLoading">
<div ng-repeat="(sku, subscriptions) in userMarketplaceSubscriptions">
{{subscriptions.length}}x {{ sku }}
</div>
</span>
<table ng-show="organization && !marketplaceLoading">
<tr class="indented-row" ng-repeat="(sku, subscriptions) in orgMarketplaceSubscriptions">
<td>
{{ subscriptions.length }} x {{ sku }} attached to this org
</td>
</tr>
<tr class="indented-row">
<td style="padding: 10px">
<select class="form-control" ng-model="subscriptionBinding">
<option ng-repeat="(sku, subscriptions) in availableSubscriptions" value="{{ subscriptions }}">
{{subscriptions.length}} x {{sku}}
</option>
</select>
<input class="form-control" type="number" min="1" max="{{subscriptions.length}}" ng-model="numSubscriptions" placeholder="Number of subscriptions">
<a class="btn btn-primary" ng-click="bindSku(subscriptionBinding, numSubscriptions)">Attach subscriptions</a>
</td>
<td style="padding: 10px">
<select class="form-control" ng-model="subscriptionRemovals">
<option ng-repeat="(sku, orgSubscriptions) in orgMarketplaceSubscriptions" value="{{orgSubscriptions}}">
{{sku}}
</option>
</select>
<input class="form-control"
type="number"
min="1"
max="{{JSON.parse(subscriptions).length}}"
ng-model="numRemovals"
placeholder="Number of subscriptions"
>
<a class="btn btn-default" ng-click="batchRemoveSku(subscriptionRemovals, numRemovals)">
Remove subscriptions
</a>
</td>
</tr>
<div class="co-alert co-alert-success" ng-show="bindOrgSuccess">
Successfully bound subscription to org
</div>
<div class="co-alert co-alert-success" ng-show="removeSkuSuccess">
Successfully removed subscription from org
</div>
<tr>
</tr>
</table>
</div>

View File

@ -41,15 +41,26 @@
</div> </div>
<!-- Chart --> <!-- Chart -->
<div class="usage-chart" total="subscribedPlan.privateRepos || 0" <div class="usage-chart"
current="subscription.usedPrivateRepos || 0" current="subscription.usedPrivateRepos || 0"
limit="limit" limit="limit"
total="subscribedPlan.privateRepos || 0"
marketplace-total="marketplaceTotal"
usage-title="Repository Usage" usage-title="Repository Usage"
ng-show="!planLoading"></div> ng-show="!planLoading"></div>
<!-- Org Binding -->
<div class="org-binding"
ng-show="!planLoading"
organization="organization"
marketplace-total="marketplaceTotal"></div>
<hr></hr>
<!-- Plans Table --> <!-- Plans Table -->
<div class="visible-xs" style="margin-top: 10px"></div> <div class="visible-xs" style="margin-top: 10px"></div>
<h3>Monthly Subscriptions Purchased via Stripe</h3>
<table class="table table-hover plans-list-table" ng-show="!planLoading"> <table class="table table-hover plans-list-table" ng-show="!planLoading">
<thead> <thead>
<td>Plan</td> <td>Plan</td>

View File

@ -21,6 +21,7 @@ angular.module('quay').directive('billingManagementPanel', function () {
$scope.changeReceiptsInfo = null; $scope.changeReceiptsInfo = null;
$scope.context = {}; $scope.context = {};
$scope.subscriptionStatus = 'loading'; $scope.subscriptionStatus = 'loading';
$scope.currentMarketplace = 0;
var setSubscription = function(sub) { var setSubscription = function(sub) {
$scope.subscription = sub; $scope.subscription = sub;
@ -44,6 +45,28 @@ angular.module('quay').directive('billingManagementPanel', function () {
}); });
}; };
var getMarketplace = function() {
var total = 0;
if ($scope.organization) {
PlanService.listOrgMarketplaceSubscriptions($scope.organization.name, function(subscriptions){
for (var i = 0; i < subscriptions.length; i++) {
total += subscriptions[i]["metadata"]["privateRepos"];
}
$scope.currentMarketplace = total;
})
} else {
PlanService.listUserMarketplaceSubscriptions(function(subscriptions){
for (var i = 0; i < subscriptions.length; i++) {
if(subscriptions[i]["assigned_to_org"] === null) {
total += subscriptions[i]["metadata"]["privateRepos"];
}
}
$scope.currentMarketplace = total;
})
}
}
var update = function() { var update = function() {
if (!$scope.isEnabled || !($scope.user || $scope.organization) || !Features.BILLING) { if (!$scope.isEnabled || !($scope.user || $scope.organization) || !Features.BILLING) {
return; return;
@ -59,6 +82,10 @@ angular.module('quay').directive('billingManagementPanel', function () {
PlanService.getSubscription($scope.organization, setSubscription, function() { PlanService.getSubscription($scope.organization, setSubscription, function() {
setSubscription({ 'plan': PlanService.getFreePlan() }); setSubscription({ 'plan': PlanService.getFreePlan() });
}); });
if (Features.RH_MARKETPLACE) {
getMarketplace();
}
}; };
// Listen to plan changes. // Listen to plan changes.
@ -141,4 +168,4 @@ angular.module('quay').directive('billingManagementPanel', function () {
} }
}; };
return directiveDefinitionObject; return directiveDefinitionObject;
}); });

View File

@ -0,0 +1,155 @@
angular.module('quay').directive('orgBinding', function() {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/org-binding.html',
restrict: 'C',
scope: {
'marketplaceTotal': '=marketplaceTotal',
'organization': '=organization',
},
controller: function($scope, $timeout, PlanService, ApiService) {
$scope.userMarketplaceSubscriptions = {};
$scope.orgMarketplaceSubscriptions = {};
$scope.availableSubscriptions = {};
$scope.marketplaceLoading = true;
$scope.bindOrgSuccess = false;
$scope.removeSkuSuccess = false;
var groupSubscriptionsBySku = function(subscriptions) {
const grouped = {};
subscriptions.forEach(obj => {
const { sku, ...rest } = obj;
if(!grouped[sku]) {
grouped[sku] = [];
}
grouped[sku].push(rest);
});
return grouped;
}
var loadSubscriptions = function() {
var total = 0;
if ($scope.organization) {
PlanService.listOrgMarketplaceSubscriptions($scope.organization, function(marketplaceSubscriptions){
// group the list of subscriptions by their sku field
$scope.orgMarketplaceSubscriptions = groupSubscriptionsBySku(marketplaceSubscriptions);
for (var i = 0; i < marketplaceSubscriptions.length; i++) {
total += marketplaceSubscriptions[i]["metadata"]["privateRepos"];
}
$scope.marketplaceTotal = total;
});
}
PlanService.listUserMarketplaceSubscriptions(function(marketplaceSubscriptions){
if(!marketplaceSubscriptions) {
$scope.marketplaceLoading = false;
return;
}
let notBound = [];
$scope.userMarketplaceSubscriptions = groupSubscriptionsBySku(marketplaceSubscriptions);
for (var i = 0; i < marketplaceSubscriptions.length; i++) {
if (marketplaceSubscriptions[i]["assigned_to_org"] === null) {
if(!($scope.organization)){
total += marketplaceSubscriptions[i]["metadata"]["privateRepos"];
}
notBound.push(marketplaceSubscriptions[i]);
}
}
if(!($scope.organization)){
$scope.marketplaceTotal = total;
}
$scope.availableSubscriptions = groupSubscriptionsBySku(notBound);
$scope.marketplaceLoading = false;
});
}
var update = function() {
$scope.marketplaceLoading = true;
loadSubscriptions();
}
$scope.bindSku = function(subscriptions, numSubscriptions) {
let subscriptionArr = JSON.parse(subscriptions);
if(numSubscriptions > subscriptionArr.length){
displayError("number of subscriptions exceeds total amount");
return;
}
$scope.marketplaceLoading = true;
const requestData = {};
requestData["subscriptions"] = [];
for(var i = 0; i < numSubscriptions; ++i) {
var subscriptionObject = {};
var subscriptionId = subscriptionArr[i].id;
subscriptionObject.subscription_id = subscriptionId;
requestData["subscriptions"].push(subscriptionObject);
}
PlanService.bindSkuToOrg(requestData, $scope.organization, function(resp){
if (resp === "Okay"){
bindSkuSuccessMessage();
}
else {
displayError(resp.message);
}
});
};
$scope.batchRemoveSku = function(removals, numRemovals) {
let removalArr = JSON.parse(removals);
const requestData = {};
requestData["subscriptions"] = [];
for(var i = 0; i < numRemovals; ++i){
var subscriptionObject = {};
var subscriptionId = removalArr[i].subscription_id;
subscriptionObject.subscription_id = subscriptionId;
requestData["subscriptions"].push(subscriptionObject);
}
PlanService.batchRemoveSku(requestData, $scope.organization, function(resp){
if (resp == "") {
removeSkuSuccessMessage();
}
else {
displayError(resp.message);
}
});
};
var displayError = function (message = "Could not update org") {
let errorDisplay = ApiService.errorDisplay(message, () => {
});
return errorDisplay;
}
var bindSkuSuccessMessage = function () {
$timeout(function () {
$scope.bindOrgSuccess = true;
}, 1);
$timeout(function () {
$scope.bindOrgSuccess = false;
}, 5000)
};
var removeSkuSuccessMessage = function () {
$timeout(function () {
$scope.removeSkuSuccess = true;
}, 1);
$timeout(function () {
$scope.removeSkuSuccess = false;
}, 5000)
};
loadSubscriptions();
$scope.$watch('bindOrgSuccess', function(){
if($scope.bindOrgSuccess === true) { update(); }
});
$scope.$watch('removeSkuSuccess', function(){
if($scope.removeSkuSuccess === true) { update(); }
});
}
};
return directiveDefinitionObject;
});

View File

@ -20,6 +20,8 @@ angular.module('quay').directive('planManager', function () {
controller: function($scope, $element, PlanService, ApiService) { controller: function($scope, $element, PlanService, ApiService) {
$scope.isExistingCustomer = false; $scope.isExistingCustomer = false;
$scope.marketplaceTotal = 0;
$scope.parseDate = function(timestamp) { $scope.parseDate = function(timestamp) {
return new Date(timestamp * 1000); return new Date(timestamp * 1000);
}; };

View File

@ -15,23 +15,29 @@ angular.module('quay').directive('usageChart', function () {
scope: { scope: {
'current': '=current', 'current': '=current',
'total': '=total', 'total': '=total',
'marketplaceTotal': '=marketplaceTotal',
'limit': '=limit', 'limit': '=limit',
'usageTitle': '@usageTitle' 'usageTitle': '@usageTitle'
}, },
controller: function($scope, $element) { controller: function($scope, $element) {
if($scope.subscribedPlan !== undefined){
$scope.total = $scope.subscribedPlan.privateRepos || 0;
}
else {
$scope.total = 0;
}
$scope.limit = ""; $scope.limit = "";
var chart = null; var chart = null;
var update = function() { var update = function() {
if ($scope.current == null || $scope.total == null) { return; }
if (!chart) { if (!chart) {
chart = new UsageChart(); chart = new UsageChart();
chart.draw('usage-chart-element'); chart.draw('usage-chart-element');
} }
var current = $scope.current || 0; var current = $scope.current || 0;
var total = $scope.total || 0; var total = $scope.total + $scope.marketplaceTotal;
if (current > total) { if (current > total) {
$scope.limit = 'over'; $scope.limit = 'over';
} else if (current == total) { } else if (current == total) {
@ -42,11 +48,13 @@ angular.module('quay').directive('usageChart', function () {
$scope.limit = 'none'; $scope.limit = 'none';
} }
chart.update($scope.current, $scope.total); var finalAmount = $scope.total + $scope.marketplaceTotal;
chart.update($scope.current, finalAmount);
}; };
$scope.$watch('current', update); $scope.$watch('current', update);
$scope.$watch('total', update); $scope.$watch('total', update);
$scope.$watch('marketplaceTotal', update);
} }
}; };
return directiveDefinitionObject; return directiveDefinitionObject;

View File

@ -198,11 +198,11 @@ function(KeyService, UserService, CookieService, ApiService, Features, Config, $
planService.updateSubscription = function($scope, orgname, planId, success, failure) { planService.updateSubscription = function($scope, orgname, planId, success, failure) {
if (!Features.BILLING) { return; } if (!Features.BILLING) { return; }
var subscriptionDetails = { var subscriptionDetails = {
plan: planId, plan: planId,
}; };
planService.getPlan(planId, function(plan) { planService.getPlan(planId, function(plan) {
ApiService.updateSubscription(orgname, subscriptionDetails).then( ApiService.updateSubscription(orgname, subscriptionDetails).then(
function(resp) { function(resp) {
@ -214,7 +214,7 @@ function(KeyService, UserService, CookieService, ApiService, Features, Config, $
failure failure
); );
}); });
}; };
planService.getCardInfo = function(orgname, callback) { planService.getCardInfo = function(orgname, callback) {
if (!Features.BILLING) { return; } if (!Features.BILLING) { return; }
@ -297,7 +297,7 @@ function(KeyService, UserService, CookieService, ApiService, Features, Config, $
callbacks['success'](resp) callbacks['success'](resp)
document.location = resp.url; document.location = resp.url;
}, 250); }, 250);
}, },
function(resp) { function(resp) {
planService.handleCardError(resp); planService.handleCardError(resp);
@ -306,5 +306,46 @@ function(KeyService, UserService, CookieService, ApiService, Features, Config, $
); );
}; };
planService.listUserMarketplaceSubscriptions = function(callback) {
if (!Features.BILLING || !Features.RH_MARKETPLACE) { return; }
var errorHandler = function(resp) {
if (resp.status == 404) {
callback(null);
}
}
ApiService.getUserMarketplaceSubscriptions().then(callback, errorHandler);
};
planService.listOrgMarketplaceSubscriptions = function(orgname, callback) {
if (!Features.BILLING || !Features.RH_MARKETPLACE) { return; }
var params = {
'orgname': orgname
}
ApiService.listOrgSkus(null, params).then(function(resp) {
callback(resp);
});
}
planService.bindSkuToOrg = function(subscriptions, orgname, callback) {
if(!Features.BILLING || !Features.RH_MARKETPLACE) { return; }
var params = {
'orgname': orgname
};
ApiService.bindSkuToOrg(subscriptions, params).then(function(resp) {
callback(resp);
});
};
planService.batchRemoveSku = function(subscriptions, orgname, callback) {
if(!Features.BILLING || !Features.RH_MARKETPLACE) { return; }
var params = {
'orgname': orgname
};
ApiService.batchRemoveSku(subscriptions, params).then(callback);
};
return planService; return planService;
}]); }]);

View File

@ -37,9 +37,11 @@ from endpoints.api.billing import (
OrganizationCard, OrganizationCard,
OrganizationPlan, OrganizationPlan,
OrganizationRhSku, OrganizationRhSku,
OrganizationRhSkuBatchRemoval,
OrganizationRhSkuSubscriptionField, OrganizationRhSkuSubscriptionField,
UserCard, UserCard,
UserPlan, UserPlan,
UserSkuList,
) )
from endpoints.api.build import ( from endpoints.api.build import (
RepositoryBuildList, RepositoryBuildList,
@ -5076,7 +5078,7 @@ class TestOrganizationRhSku(ApiTestCase):
self.postResponse( self.postResponse(
resource_name=OrganizationRhSku, resource_name=OrganizationRhSku,
params=dict(orgname=SUBSCRIPTION_ORG), params=dict(orgname=SUBSCRIPTION_ORG),
data={"subscription_id": 12345678}, data={"subscriptions": [{"subscription_id": 12345678}]},
expected_code=201, expected_code=201,
) )
json = self.getJsonResponse( json = self.getJsonResponse(
@ -5093,7 +5095,7 @@ class TestOrganizationRhSku(ApiTestCase):
self.postResponse( self.postResponse(
resource_name=OrganizationRhSku, resource_name=OrganizationRhSku,
params=dict(orgname=SUBSCRIPTION_ORG), params=dict(orgname=SUBSCRIPTION_ORG),
data={"subscription_id": 12345678}, data={"subscriptions": [{"subscription_id": 12345678}]},
expected_code=400, expected_code=400,
) )
@ -5103,7 +5105,7 @@ class TestOrganizationRhSku(ApiTestCase):
self.postResponse( self.postResponse(
resource_name=OrganizationRhSku, resource_name=OrganizationRhSku,
params=dict(orgname=SUBSCRIPTION_ORG), params=dict(orgname=SUBSCRIPTION_ORG),
data={"subscription_id": 11111111}, data={"subscriptions": [{"subscription_id": 11111}]},
expected_code=401, expected_code=401,
) )
@ -5112,7 +5114,7 @@ class TestOrganizationRhSku(ApiTestCase):
self.postResponse( self.postResponse(
resource_name=OrganizationRhSku, resource_name=OrganizationRhSku,
params=dict(orgname=SUBSCRIPTION_ORG), params=dict(orgname=SUBSCRIPTION_ORG),
data={"subscription_id": 12345678}, data={"subscriptions": [{"subscription_id": 12345678}]},
expected_code=201, expected_code=201,
) )
self.deleteResponse( self.deleteResponse(
@ -5126,6 +5128,49 @@ class TestOrganizationRhSku(ApiTestCase):
) )
self.assertEqual(len(json), 0) self.assertEqual(len(json), 0)
def test_sku_stacking(self):
# multiples of same sku
self.login(SUBSCRIPTION_USER)
self.postResponse(
resource_name=OrganizationRhSku,
params=dict(orgname=SUBSCRIPTION_ORG),
data={"subscriptions": [{"subscription_id": 12345678}, {"subscription_id": 11223344}]},
expected_code=201,
)
json = self.getJsonResponse(
resource_name=OrganizationRhSku,
params=dict(orgname=SUBSCRIPTION_ORG),
)
self.assertEqual(len(json), 2)
json = self.getJsonResponse(OrgPrivateRepositories, params=dict(orgname=SUBSCRIPTION_ORG))
self.assertEqual(True, json["privateAllowed"])
def test_batch_sku_remove(self):
self.login(SUBSCRIPTION_USER)
self.postResponse(
resource_name=OrganizationRhSku,
params=dict(orgname=SUBSCRIPTION_ORG),
data={"subscriptions": [{"subscription_id": 12345678}, {"subscription_id": 11223344}]},
expected_code=201,
)
self.postResponse(
resource_name=OrganizationRhSkuBatchRemoval,
params=dict(orgname=SUBSCRIPTION_ORG),
data={"subscriptions": [{"subscription_id": 12345678}, {"subscription_id": 11223344}]},
expected_code=204,
)
json = self.getJsonResponse(
resource_name=OrganizationRhSku, params=dict(orgname=SUBSCRIPTION_ORG)
)
self.assertEqual(len(json), 0)
class TestUserSku(ApiTestCase):
def test_get_user_skus(self):
self.login(SUBSCRIPTION_USER)
json = self.getJsonResponse(UserSkuList)
self.assertEqual(len(json), 2)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -39,6 +39,8 @@ def test_create_for_stripe_user(initialized_db):
with patch.object(marketplace_subscriptions, "create_entitlement") as mock: with patch.object(marketplace_subscriptions, "create_entitlement") as mock:
worker._perform_reconciliation(marketplace_users, marketplace_subscriptions) worker._perform_reconciliation(marketplace_users, marketplace_subscriptions)
# expect that entitlment is created with account number
mock.assert_called_with(11111, "FakeSKU")
# expect that entitlment is created with customer id number # expect that entitlment is created with customer id number
mock.assert_called_with(model.entitlements.get_web_customer_id(test_user.id), "FakeSKU") mock.assert_called_with(model.entitlements.get_web_customer_id(test_user.id), "FakeSKU")