diff --git a/data/model/legacy.py b/data/model/legacy.py index 464131f55..f34ef7cae 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -2928,4 +2928,3 @@ def revert_tag(repository, tag_name, docker_image_id): return create_or_update_tag(repository.namespace_user.username, repository.name, tag_name, docker_image_id, reversion=True) - diff --git a/endpoints/api/billing.py b/endpoints/api/billing.py index 36dc96102..0342c9ea0 100644 --- a/endpoints/api/billing.py +++ b/endpoints/api/billing.py @@ -13,6 +13,8 @@ from data import model from data.billing import PLANS import features +import uuid +import json def carderror_response(e): return {'carderror': e.message}, 402 @@ -96,6 +98,48 @@ def get_invoices(customer_id): } +def get_invoice_fields(user): + try: + cus = billing.Customer.retrieve(user.stripe_id) + except stripe.APIConnectionError: + abort(503, message='Cannot contact Stripe') + + if not 'metadata' in cus: + cus.metadata = {} + + return json.loads(cus.metadata.get('invoice_fields') or '[]'), cus + + +def create_billing_invoice_field(user, title, value): + new_field = { + 'uuid': str(uuid.uuid4()).split('-')[0], + 'title': title, + 'value': value + } + + invoice_fields, cus = get_invoice_fields(user) + invoice_fields.append(new_field) + + if not 'metadata' in cus: + cus.metadata = {} + + cus.metadata['invoice_fields'] = json.dumps(invoice_fields) + cus.save() + return new_field + + +def delete_billing_invoice_field(user, field_uuid): + invoice_fields, cus = get_invoice_fields(user) + invoice_fields = [field for field in invoice_fields if not field['uuid'] == field_uuid] + + if not 'metadata' in cus: + cus.metadata = {} + + cus.metadata['invoice_fields'] = json.dumps(invoice_fields) + cus.save() + return True + + @resource('/v1/plans/') @show_if(features.BILLING) class ListPlans(ApiResource): @@ -367,3 +411,159 @@ class OrgnaizationInvoiceList(ApiResource): return get_invoices(organization.stripe_id) raise Unauthorized() + + +@resource('/v1/user/invoice/fields') +@internal_only +@show_if(features.BILLING) +class UserInvoiceFieldList(ApiResource): + """ Resource for listing and creating a user's custom invoice fields. """ + schemas = { + 'InvoiceField': { + 'id': 'InvoiceField', + 'type': 'object', + 'description': 'Description of an invoice field', + 'required': [ + 'title', 'value' + ], + 'properties': { + 'title': { + 'type': 'string', + 'description': 'The title of the field being added', + }, + 'value': { + 'type': 'string', + 'description': 'The value of the field being added', + }, + }, + }, + } + + @require_user_admin + @nickname('listUserInvoiceFields') + def get(self): + """ List the invoice fields for the current user. """ + user = get_authenticated_user() + if not user.stripe_id: + raise NotFound() + + return {'fields': get_invoice_fields(user)[0]} + + @require_user_admin + @nickname('createUserInvoiceField') + @validate_json_request('InvoiceField') + def post(self): + """ Creates a new invoice field. """ + user = get_authenticated_user() + if not user.stripe_id: + raise NotFound() + + data = request.get_json() + created_field = create_billing_invoice_field(user, data['title'], data['value']) + return created_field + + +@resource('/v1/user/invoice/field/') +@internal_only +@show_if(features.BILLING) +class UserInvoiceField(ApiResource): + """ Resource for deleting a user's custom invoice fields. """ + @require_user_admin + @nickname('deleteUserInvoiceField') + def delete(self, field_uuid): + """ Deletes the invoice field for the current user. """ + user = get_authenticated_user() + if not user.stripe_id: + raise NotFound() + + result = delete_billing_invoice_field(user, field_uuid) + if not result: + abort(404) + + return 'Okay', 201 + + +@resource('/v1/organization//invoice/fields') +@path_param('orgname', 'The name of the organization') +@related_user_resource(UserInvoiceFieldList) +@internal_only +@show_if(features.BILLING) +class OrganizationInvoiceFieldList(ApiResource): + """ Resource for listing and creating an organization's custom invoice fields. """ + schemas = { + 'InvoiceField': { + 'id': 'InvoiceField', + 'type': 'object', + 'description': 'Description of an invoice field', + 'required': [ + 'title', 'value' + ], + 'properties': { + 'title': { + 'type': 'string', + 'description': 'The title of the field being added', + }, + 'value': { + 'type': 'string', + 'description': 'The value of the field being added', + }, + }, + }, + } + + @require_scope(scopes.ORG_ADMIN) + @nickname('listOrgInvoiceFields') + def get(self, orgname): + """ List the invoice fields for the organization. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + organization = model.get_organization(orgname) + if not organization.stripe_id: + raise NotFound() + + return {'fields': get_invoice_fields(organization)[0]} + + abort(403) + + @require_scope(scopes.ORG_ADMIN) + @nickname('createOrgInvoiceField') + @validate_json_request('InvoiceField') + def post(self, orgname): + """ Creates a new invoice field. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + organization = model.get_organization(orgname) + if not organization.stripe_id: + raise NotFound() + + data = request.get_json() + created_field = create_billing_invoice_field(organization, data['title'], data['value']) + return created_field + + abort(403) + + +@resource('/v1/organization//invoice/field/') +@path_param('orgname', 'The name of the organization') +@related_user_resource(UserInvoiceField) +@internal_only +@show_if(features.BILLING) +class OrganizationInvoiceField(ApiResource): + """ Resource for deleting an organization's custom invoice fields. """ + @require_scope(scopes.ORG_ADMIN) + @nickname('deleteOrgInvoiceField') + def delete(self, orgname, field_uuid): + """ Deletes the invoice field for the current user. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + organization = model.get_organization(orgname) + if not organization.stripe_id: + raise NotFound() + + result = delete_billing_invoice_field(organization, field_uuid) + if not result: + abort(404) + + return 'Okay', 201 + + abort(403) diff --git a/static/css/directives/ui/billing-invoices.css b/static/css/directives/ui/billing-invoices.css index 10011237c..299a5e937 100644 --- a/static/css/directives/ui/billing-invoices.css +++ b/static/css/directives/ui/billing-invoices.css @@ -1,3 +1,6 @@ +.billing-invoices-element .fields-menu { + float: right; +} .billing-invoices-element .invoice-title { padding: 6px; @@ -22,4 +25,20 @@ .billing-invoices-element .fa-download { color: #aaa; +} + +.billing-invoices-element .fa-trash-o { + float: right; + margin-top: -3px; + margin-right: -14px !important; + font-size: 14px; + padding: 2px; + padding-left: 6px; + padding-right: 6px; + border-radius: 4px; +} + +.billing-invoices-element .invoice-field { + padding-top: 6px; + padding-bottom: 6px; } \ No newline at end of file diff --git a/static/directives/billing-invoices.html b/static/directives/billing-invoices.html index 802abc358..83200cb64 100644 --- a/static/directives/billing-invoices.html +++ b/static/directives/billing-invoices.html @@ -9,6 +9,30 @@
+ + @@ -39,4 +63,23 @@
Billing Date/Time
+ +
+
+
+ + +
+
+ + +
+
+
+ + diff --git a/static/js/directives/ui/billing-invoices.js b/static/js/directives/ui/billing-invoices.js index ad6696621..1ac3ff562 100644 --- a/static/js/directives/ui/billing-invoices.js +++ b/static/js/directives/ui/billing-invoices.js @@ -15,6 +15,8 @@ angular.module('quay').directive('billingInvoices', function () { }, controller: function($scope, $element, $sce, ApiService) { $scope.loading = false; + $scope.showCreateField = null; + $scope.invoiceFields = []; var update = function() { var hasValidUser = !!$scope.user; @@ -34,11 +36,59 @@ angular.module('quay').directive('billingInvoices', function () { $scope.invoices = []; $scope.loading = false; }); + + ApiService.listInvoiceFields($scope.organization).then(function(resp) { + $scope.invoiceFields = resp.fields || []; + }, function() { + $scope.invoiceFields = []; + }); }; $scope.$watch('organization', update); $scope.$watch('user', update); $scope.$watch('makevisible', update); + + $scope.showCreateField = function() { + $scope.createFieldInfo = { + 'title': '', + 'value': '' + }; + }; + + $scope.askDeleteField = function(field) { + bootbox.confirm('Are you sure you want to delete field ' + field.title + '?', function(r) { + if (r) { + var params = { + 'field_uuid': field.uuid + }; + + ApiService.deleteInvoiceField($scope.organization, null, params).then(function(resp) { + $scope.invoiceFields = $.grep($scope.invoiceFields, function(current) { + return current.uuid != field.uuid + }); + + }, ApiService.errorDisplay('Could not delete custom field')); + } + }); + }; + + $scope.createCustomField = function(title, value, callback) { + var data = { + 'title': title, + 'value': value + }; + + if (!title || !value) { + callback(false); + bootbox.alert('Missing title or value'); + return; + } + + ApiService.createInvoiceField($scope.organization, data).then(function(resp) { + $scope.invoiceFields.push(resp); + callback(true); + }, ApiService.errorDisplay('Could not create custom field')); + }; } }; diff --git a/test/data/test.db b/test/data/test.db index ad88374b6..316bc529c 100644 Binary files a/test/data/test.db and b/test/data/test.db differ diff --git a/test/test_api_security.py b/test/test_api_security.py index 0b9b0f09d..078faecae 100644 --- a/test/test_api_security.py +++ b/test/test_api_security.py @@ -19,7 +19,6 @@ from endpoints.api.build import (FileDropResource, RepositoryBuildStatus, Reposi from endpoints.api.robot import (UserRobotList, OrgRobot, OrgRobotList, UserRobot, RegenerateOrgRobot, RegenerateUserRobot, UserRobotPermissions, OrgRobotPermissions) - from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs, TriggerBuildList, ActivateBuildTrigger, BuildTrigger, BuildTriggerList, BuildTriggerAnalyze, BuildTriggerFieldValues) @@ -33,7 +32,9 @@ from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList from endpoints.api.logs import UserLogs, OrgLogs, RepositoryLogs from endpoints.api.billing import (UserInvoiceList, UserCard, UserPlan, ListPlans, - OrgnaizationInvoiceList, OrganizationCard, OrganizationPlan) + OrgnaizationInvoiceList, OrganizationCard, OrganizationPlan, + UserInvoiceFieldList, UserInvoiceField, + OrganizationInvoiceFieldList, OrganizationInvoiceField) from endpoints.api.discovery import DiscoveryResource from endpoints.api.organization import (OrganizationList, OrganizationMember, OrgPrivateRepositories, OrgnaizationMemberList, @@ -43,7 +44,6 @@ from endpoints.api.organization import (OrganizationList, OrganizationMember, from endpoints.api.repository import RepositoryList, RepositoryVisibility, Repository from endpoints.api.permission import (RepositoryUserPermission, RepositoryTeamPermission, RepositoryTeamPermissionList, RepositoryUserPermissionList) - from endpoints.api.superuser import (SuperUserLogs, SuperUserList, SuperUserManagement, SuperUserSendRecoveryEmail, UsageInformation, SuperUserOrganizationManagement, SuperUserOrganizationList) @@ -4058,6 +4058,104 @@ class TestSuperUserManagement(ApiTestCase): self._run_test('DELETE', 204, 'devtable', None) +class TestUserInvoiceFieldList(ApiTestCase): + def setUp(self): + ApiTestCase.setUp(self) + self._set_url(UserInvoiceFieldList) + + def test_get_anonymous(self): + self._run_test('GET', 401, None, None) + + def test_get_freshuser(self): + self._run_test('GET', 404, 'freshuser', None) + + def test_get_reader(self): + self._run_test('GET', 404, 'reader', None) + + def test_get_devtable(self): + self._run_test('GET', 200, 'devtable', None) + + + def test_post_anonymous(self): + self._run_test('POST', 401, None, None) + + def test_post_freshuser(self): + self._run_test('POST', 404, 'freshuser', dict(title='foo', value='bar')) + + def test_post_reader(self): + self._run_test('POST', 404, 'reader', dict(title='foo', value='bar')) + + def test_post_devtable(self): + self._run_test('POST', 200, 'devtable', dict(title='foo', value='bar')) + + +class TestUserInvoiceField(ApiTestCase): + def setUp(self): + ApiTestCase.setUp(self) + self._set_url(UserInvoiceField, field_uuid='1234') + + def test_get_anonymous(self): + self._run_test('DELETE', 401, None, None) + + def test_get_freshuser(self): + self._run_test('DELETE', 404, 'freshuser', None) + + def test_get_reader(self): + self._run_test('DELETE', 404, 'reader', None) + + def test_get_devtable(self): + self._run_test('DELETE', 201, 'devtable', None) + + + +class TestOrganizationInvoiceFieldList(ApiTestCase): + def setUp(self): + ApiTestCase.setUp(self) + self._set_url(OrganizationInvoiceFieldList, orgname='buynlarge') + + def test_get_anonymous(self): + self._run_test('GET', 403, None, None) + + def test_get_freshuser(self): + self._run_test('GET', 403, 'freshuser', None) + + def test_get_reader(self): + self._run_test('GET', 403, 'reader', None) + + def test_get_devtable(self): + self._run_test('GET', 200, 'devtable', None) + + + def test_post_anonymous(self): + self._run_test('POST', 403, None, dict(title='foo', value='bar')) + + def test_post_freshuser(self): + self._run_test('POST', 403, 'freshuser', dict(title='foo', value='bar')) + + def test_post_reader(self): + self._run_test('POST', 403, 'reader', dict(title='foo', value='bar')) + + def test_post_devtable(self): + self._run_test('POST', 200, 'devtable', dict(title='foo', value='bar')) + + +class TestOrganizationInvoiceField(ApiTestCase): + def setUp(self): + ApiTestCase.setUp(self) + self._set_url(OrganizationInvoiceField, orgname='buynlarge', field_uuid='1234') + + def test_get_anonymous(self): + self._run_test('DELETE', 403, None, None) + + def test_get_freshuser(self): + self._run_test('DELETE', 403, 'freshuser', None) + + def test_get_reader(self): + self._run_test('DELETE', 403, 'reader', None) + + def test_get_devtable(self): + self._run_test('DELETE', 201, 'devtable', None) + if __name__ == '__main__': unittest.main() diff --git a/util/invoice.py b/util/invoice.py index 1c0650e18..b6a81cca5 100644 --- a/util/invoice.py +++ b/util/invoice.py @@ -25,6 +25,8 @@ def renderInvoiceToPdf(invoice, user): def renderInvoiceToHtml(invoice, user): """ Renders a nice HTML display for the given invoice. """ + from endpoints.api.billing import get_invoice_fields + def get_price(price): if not price: return '$0' @@ -44,7 +46,8 @@ def renderInvoiceToHtml(invoice, user): 'invoice': invoice, 'invoice_date': format_date(invoice.date), 'getPrice': get_price, - 'getRange': get_range + 'getRange': get_range, + 'custom_fields': get_invoice_fields(user)[0], } template = env.get_template('invoice.tmpl') diff --git a/util/invoice.tmpl b/util/invoice.tmpl index fac657ef5..68228886f 100644 --- a/util/invoice.tmpl +++ b/util/invoice.tmpl @@ -19,6 +19,12 @@ + {% for custom_field in custom_fields %} + + + + + {% endfor %}
Date:{{ invoice_date }}
Invoice #:{{ invoice.id }}
*{{ custom_field['title'] }}:{{ custom_field['value'] }}
@@ -38,8 +44,8 @@ {{ getPrice(line.amount) }} {%- endfor -%} - - + + @@ -54,7 +60,7 @@ - +
We thank you for your continued business!