diff --git a/data/database.py b/data/database.py index 6b0baadf20..16415766c6 100644 --- a/data/database.py +++ b/data/database.py @@ -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) diff --git a/data/migrations/versions/3634f2df3c5b_drop_unique_constraint_on_org_sku_table.py b/data/migrations/versions/3634f2df3c5b_drop_unique_constraint_on_org_sku_table.py new file mode 100644 index 0000000000..efbe442c61 --- /dev/null +++ b/data/migrations/versions/3634f2df3c5b_drop_unique_constraint_on_org_sku_table.py @@ -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 + ) diff --git a/data/model/organization.py b/data/model/organization.py index 6ec7f57528..c26b94028b 100644 --- a/data/model/organization.py +++ b/data/model/organization.py @@ -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") diff --git a/data/model/organization_skus.py b/data/model/organization_skus.py index 9a8252fb0c..1d51c47c24 100644 --- a/data/model/organization_skus.py +++ b/data/model/organization_skus.py @@ -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 @@ -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 diff --git a/endpoints/api/billing.py b/endpoints/api/billing.py index 116a61500b..9c87e154ca 100644 --- a/endpoints/api/billing.py +++ b/endpoints/api/billing.py @@ -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, @@ -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 diff --git a/static/directives/org-binding.html b/static/directives/org-binding.html index d1b07f8ed0..7c8e684646 100644 --- a/static/directives/org-binding.html +++ b/static/directives/org-binding.html @@ -3,7 +3,7 @@
- {{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 : ""}}
@@ -11,22 +11,29 @@
- {{ 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
- + - Attach subscriptions + + Attach subscriptions diff --git a/static/js/directives/ui/org-binding.js b/static/js/directives/ui/org-binding.js index f28f51488b..13f88d437d 100644 --- a/static/js/directives/ui/org-binding.js +++ b/static/js/directives/ui/org-binding.js @@ -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(); diff --git a/static/js/directives/ui/usage-chart.js b/static/js/directives/ui/usage-chart.js index ec0df13de5..2c49e2add9 100644 --- a/static/js/directives/ui/usage-chart.js +++ b/static/js/directives/ui/usage-chart.js @@ -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); }; diff --git a/test/test_api_usage.py b/test/test_api_usage.py index b0746043fd..65d4911332 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -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) @@ -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): @@ -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__": diff --git a/util/marketplace.py b/util/marketplace.py index 6225729e11..b8448d5525 100644 --- a/util/marketplace.py +++ b/util/marketplace.py @@ -239,12 +239,22 @@ def get_list_of_subscriptions( ): continue - bound_to_org = organization_skus.subscription_bound_to_org( + bound_to_org, bound_subs = organization_skus.subscription_bound_to_org( user_subscription["id"] ) - if filter_out_org_bindings and bound_to_org[0]: - continue + if filter_out_org_bindings and bound_to_org: + # special case for MW02702, we need to calculate how many + # subscriptions are left if it is split across orgs + if sku == "MW02702": + total_attached = 0 + for sub in bound_subs: + total_attached += sub["quantity"] + user_subscription["quantity"] -= total_attached + if user_subscription["quantity"] <= 0: + continue + else: + continue if convert_to_stripe_plans: quantity = user_subscription["quantity"] @@ -296,6 +306,21 @@ def get_list_of_subscriptions( "effectiveEndDate": 3813177600000, }, ], + "private_subscription": { + "id": 80808080, + "masterEndSystemName": "Quay", + "createdEndSystemName": "SUBSCRIPTION", + "createdDate": 1675957362000, + "lastUpdateEndSystemName": "SUBSCRIPTION", + "lastUpdateDate": 1675957362000, + "installBaseStartDate": 1707368400000, + "installBaseEndDate": 1707368399000, + "webCustomerId": 123456, + "subscriptionNumber": "12399889", + "quantity": 10, + "effectiveStartDate": 1707368400000, + "effectiveEndDate": 3813177600000, + }, "reconciled_subscription": { "id": 87654321, "masterEndSystemName": "SUBSCRIPTION", @@ -362,6 +387,8 @@ def __init__(self): def lookup_subscription(self, customer_id, sku_id): if customer_id == TEST_USER["account_number"] and sku_id == "MW02701": return TEST_USER["subscriptions"] + elif customer_id == TEST_USER["account_number"] and sku_id == "MW02702": + return [TEST_USER["private_subscription"]] elif customer_id == TEST_USER["account_number"] and sku_id == "MW00584MO": return [TEST_USER["reconciled_subscription"]] return None @@ -377,7 +404,7 @@ def get_subscription_details(self, subscription_id): if subscription_id in valid_ids: return {"sku": "MW02701", "expiration_date": 3813177600000, "terminated_date": None} elif subscription_id == 80808080: - return {"sku": "MW02701", "expiration_date": 1645544830000, "terminated_date": None} + return {"sku": "MW02702", "expiration_date": 1645544830000, "terminated_date": None} elif subscription_id == 87654321: return {"sku": "MW00584MO", "expiration_date": 3813177600000, "terminated_date": None} elif subscription_id == 22222222: diff --git a/web/src/components/modals/OrgSubscriptionModal.tsx b/web/src/components/modals/OrgSubscriptionModal.tsx index e4abf2cb28..ca3a855f77 100644 --- a/web/src/components/modals/OrgSubscriptionModal.tsx +++ b/web/src/components/modals/OrgSubscriptionModal.tsx @@ -7,6 +7,7 @@ import { MenuToggleElement, Modal, ModalVariant, + NumberInput, Select, SelectList, SelectOption, @@ -27,6 +28,7 @@ interface OrgSubscriptionModalProps { export default function OrgSubscriptionModal(props: OrgSubscriptionModalProps) { const [selectedSku, setSelectedSku] = React.useState(''); const [menuIsOpen, setMenuIsOpen] = React.useState(false); + const [bindingQuantity, setBindingQuantity] = React.useState(null); const {addAlert} = useAlerts(); const onSelect = ( _event: React.MouseEvent | undefined, @@ -103,6 +105,18 @@ export default function OrgSubscriptionModal(props: OrgSubscriptionModalProps) { + + {props.subscriptions[selectedSku]?.sku === 'MW02702' && + props.modalType === 'attach' && ( + setBindingQuantity(bindingQuantity + 1)} + onMinus={() => setBindingQuantity(bindingQuantity - 1)} + min={1} + max={props.subscriptions[selectedSku]?.quantity} + /> + )} +