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
|
- |
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}
+ />
+ )}
+
|