Skip to content

Commit

Permalink
marketplace: splittable sku for MW02702 (PROJQUAY-8151) (quay#3389)
Browse files Browse the repository at this point in the history
* marketplace: splittable sku for MW02702 (PROJQUAY-8151)
* Alembic migration to drop unique constraint on the orgrhsubscriptions
  table
* Can split sub quantities of MW02702 across multiple orgs
* Can specify quantity for the MW02702 SKU across orgs on react UI
* Update angular UI to allow user to specify quantities for MW02702
  • Loading branch information
Marcusk19 authored Jan 9, 2025
1 parent f869c27 commit f69716b
Show file tree
Hide file tree
Showing 12 changed files with 250 additions and 50 deletions.
2 changes: 1 addition & 1 deletion data/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -2034,7 +2034,7 @@ class OrganizationRhSkus(BaseModel):
RH subscriptions
"""

subscription_id = IntegerField(index=True, unique=True)
subscription_id = IntegerField(index=True)
user_id = ForeignKeyField(User, backref="org_bound_subscription")
org_id = ForeignKeyField(User, backref="subscription")
quantity = IntegerField(index=True, null=True)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""drop unique constraint on org sku table
Revision ID: 3634f2df3c5b
Revises: 8e97c2cfee57
Create Date: 2024-11-04 14:14:21.736496
"""

# revision identifiers, used by Alembic.
revision = "3634f2df3c5b"
down_revision = "8e97c2cfee57"

import sqlalchemy as sa


def upgrade(op, tables, tester):
# Drop the existing unique index
op.drop_index("organizationrhskus_subscription_id", table_name="organizationrhskus")
op.create_index(
"organizationrhskus_subscription_id",
"organizationrhskus",
["subscription_id"],
unique=False,
)


def downgrade(op, tables, tester):
# Re-add the unique index if we need to rollback
op.drop_index("organizationrhskus_subscription_id", table_name="organizationrhskus")
op.create_index(
"organizationrhskus_subscription_id", "organizationrhskus", ["subscription_id"], unique=True
)
7 changes: 7 additions & 0 deletions data/model/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ def get_organization(name):
raise InvalidOrganizationException("Organization does not exist: %s" % name)


def get_organization_by_id(org_db_id):
try:
return User.get(id=org_db_id, organization=True)
except User.DoesNotExist:
raise InvalidOrganizationException("Organization does not exist: %s" % org_db_id)


def convert_user_to_organization(user_obj, admin_user):
if user_obj.robot:
raise DataModelException("Cannot convert a robot into an organization")
Expand Down
20 changes: 18 additions & 2 deletions data/model/organization_skus.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,14 @@ def subscription_bound_to_org(subscription_id):
# lookup row in table matching subscription_id, if there is no row return false, otherwise return true
# this function is used to check if a subscription is bound to an org or
try:
binding = OrganizationRhSkus.get(OrganizationRhSkus.subscription_id == subscription_id)
return True, binding.org_id
query = (
OrganizationRhSkus.select()
.where(OrganizationRhSkus.subscription_id == subscription_id)
.dicts()
)
if query.__len__() > 0:
return True, query
return False, None
except OrganizationRhSkus.DoesNotExist:
return False, None

Expand All @@ -54,3 +60,13 @@ def remove_all_owner_subscriptions_from_org(user_id, org_id):
query.execute()
except model.DataModelException as ex:
raise model.DataModelException(ex)


def get_bound_subscriptions(subscription_id):
try:
query = OrganizationRhSkus.select().where(
OrganizationRhSkus.subscription_id == subscription_id
)
return query
except OrganizationRhSkus.DoesNotExist:
return None
69 changes: 53 additions & 16 deletions endpoints/api/billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -1011,21 +1011,29 @@ def post(self, orgname):
user_available_subscriptions = []
for account_number in account_numbers:
user_available_subscriptions += (
marketplace_subscriptions.get_list_of_subscriptions(account_number)
marketplace_subscriptions.get_list_of_subscriptions(
account_number, filter_out_org_bindings=True
)
)

if subscriptions is None:
abort(401, message="no valid subscriptions present")

user_subscription_ids = [
int(subscription["id"]) for subscription in user_available_subscriptions
]
if int(subscription_id) in user_subscription_ids:
quantity = 1
for subscription in user_available_subscriptions:
if subscription["id"] == subscription_id:
quantity = subscription["quantity"]
break
user_subs = {sub["id"]: sub for sub in user_available_subscriptions}
if int(subscription_id) in user_subs.keys():
# Check if the sku is being split
quantity = subscription.get("quantity")
base_quantity = user_subs.get(subscription_id).get("quantity", 1)
sku = user_subs.get(subscription_id).get("sku")

if quantity is not None:
if sku != "MW02702" and quantity != base_quantity:
abort(403, message="cannot split a non-MW02702 sku")
if quantity > base_quantity:
abort(400, message="quantity cannot exceed available amount")
else:
quantity = base_quantity

try:
model.organization_skus.bind_subscription_to_org(
user_id=user.id,
Expand Down Expand Up @@ -1127,16 +1135,45 @@ def get(self):
account_number
)

child_subscriptions = []
for subscription in user_subscriptions:
bound_to_org, organization = organization_skus.subscription_bound_to_org(
subscription["id"]
)
bound_to_org, bindings = organization_skus.subscription_bound_to_org(subscription["id"])
# fill in information for whether a subscription is bound to an org
metadata = get_plan_using_rh_sku(subscription["sku"])
if bound_to_org:
subscription["assigned_to_org"] = organization.username
# special case for MW02702, which can be split across orgs
if subscription["sku"] == "MW02702":
number_of_bindings = 0
for binding in bindings:
# for each bound org, create a new subscription to add to
# the response body
child_subscription = subscription.copy()
child_subscription["quantity"] = binding["quantity"]
child_subscription[
"assigned_to_org"
] = model.organization.get_organization_by_id(binding["org_id"]).username
child_subscription["metadata"] = metadata
child_subscriptions.append(child_subscription)

number_of_bindings += binding["quantity"]

remaining_unbound = subscription["quantity"] - number_of_bindings
if remaining_unbound > 0:
subscription["quantity"] = remaining_unbound
subscription["assigned_to_org"] = None
else:
# all quantities for this subscription are bound, remove it from
# the response body
user_subscriptions.remove(subscription)

else:
# default case, only one org is bound
subscription["assigned_to_org"] = model.organization.get_organization_by_id(
bindings[0]["org_id"]
).username
else:
subscription["assigned_to_org"] = None

subscription["metadata"] = get_plan_using_rh_sku(subscription["sku"])
subscription["metadata"] = metadata

return user_subscriptions
return user_subscriptions + child_subscriptions
23 changes: 15 additions & 8 deletions static/directives/org-binding.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,37 @@
<div class="cor-loader-inline" ng-show="marketplaceLoading"></div>
<span ng-show="!organization && !marketplaceLoading">
<div ng-repeat="subscription in userMarketplaceSubscriptions">
{{subscription.quantity}}x {{ subscription.metadata.privateRepos }} private repos
{{subscription.quantity}}x {{ subscription.metadata.privateRepos > 15000 ? 'unlimited' : subscription.metadata.privateRepos }} private repos
{{subscription.assigned_to_org ? "attached to org " + subscription.assigned_to_org : ""}}
</div>
</span>

<table ng-show="organization && !marketplaceLoading">
<tr class="indented-row" ng-repeat="subscription in orgMarketplaceSubscriptions">
<td>
{{ subscription.quantity }}x {{ subscription.metadata.privateRepos }} private repos attached to this org
{{ subscription.quantity }}x {{ subscription.metadata.privateRepos > 15000 ? 'unlimited' : subscription.metadata.privateRepos }} private repos attached to this org
</td>
</tr>
<tr class="indented-row">
<td style="padding: 10px">
<select class="form-control" ng-model="subscriptionBinding">
<option ng-repeat="subscription in availableSubscriptions" value="{{ subscription }}">
{{subscription.quantity}}x {{subscription.metadata.privateRepos}} private repos
</option>
<select class="form-control" ng-model="subscriptionBinding" ng-options="subscription as (subscription.quantity + 'x ' + (subscription.metadata.privateRepos > 15000 ? 'unlimited' : subscription.metadata.privateRepos) + ' private repos') for subscription in availableSubscriptions">
<option value="">Select a subscription</option>
</select>
<a class="btn btn-primary" ng-click="bindSku(subscriptionBinding)">Attach subscriptions</a>
<input
type="number"
class="form-control"
ng-show="subscriptionBinding.sku === 'MW02702'"
ng-model="bindingQuantity"
min="1"
max="{{subscriptionBinding.quantity}}"
ng-init="bindingQuantity = 1"
>
<a class="btn btn-primary" ng-click="bindSku(subscriptionBinding, subscriptionBinding.sku === 'MW02702' ? bindingQuantity : undefined)">Attach subscriptions</a>
</td>
<td style="padding: 10px">
<select class="form-control" ng-model="subscriptionRemovals">
<option ng-repeat="orgSubscription in orgMarketplaceSubscriptions" value="{{orgSubscription}}">
{{orgSubscription.quantity}}x {{orgSubscription.metadata.privateRepos}} private repos
{{orgSubscription.quantity}}x {{orgSubscription.metadata.privateRepos > 15000 ? 'unlimited' : subscription.metadata.privateRepos}} private repos
</option>
</select>
<a class="btn btn-default" ng-click="batchRemoveSku(subscriptionRemovals, numRemovals)">
Expand Down
21 changes: 16 additions & 5 deletions static/js/directives/ui/org-binding.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,25 @@ angular.module('quay').directive('orgBinding', function() {
loadSubscriptions();
}

$scope.bindSku = function(subscriptionToBind) {
let subscription = JSON.parse(subscriptionToBind);
$scope.bindSku = function(subscriptionToBind, bindingQuantity) {
let subscription;
try {
// Try to parse if it's a JSON string
subscription = typeof subscriptionToBind === 'string' ? JSON.parse(subscriptionToBind) : subscriptionToBind;
} catch (e) {
// If parsing fails, assume it's already an object
subscription = subscriptionToBind;
}
$scope.marketplaceLoading = true;
const requestData = {};
requestData["subscriptions"] = [];
requestData["subscriptions"].push({
"subscription_id": subscription["id"],
});
const subscriptionData = {
"subscription_id": subscription["id"]
};
if (bindingQuantity !== undefined) {
subscriptionData["quantity"] = bindingQuantity;
}
requestData["subscriptions"].push(subscriptionData);
PlanService.bindSkuToOrg(requestData, $scope.organization, function(resp){
if (resp === "Okay"){
bindSkuSuccessMessage();
Expand Down
2 changes: 1 addition & 1 deletion static/js/directives/ui/usage-chart.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ angular.module('quay').directive('usageChart', function () {
}

var finalAmount = $scope.total + $scope.marketplaceTotal;
if(finalAmount >= 9223372036854775807) { finalAmount = "unlimited"; }
if(finalAmount >= 9223372036854775807) { finalAmount = "inf"; }
chart.update($scope.current, finalAmount);
};

Expand Down
64 changes: 53 additions & 11 deletions test/test_api_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -5185,6 +5185,38 @@ def test_delete_message(self):
self.assertEqual(len(json["messages"]), 1)


class TestUserSku(ApiTestCase):
def test_get_user_skus(self):
self.login(SUBSCRIPTION_USER)
json = self.getJsonResponse(UserSkuList)
self.assertEqual(len(json), 3)

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) == 13

def test_split_sku(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(80808080, org.id, user.id, 3)

user_subs = self.getJsonResponse(resource_name=UserSkuList)

unassigned_sub = None
assigned_sub = None
for sub in user_subs:
if sub["id"] == 80808080 and sub["assigned_to_org"] is None:
unassigned_sub = sub
elif sub["id"] == 80808080 and sub["assigned_to_org"] is not None:
assigned_sub = sub
self.assertIsNotNone(unassigned_sub, "Could not find unassigned remaining subscription")
self.assertIsNotNone(assigned_sub, "Could not find assigned subscription")
self.assertEqual(7, unassigned_sub["quantity"])


class TestOrganizationRhSku(ApiTestCase):
def test_bind_sku_to_org(self):
self.login(SUBSCRIPTION_USER)
Expand All @@ -5209,7 +5241,7 @@ def test_bind_sku_duplicate(self):
resource_name=OrganizationRhSku,
params=dict(orgname=SUBSCRIPTION_ORG),
data={"subscriptions": [{"subscription_id": 12345678}]},
expected_code=400,
expected_code=401,
)

def test_bind_sku_unauthorized(self):
Expand Down Expand Up @@ -5320,18 +5352,28 @@ def test_terminated_attachment(self):
json = self.getJsonResponse(OrgPrivateRepositories, params=dict(orgname=SUBSCRIPTION_ORG))
self.assertEqual(json["privateAllowed"], False)


class TestUserSku(ApiTestCase):
def test_get_user_skus(self):
def test_splittable_sku(self):
self.login(SUBSCRIPTION_USER)
json = self.getJsonResponse(UserSkuList)
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
self.postResponse(
resource_name=OrganizationRhSku,
params=dict(orgname=SUBSCRIPTION_ORG),
data={"subscriptions": [{"subscription_id": 80808080, "quantity": 3}]},
expected_code=201,
)

self.postResponse(
resource_name=OrganizationRhSku,
params=dict(orgname=SUBSCRIPTION_ORG),
data={"subscriptions": [{"subscription_id": 80808080, "quantity": 10}]},
expected_code=400,
)

org_subs = self.getJsonResponse(
resource_name=OrganizationRhSku,
params=dict(orgname=SUBSCRIPTION_ORG),
)
self.assertEqual(org_subs[0]["quantity"], 3)


if __name__ == "__main__":
Expand Down
Loading

0 comments on commit f69716b

Please sign in to comment.