1
0
mirror of https://github.com/quay/quay.git synced 2025-07-30 07:43:13 +03:00

marketplace: add support for quantity from subscriptions api (PROJQUAY-6551) (#2633)

* Adds handling for when a subscription returned from the subscription watch api has a quantity greater than 1. Number of private repos should be correctly calculated using the quantity.

* Updates ui so that subscriptions can only be added to an org as a group, i.e. a subscription with quantity = 2 cannot be split across organizations.
This commit is contained in:
Marcus Kok
2024-01-29 15:21:30 -05:00
committed by GitHub
parent 6d5e6293e3
commit 2ab7dc29f4
12 changed files with 116 additions and 62 deletions

View File

@ -2001,6 +2001,7 @@ class OrganizationRhSkus(BaseModel):
subscription_id = IntegerField(index=True, unique=True) subscription_id = IntegerField(index=True, unique=True)
user_id = ForeignKeyField(User, backref="org_bound_subscription") user_id = ForeignKeyField(User, backref="org_bound_subscription")
org_id = ForeignKeyField(User, backref="subscription") org_id = ForeignKeyField(User, backref="subscription")
quantity = IntegerField(index=True, null=True)
indexes = ( indexes = (
(("subscription_id", "org_id"), True), (("subscription_id", "org_id"), True),

View File

@ -0,0 +1,21 @@
"""add quantity field to orgRhSkus
Revision ID: 41d15c93c299
Revises: 3f8e3657bb67
Create Date: 2024-01-24 11:19:19.095256
"""
# revision identifiers, used by Alembic.
revision = "41d15c93c299"
down_revision = "3f8e3657bb67"
import sqlalchemy as sa
def upgrade(op, tables, tester):
op.add_column("organizationrhskus", sa.Column("quantity", sa.Integer(), nullable=True))
def downgrade(op, tables, tester):
op.drop_column("organizationrhskus", "quantity")

View File

@ -3,7 +3,7 @@ import logging
import peewee import peewee
from data import model from data import model
from data.database import OrganizationRhSkus from data.database import OrganizationRhSkus, db_transaction
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -16,10 +16,10 @@ def get_org_subscriptions(org_id):
return None return None
def bind_subscription_to_org(subscription_id, org_id, user_id): def bind_subscription_to_org(subscription_id, org_id, user_id, quantity=1):
try: try:
return OrganizationRhSkus.create( return OrganizationRhSkus.create(
subscription_id=subscription_id, org_id=org_id, user_id=user_id subscription_id=subscription_id, org_id=org_id, user_id=user_id, quantity=quantity
) )
except model.DataModelException as ex: except model.DataModelException as ex:
logger.error("Problem binding subscription to org %s: %s", org_id, ex) logger.error("Problem binding subscription to org %s: %s", org_id, ex)

View File

@ -53,9 +53,11 @@ def check_internal_api_for_subscription(namespace_user):
query = organization_skus.get_org_subscriptions(namespace_user.id) query = organization_skus.get_org_subscriptions(namespace_user.id)
org_subscriptions = list(query.dicts()) if query is not None else [] org_subscriptions = list(query.dicts()) if query is not None else []
for subscription in org_subscriptions: for subscription in org_subscriptions:
quantity = 1 if subscription.get("quantity") is None else subscription["quantity"]
subscription_id = subscription["subscription_id"] subscription_id = subscription["subscription_id"]
sku = marketplace_subscriptions.get_subscription_sku(subscription_id) sku = marketplace_subscriptions.get_subscription_sku(subscription_id)
plans.append(get_plan_using_rh_sku(sku)) for x in range(quantity):
plans.append(get_plan_using_rh_sku(sku))
pass pass
else: else:
user_account_number = marketplace_users.get_account_number(namespace_user) user_account_number = marketplace_users.get_account_number(namespace_user)
@ -960,6 +962,8 @@ class OrganizationRhSku(ApiResource):
) )
subscription["sku"] = subscription_sku subscription["sku"] = subscription_sku
subscription["metadata"] = get_plan_using_rh_sku(subscription_sku) subscription["metadata"] = get_plan_using_rh_sku(subscription_sku)
if subscription.get("quantity") is None:
subscription["quantity"] = 1
return subscriptions return subscriptions
else: else:
return [] return []
@ -989,9 +993,17 @@ class OrganizationRhSku(ApiResource):
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:
quantity = 1
for subscription in subscriptions:
if subscription["id"] == subscription_id:
quantity = subscription["quantity"]
break
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,
quantity=quantity,
) )
except model.OrgSubscriptionBindingAlreadyExists: except model.OrgSubscriptionBindingAlreadyExists:
abort(400, message="subscription is already bound to an org") abort(400, message="subscription is already bound to an org")

View File

@ -384,7 +384,11 @@ class OrgPrivateRepositories(ApiResource):
) )
equivalent_stripe_plan = get_plan_using_rh_sku(subscription_sku) equivalent_stripe_plan = get_plan_using_rh_sku(subscription_sku)
if equivalent_stripe_plan: if equivalent_stripe_plan:
repos_allowed += equivalent_stripe_plan["privateRepos"] if subscription.get("quantity") is None:
quantity = 1
else:
quantity = subscription["quantity"]
repos_allowed += quantity * equivalent_stripe_plan["privateRepos"]
data["privateAllowed"] = private_repos < repos_allowed data["privateAllowed"] = private_repos < repos_allowed

View File

@ -2,40 +2,33 @@
<span><h3>Monthly Subscriptions From Red Hat Customer Portal</h3></span> <span><h3>Monthly Subscriptions From Red Hat Customer Portal</h3></span>
<div class="cor-loader-inline" ng-show="marketplaceLoading"></div> <div class="cor-loader-inline" ng-show="marketplaceLoading"></div>
<span ng-show="!organization && !marketplaceLoading"> <span ng-show="!organization && !marketplaceLoading">
<div ng-repeat="(sku, subscriptions) in userMarketplaceSubscriptions"> <div ng-repeat="subscription in userMarketplaceSubscriptions">
{{subscriptions.length}}x {{ sku }} {{subscription.quantity}}x {{ subscription.sku }}
{{subscription.assigned_to_org ? "attached to org " + subscription.assigned_to_org : ""}}
</div> </div>
</span> </span>
<table ng-show="organization && !marketplaceLoading"> <table ng-show="organization && !marketplaceLoading">
<tr class="indented-row" ng-repeat="(sku, subscriptions) in orgMarketplaceSubscriptions"> <tr class="indented-row" ng-repeat="subscription in orgMarketplaceSubscriptions">
<td> <td>
{{ subscriptions.length }} x {{ sku }} attached to this org {{ subscription.quantity }}x {{ subscription.sku }} attached to this org
</td> </td>
</tr> </tr>
<tr class="indented-row"> <tr class="indented-row">
<td style="padding: 10px"> <td style="padding: 10px">
<select class="form-control" ng-model="subscriptionBinding"> <select class="form-control" ng-model="subscriptionBinding">
<option ng-repeat="(sku, subscriptions) in availableSubscriptions" value="{{ subscriptions }}"> <option ng-repeat="subscription in availableSubscriptions" value="{{ subscription }}">
{{subscriptions.length}} x {{sku}} {{subscription.quantity}}x {{subscription.sku}}
</option> </option>
</select> </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)">Attach subscriptions</a>
<a class="btn btn-primary" ng-click="bindSku(subscriptionBinding, numSubscriptions)">Attach subscriptions</a>
</td> </td>
<td style="padding: 10px"> <td style="padding: 10px">
<select class="form-control" ng-model="subscriptionRemovals"> <select class="form-control" ng-model="subscriptionRemovals">
<option ng-repeat="(sku, orgSubscriptions) in orgMarketplaceSubscriptions" value="{{orgSubscriptions}}"> <option ng-repeat="orgSubscription in orgMarketplaceSubscriptions" value="{{orgSubscription}}">
{{sku}} {{orgSubscription.quantity}}x {{orgSubscription.sku}}
</option> </option>
</select> </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)"> <a class="btn btn-default" ng-click="batchRemoveSku(subscriptionRemovals, numRemovals)">
Remove subscriptions Remove subscriptions
</a> </a>

View File

@ -50,7 +50,10 @@ angular.module('quay').directive('billingManagementPanel', function () {
if ($scope.organization) { if ($scope.organization) {
PlanService.listOrgMarketplaceSubscriptions($scope.organization.name, function(subscriptions){ PlanService.listOrgMarketplaceSubscriptions($scope.organization.name, function(subscriptions){
for (var i = 0; i < subscriptions.length; i++) { for (var i = 0; i < subscriptions.length; i++) {
total += subscriptions[i]["metadata"]["privateRepos"]; total += (
subscriptions[i]["quantity"] *
subscriptions[i]["metadata"]["privateRepos"]
);
} }
$scope.currentMarketplace = total; $scope.currentMarketplace = total;
}) })
@ -58,7 +61,10 @@ angular.module('quay').directive('billingManagementPanel', function () {
PlanService.listUserMarketplaceSubscriptions(function(subscriptions){ PlanService.listUserMarketplaceSubscriptions(function(subscriptions){
for (var i = 0; i < subscriptions.length; i++) { for (var i = 0; i < subscriptions.length; i++) {
if(subscriptions[i]["assigned_to_org"] === null) { if(subscriptions[i]["assigned_to_org"] === null) {
total += subscriptions[i]["metadata"]["privateRepos"]; total += (
subscriptions[i]["quantity"] *
subscriptions[i]["metadata"]["privateRepos"]
);
} }
} }
$scope.currentMarketplace = total; $scope.currentMarketplace = total;

View File

@ -8,9 +8,9 @@ angular.module('quay').directive('orgBinding', function() {
'organization': '=organization', 'organization': '=organization',
}, },
controller: function($scope, $timeout, PlanService, ApiService) { controller: function($scope, $timeout, PlanService, ApiService) {
$scope.userMarketplaceSubscriptions = {}; $scope.userMarketplaceSubscriptions = [];
$scope.orgMarketplaceSubscriptions = {}; $scope.orgMarketplaceSubscriptions = [];
$scope.availableSubscriptions = {}; $scope.availableSubscriptions = [];
$scope.marketplaceLoading = true; $scope.marketplaceLoading = true;
$scope.bindOrgSuccess = false; $scope.bindOrgSuccess = false;
$scope.removeSkuSuccess = false; $scope.removeSkuSuccess = false;
@ -34,9 +34,12 @@ angular.module('quay').directive('orgBinding', function() {
if ($scope.organization) { if ($scope.organization) {
PlanService.listOrgMarketplaceSubscriptions($scope.organization, function(marketplaceSubscriptions){ PlanService.listOrgMarketplaceSubscriptions($scope.organization, function(marketplaceSubscriptions){
// group the list of subscriptions by their sku field // group the list of subscriptions by their sku field
$scope.orgMarketplaceSubscriptions = groupSubscriptionsBySku(marketplaceSubscriptions); $scope.orgMarketplaceSubscriptions = marketplaceSubscriptions;
for (var i = 0; i < marketplaceSubscriptions.length; i++) { for (var i = 0; i < marketplaceSubscriptions.length; i++) {
total += marketplaceSubscriptions[i]["metadata"]["privateRepos"]; total += (
marketplaceSubscriptions[i]["quantity"] *
marketplaceSubscriptions[i]["metadata"]["privateRepos"]
);
} }
$scope.marketplaceTotal = total; $scope.marketplaceTotal = total;
}); });
@ -48,12 +51,15 @@ angular.module('quay').directive('orgBinding', function() {
return; return;
} }
let notBound = []; let notBound = [];
$scope.userMarketplaceSubscriptions = groupSubscriptionsBySku(marketplaceSubscriptions); $scope.userMarketplaceSubscriptions = marketplaceSubscriptions;
for (var i = 0; i < marketplaceSubscriptions.length; i++) { for (var i = 0; i < marketplaceSubscriptions.length; i++) {
if (marketplaceSubscriptions[i]["assigned_to_org"] === null) { if (marketplaceSubscriptions[i]["assigned_to_org"] === null) {
if(!($scope.organization)){ if(!($scope.organization)){
total += marketplaceSubscriptions[i]["metadata"]["privateRepos"]; total += (
marketplaceSubscriptions[i]["quantity"] *
marketplaceSubscriptions[i]["metadata"]["privateRepos"]
);
} }
notBound.push(marketplaceSubscriptions[i]); notBound.push(marketplaceSubscriptions[i]);
} }
@ -61,7 +67,7 @@ angular.module('quay').directive('orgBinding', function() {
if(!($scope.organization)){ if(!($scope.organization)){
$scope.marketplaceTotal = total; $scope.marketplaceTotal = total;
} }
$scope.availableSubscriptions = groupSubscriptionsBySku(notBound); $scope.availableSubscriptions = notBound;
$scope.marketplaceLoading = false; $scope.marketplaceLoading = false;
}); });
} }
@ -71,21 +77,14 @@ angular.module('quay').directive('orgBinding', function() {
loadSubscriptions(); loadSubscriptions();
} }
$scope.bindSku = function(subscriptions, numSubscriptions) { $scope.bindSku = function(subscriptionToBind) {
let subscriptionArr = JSON.parse(subscriptions); let subscription = JSON.parse(subscriptionToBind);
if(numSubscriptions > subscriptionArr.length){
displayError("number of subscriptions exceeds total amount");
return;
}
$scope.marketplaceLoading = true; $scope.marketplaceLoading = true;
const requestData = {}; const requestData = {};
requestData["subscriptions"] = []; requestData["subscriptions"] = [];
for(var i = 0; i < numSubscriptions; ++i) { requestData["subscriptions"].push({
var subscriptionObject = {}; "subscription_id": subscription["id"],
var subscriptionId = subscriptionArr[i].id; });
subscriptionObject.subscription_id = subscriptionId;
requestData["subscriptions"].push(subscriptionObject);
}
PlanService.bindSkuToOrg(requestData, $scope.organization, function(resp){ PlanService.bindSkuToOrg(requestData, $scope.organization, function(resp){
if (resp === "Okay"){ if (resp === "Okay"){
bindSkuSuccessMessage(); bindSkuSuccessMessage();
@ -96,16 +95,11 @@ angular.module('quay').directive('orgBinding', function() {
}); });
}; };
$scope.batchRemoveSku = function(removals, numRemovals) { $scope.batchRemoveSku = function(subscriptionToRemove) {
let removalArr = JSON.parse(removals); let subscription = JSON.parse(subscriptionToRemove);
const requestData = {}; const requestData = {};
requestData["subscriptions"] = []; requestData["subscriptions"] = [];
for(var i = 0; i < numRemovals; ++i){ requestData["subscriptions"].push({"subscription_id": subscription["subscription_id"]});
var subscriptionObject = {};
var subscriptionId = removalArr[i].subscription_id;
subscriptionObject.subscription_id = subscriptionId;
requestData["subscriptions"].push(subscriptionObject);
}
PlanService.batchRemoveSku(requestData, $scope.organization, function(resp){ PlanService.batchRemoveSku(requestData, $scope.organization, function(resp){
if (resp == "") { if (resp == "") {
removeSkuSuccessMessage(); removeSkuSuccessMessage();

View File

@ -42,6 +42,7 @@ from endpoints.api.billing import (
UserCard, UserCard,
UserPlan, UserPlan,
UserSkuList, UserSkuList,
check_internal_api_for_subscription,
) )
from endpoints.api.build import ( from endpoints.api.build import (
RepositoryBuildList, RepositoryBuildList,
@ -5078,7 +5079,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={"subscriptions": [{"subscription_id": 12345678}]}, data={"subscriptions": [{"subscription_id": 12345678, "quantity": 2}]},
expected_code=201, expected_code=201,
) )
json = self.getJsonResponse( json = self.getJsonResponse(
@ -5164,6 +5165,19 @@ class TestOrganizationRhSku(ApiTestCase):
) )
self.assertEqual(len(json), 0) self.assertEqual(len(json), 0)
def test_none_quantity(self):
self.login(SUBSCRIPTION_USER)
user = model.user.get_user(SUBSCRIPTION_USER)
org = model.organization.get_organization(SUBSCRIPTION_ORG)
model.organization_skus.bind_subscription_to_org(12345678, org.id, user.id, None)
json = self.getJsonResponse(
resource_name=OrganizationRhSku, params=dict(orgname=SUBSCRIPTION_ORG)
)
self.assertEqual(json[0]["quantity"], 1)
plans = check_internal_api_for_subscription(org)
assert len(plans) == 1
class TestUserSku(ApiTestCase): class TestUserSku(ApiTestCase):
def test_get_user_skus(self): def test_get_user_skus(self):
@ -5171,6 +5185,12 @@ class TestUserSku(ApiTestCase):
json = self.getJsonResponse(UserSkuList) json = self.getJsonResponse(UserSkuList)
self.assertEqual(len(json), 2) self.assertEqual(len(json), 2)
def test_quantity(self):
self.login(SUBSCRIPTION_USER)
subscription_user = model.user.get_user(SUBSCRIPTION_USER)
plans = check_internal_api_for_subscription(subscription_user)
assert len(plans) == 3
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -239,7 +239,9 @@ class RedHatSubscriptionApi(object):
continue continue
if convert_to_stripe_plans: if convert_to_stripe_plans:
subscription_list.append(get_plan_using_rh_sku(sku)) quantity = user_subscription["quantity"]
for i in range(quantity):
subscription_list.append(get_plan_using_rh_sku(sku))
else: else:
# add in sku field for convenience # add in sku field for convenience
user_subscription["sku"] = sku user_subscription["sku"] = sku
@ -263,7 +265,7 @@ TEST_USER = {
"installBaseEndDate": 1707368399000, "installBaseEndDate": 1707368399000,
"webCustomerId": 123456, "webCustomerId": 123456,
"subscriptionNumber": "12399889", "subscriptionNumber": "12399889",
"quantity": 1, "quantity": 2,
"effectiveStartDate": 1707368400000, "effectiveStartDate": 1707368400000,
"effectiveEndDate": 3813177600, "effectiveEndDate": 3813177600,
}, },

View File

@ -1,4 +1,5 @@
import json import json
import unittest
import requests import requests
from mock import patch from mock import patch
@ -119,7 +120,7 @@ mocked_subscription_response = [
"installBaseStartDate": 1705467600000, "installBaseStartDate": 1705467600000,
"installBaseEndDate": 1708145999000, "installBaseEndDate": 1708145999000,
"webCustomerId": 12345, "webCustomerId": 12345,
"quantity": 1, "quantity": 2,
"effectiveStartDate": 1705467600000, "effectiveStartDate": 1705467600000,
"effectiveEndDate": 1708145999000, "effectiveEndDate": 1708145999000,
}, },
@ -140,7 +141,7 @@ mocked_subscription_response = [
] ]
class TestMarketplace: class TestMarketplace(unittest.TestCase):
@patch("requests.request") @patch("requests.request")
def test_timeout_exception(self, requests_mock): def test_timeout_exception(self, requests_mock):
requests_mock.side_effect = requests.exceptions.ReadTimeout() requests_mock.side_effect = requests.exceptions.ReadTimeout()

View File

@ -2869,7 +2869,8 @@ CREATE TABLE public.organizationrhskus (
id integer NOT NULL, id integer NOT NULL,
subscription_id integer NOT NULL, subscription_id integer NOT NULL,
org_id integer NOT NULL, org_id integer NOT NULL,
user_id integer NOT NULL user_id integer NOT NULL,
quantity integer
); );
@ -5569,7 +5570,7 @@ COPY public.accesstokenkind (id, name) FROM stdin;
-- --
COPY public.alembic_version (version_num) FROM stdin; COPY public.alembic_version (version_num) FROM stdin;
3f8e3657bb67 41d15c93c299
\. \.
@ -6465,7 +6466,7 @@ COPY public.oauthauthorizationcode (id, application_id, scope, data, code_creden
-- Data for Name: organizationrhskus; Type: TABLE DATA; Schema: public; Owner: quay -- Data for Name: organizationrhskus; Type: TABLE DATA; Schema: public; Owner: quay
-- --
COPY public.organizationrhskus (id, subscription_id, org_id, user_id) FROM stdin; COPY public.organizationrhskus (id, subscription_id, org_id, user_id, quantity) FROM stdin;
\. \.
@ -8083,7 +8084,6 @@ SELECT pg_catalog.setval('public.logentry_id_seq', 1, false);
-- Name: logentrykind_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay -- Name: logentrykind_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay
-- --
SELECT pg_catalog.setval('public.logentrykind_id_seq', 107, true); SELECT pg_catalog.setval('public.logentrykind_id_seq', 107, true);