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

View File

@ -955,9 +955,11 @@ class OrganizationRhSku(ApiResource):
if query:
subscriptions = list(query.dicts())
for subscription in subscriptions:
subscription["sku"] = marketplace_subscriptions.get_subscription_sku(
subscription_sku = marketplace_subscriptions.get_subscription_sku(
subscription["subscription_id"]
)
subscription["sku"] = subscription_sku
subscription["metadata"] = get_plan_using_rh_sku(subscription_sku)
return subscriptions
else:
return []
@ -971,35 +973,71 @@ class OrganizationRhSku(ApiResource):
"""
permission = AdministerOrganizationPermission(orgname)
request_data = request.get_json()
subscription_id = request_data["subscription_id"]
organization = model.organization.get_organization(orgname)
subscriptions = request_data["subscriptions"]
if permission.can():
organization = model.organization.get_organization(orgname)
user = get_authenticated_user()
account_number = marketplace_users.get_account_number(user)
subscriptions = marketplace_subscriptions.get_list_of_subscriptions(account_number)
for subscription in subscriptions:
subscription_id = subscription.get("subscription_id")
if subscription_id is None:
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:
abort(401, message="no valid subscriptions present")
if subscriptions is None:
abort(401, message="no valid subscriptions present")
user_subscription_ids = [int(subscription["id"]) for subscription in subscriptions]
if int(subscription_id) in user_subscription_ids:
try:
model.organization_skus.bind_subscription_to_org(
user_id=user.id, subscription_id=subscription_id, org_id=organization.id
user_subscription_ids = [int(subscription["id"]) for subscription in subscriptions]
if int(subscription_id) in user_subscription_ids:
try:
model.organization_skus.bind_subscription_to_org(
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)
@resource("/v1/organization/<orgname>/marketplace/<subscription_id>")
@path_param("orgname", "The name of the organization")
@path_param("subscription_id", "Marketplace subscription id")
@related_user_resource(UserPlan)
@show_if(features.BILLING)
class OrganizationRhSkuSubscriptionField(ApiResource):
"""
@ -1007,6 +1045,7 @@ class OrganizationRhSkuSubscriptionField(ApiResource):
"""
@require_scope(scopes.ORG_ADMIN)
@nickname("removeSkuFromOrg")
def delete(self, orgname, subscription_id):
"""
Remove sku from an org
@ -1054,4 +1093,6 @@ class UserSkuList(ApiResource):
else:
subscription["assigned_to_org"] = None
subscription["metadata"] = get_plan_using_rh_sku(subscription["sku"])
return user_subscriptions

View File

@ -6047,7 +6047,7 @@ SECURITY_TESTS: List[
OrganizationRhSku,
"POST",
{"orgname": "buynlarge"},
{"subscription_id": 12345},
{"subscriptions": [{"subscription_id": 12345}]},
None,
401,
),
@ -6059,6 +6059,14 @@ SECURITY_TESTS: List[
None,
401,
),
(
OrganizationRhSkuBatchRemoval,
"POST",
{"orgname": "buynlarge"},
{"subscriptions": [{"subscription_id": 12345}]},
None,
401,
),
(OrgAutoPrunePolicies, "GET", {"orgname": "buynlarge"}, None, None, 401),
(OrgAutoPrunePolicies, "GET", {"orgname": "buynlarge"}, None, "devtable", 200),
(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>
<td>Current Plan:</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.
</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.
</div>
<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>
</tr>
<tr ng-show="currentCard">

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>
<!-- Chart -->
<div class="usage-chart" total="subscribedPlan.privateRepos || 0"
<div class="usage-chart"
current="subscription.usedPrivateRepos || 0"
limit="limit"
total="subscribedPlan.privateRepos || 0"
marketplace-total="marketplaceTotal"
usage-title="Repository Usage"
ng-show="!planLoading"></div>
<!-- Org Binding -->
<div class="org-binding"
ng-show="!planLoading"
organization="organization"
marketplace-total="marketplaceTotal"></div>
<hr></hr>
<!-- Plans Table -->
<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">
<thead>
<td>Plan</td>

View File

@ -21,6 +21,7 @@ angular.module('quay').directive('billingManagementPanel', function () {
$scope.changeReceiptsInfo = null;
$scope.context = {};
$scope.subscriptionStatus = 'loading';
$scope.currentMarketplace = 0;
var setSubscription = function(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() {
if (!$scope.isEnabled || !($scope.user || $scope.organization) || !Features.BILLING) {
return;
@ -59,6 +82,10 @@ angular.module('quay').directive('billingManagementPanel', function () {
PlanService.getSubscription($scope.organization, setSubscription, function() {
setSubscription({ 'plan': PlanService.getFreePlan() });
});
if (Features.RH_MARKETPLACE) {
getMarketplace();
}
};
// Listen to plan changes.

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) {
$scope.isExistingCustomer = false;
$scope.marketplaceTotal = 0;
$scope.parseDate = function(timestamp) {
return new Date(timestamp * 1000);
};

View File

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

View File

@ -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;
}]);

View File

@ -37,9 +37,11 @@ from endpoints.api.billing import (
OrganizationCard,
OrganizationPlan,
OrganizationRhSku,
OrganizationRhSkuBatchRemoval,
OrganizationRhSkuSubscriptionField,
UserCard,
UserPlan,
UserSkuList,
)
from endpoints.api.build import (
RepositoryBuildList,
@ -5076,7 +5078,7 @@ class TestOrganizationRhSku(ApiTestCase):
self.postResponse(
resource_name=OrganizationRhSku,
params=dict(orgname=SUBSCRIPTION_ORG),
data={"subscription_id": 12345678},
data={"subscriptions": [{"subscription_id": 12345678}]},
expected_code=201,
)
json = self.getJsonResponse(
@ -5093,7 +5095,7 @@ class TestOrganizationRhSku(ApiTestCase):
self.postResponse(
resource_name=OrganizationRhSku,
params=dict(orgname=SUBSCRIPTION_ORG),
data={"subscription_id": 12345678},
data={"subscriptions": [{"subscription_id": 12345678}]},
expected_code=400,
)
@ -5103,7 +5105,7 @@ class TestOrganizationRhSku(ApiTestCase):
self.postResponse(
resource_name=OrganizationRhSku,
params=dict(orgname=SUBSCRIPTION_ORG),
data={"subscription_id": 11111111},
data={"subscriptions": [{"subscription_id": 11111}]},
expected_code=401,
)
@ -5112,7 +5114,7 @@ class TestOrganizationRhSku(ApiTestCase):
self.postResponse(
resource_name=OrganizationRhSku,
params=dict(orgname=SUBSCRIPTION_ORG),
data={"subscription_id": 12345678},
data={"subscriptions": [{"subscription_id": 12345678}]},
expected_code=201,
)
self.deleteResponse(
@ -5126,6 +5128,49 @@ class TestOrganizationRhSku(ApiTestCase):
)
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__":
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:
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
mock.assert_called_with(model.entitlements.get_web_customer_id(test_user.id), "FakeSKU")