Skip to content

Commit

Permalink
billing: marketplace UI (PROJQUAY-6551) (quay#2595)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Marcusk19 authored Jan 11, 2024
1 parent 27cceb1 commit 2a4ac09
Show file tree
Hide file tree
Showing 14 changed files with 456 additions and 39 deletions.
15 changes: 14 additions & 1 deletion data/billing.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import random
import string
import sys
from calendar import timegm
from datetime import datetime, timedelta
from typing import Any, Dict
Expand Down Expand Up @@ -292,6 +293,7 @@
"price": 45000,
"privateRepos": 250,
"rh_sku": "MW00589MO",
"billing_enabled": False,
"stripeId": "bus-xlarge-2018",
"audience": "For extra large businesses",
"bus_features": True,
Expand All @@ -305,6 +307,7 @@
"price": 85000,
"privateRepos": 500,
"rh_sku": "MW00590MO",
"billing_enabled": False,
"stripeId": "bus-500-2018",
"audience": "For huge business",
"bus_features": True,
Expand Down Expand Up @@ -354,13 +357,21 @@
"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 = [
Expand Down Expand Up @@ -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
Expand Down
81 changes: 61 additions & 20 deletions endpoints/api/billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -955,9 +955,11 @@ def get(self, orgname):
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 []
Expand All @@ -971,42 +973,79 @@ def post(self, orgname):
"""
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")

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}",
)

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

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
)
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}")
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):
"""
Resource for removing RH skus from an organization
"""

@require_scope(scopes.ORG_ADMIN)
@nickname("removeSkuFromOrg")
def delete(self, orgname, subscription_id):
"""
Remove sku from an org
Expand Down Expand Up @@ -1054,4 +1093,6 @@ def get(self):
else:
subscription["assigned_to_org"] = None

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

return user_subscriptions
10 changes: 9 additions & 1 deletion endpoints/api/test/test_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -6047,7 +6047,7 @@
OrganizationRhSku,
"POST",
{"orgname": "buynlarge"},
{"subscription_id": 12345},
{"subscriptions": [{"subscription_id": 12345}]},
None,
401,
),
Expand All @@ -6059,6 +6059,14 @@
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),
Expand Down
11 changes: 11 additions & 0 deletions static/css/directives/ui/org-binding.css
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 4 additions & 4 deletions static/directives/billing-management-panel.html
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down Expand Up @@ -77,4 +77,4 @@
</form>
</div>

</div>
</div>
53 changes: 53 additions & 0 deletions static/directives/org-binding.html
Original file line number Diff line number Diff line change
@@ -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>
13 changes: 12 additions & 1 deletion static/directives/plan-manager.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
29 changes: 28 additions & 1 deletion static/js/directives/ui/billing-management-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -141,4 +168,4 @@ angular.module('quay').directive('billingManagementPanel', function () {
}
};
return directiveDefinitionObject;
});
});
Loading

0 comments on commit 2a4ac09

Please sign in to comment.