Skip to content

Commit f8ca063

Browse files
authored
Merge pull request #96 from cloudblue/lite-28889-billing-request-lookup-transformation
Add billing request transformation
2 parents 100bdd2 + d9e7738 commit f8ca063

15 files changed

Lines changed: 2833 additions & 0 deletions

File tree

connect_transformations/lookup_billing_request/__init__.py

Whitespace-only changes.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from connect_transformations.exceptions import BaseTransformationException
2+
3+
4+
class BillingRequestLookupError(BaseTransformationException):
5+
"""Exception that happens when the billing request lookup fails"""
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (c) 2023, CloudBlue LLC
4+
# All rights reserved.
5+
#
6+
from typing import Dict
7+
8+
from connect.client import AsyncConnectClient
9+
from connect.eaas.core.decorators import router, transformation
10+
from connect.eaas.core.inject.asynchronous import get_installation_client
11+
from connect.eaas.core.responses import RowTransformationResponse
12+
from fastapi import Depends
13+
14+
from connect_transformations.constants import SEPARATOR
15+
from connect_transformations.lookup_billing_request.exceptions import BillingRequestLookupError
16+
from connect_transformations.lookup_billing_request.models import (
17+
Configuration,
18+
SubscriptionParameter,
19+
)
20+
from connect_transformations.lookup_billing_request.utils import validate_lookup_billing_request
21+
from connect_transformations.models import Error, ValidationResult
22+
from connect_transformations.utils import deep_itemgetter, is_input_column_nullable
23+
24+
25+
class LookupBillingRequestTransformationMixin:
26+
@transformation(
27+
name='Lookup CloudBlue Billing request data',
28+
description=(
29+
'This transformation function allows you to get the CloudBlue billing'
30+
' request data by the subscription ID or parameter value and sets'
31+
' item by ID or MPN.'
32+
),
33+
edit_dialog_ui='/static/transformations/lookup_billing_request.html',
34+
)
35+
async def lookup_billing_request(
36+
self,
37+
row: Dict,
38+
):
39+
output_columns = self.billing_settings.get('output_config')
40+
value = row[self.billing_settings['parameter_column']]
41+
item_id = row.get(self.billing_settings['item_column'])
42+
43+
if is_input_column_nullable(
44+
self.transformation_request['transformation']['columns']['input'],
45+
self.billing_settings['parameter_column'],
46+
) and not value:
47+
return RowTransformationResponse.skip()
48+
49+
lookup = {}
50+
if self.billing_settings.get('asset_type'):
51+
lookup[f'asset.{self.billing_settings["asset_type"]}'] = row[
52+
self.billing_settings['asset_column']
53+
]
54+
55+
lookup['asset.params.name'] = self.billing_settings['parameter']['name']
56+
lookup['asset.params.value'] = value
57+
58+
try:
59+
request = await self.get_billing_request(lookup)
60+
except Exception as e:
61+
return RowTransformationResponse.fail(output=str(e))
62+
63+
if not request:
64+
return RowTransformationResponse.skip()
65+
66+
return RowTransformationResponse.done(
67+
self.extract_row_from_billing(request, output_columns, item_id),
68+
)
69+
70+
def extract_row_from_billing(self, request, output_columns, item_id):
71+
row = {}
72+
73+
item_attrs = []
74+
75+
for col_name, col_config in output_columns.items():
76+
value = None
77+
attr = col_config['attribute']
78+
if attr == 'asset.parameter.value':
79+
param_name = col_config['parameter_name']
80+
for param_data in request['asset']['params']:
81+
if param_data['name'] == param_name:
82+
value = param_data['value']
83+
break
84+
elif attr.startswith('items.'):
85+
item_attr = attr.split('.')[-1]
86+
item_attrs.append((col_name, item_attr))
87+
value = ''
88+
else:
89+
value = deep_itemgetter(request, attr)
90+
91+
row[col_name] = value
92+
93+
if item_attrs:
94+
self.extract_item_attrs_from_billing(item_attrs, row, request, item_id)
95+
96+
return row
97+
98+
def extract_item_attrs_from_billing(self, item_attrs, row, request, item_id_value):
99+
item_id = self.billing_settings['item']['id']
100+
for item in request['items']:
101+
if item_id == 'all' or item.get(item_id) == item_id_value:
102+
for col_name, item_attr in item_attrs:
103+
item_value = str(item.get(item_attr, ''))
104+
row[col_name] += f'{SEPARATOR}{item_value}' if row[col_name] else item_value
105+
106+
async def get_billing_request(self, lookup):
107+
k = 'billing-'
108+
for key, value in lookup.items():
109+
k = k + f'{key}-{value}'
110+
try:
111+
return self.cache_get(k)
112+
except KeyError:
113+
pass
114+
115+
result = await self.retrieve_billing_requests(lookup)
116+
117+
await self.acache_put(k, result)
118+
return result
119+
120+
async def retrieve_billing_requests(self, lookup):
121+
batch_context = self.transformation_request['batch']['context']
122+
period_end = batch_context.get('period', {}).get('end')
123+
additional_filters = {}
124+
if period_end:
125+
additional_filters['events.created.at__lt'] = period_end
126+
127+
requests = self.installation_client('subscriptions').requests.filter(
128+
**lookup,
129+
**additional_filters,
130+
).order_by('-events.created.at')
131+
requests = [r async for r in requests]
132+
133+
result = None
134+
135+
for item in requests:
136+
if result is None:
137+
result = item
138+
elif self.billing_settings.get('action_if_multiple') == 'leave_empty':
139+
return
140+
elif self.billing_settings.get('action_if_multiple') == 'use_most_actual':
141+
return result
142+
else:
143+
raise BillingRequestLookupError(f'Many results found for the filter {lookup}')
144+
145+
if result:
146+
return result
147+
148+
if self.billing_settings.get('action_if_not_found') == 'fail':
149+
raise BillingRequestLookupError(f'No result found for the filter {lookup}')
150+
151+
@property
152+
def billing_settings(self):
153+
return self.transformation_request['transformation']['settings']
154+
155+
156+
class LookupBillingRequestWebAppMixin:
157+
158+
@router.post(
159+
'/lookup_billing_request/validate',
160+
summary='Validate lookup billing request settings',
161+
response_model=ValidationResult,
162+
responses={
163+
400: {'model': Error},
164+
},
165+
)
166+
def validate_lookup_billing_request_settings(
167+
self,
168+
data: Configuration,
169+
):
170+
return validate_lookup_billing_request(data)
171+
172+
@router.get(
173+
'/lookup_billing_request/parameters',
174+
summary='Return available parameters names',
175+
)
176+
async def get_billing_parameters(
177+
self,
178+
product_id: str,
179+
client: AsyncConnectClient = Depends(get_installation_client),
180+
):
181+
return [
182+
SubscriptionParameter(
183+
id=parameter['id'],
184+
name=parameter['name'],
185+
)
186+
async for parameter in client.products[product_id].parameters.all()
187+
]
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from typing import Dict, Optional
2+
3+
from pydantic import BaseModel
4+
5+
from connect_transformations.models import Columns
6+
7+
8+
class SubscriptionParameter(BaseModel):
9+
id: str
10+
name: str
11+
12+
13+
class Parameter(BaseModel):
14+
id: Optional[str]
15+
name: Optional[str]
16+
17+
18+
class OutputConfig(BaseModel):
19+
attribute: Optional[str]
20+
parameter: Optional[str]
21+
22+
23+
class Settings(BaseModel):
24+
asset_type: Optional[str]
25+
asset_column: Optional[str]
26+
parameter: Optional[Parameter]
27+
parameter_column: Optional[str]
28+
item: Optional[Parameter]
29+
item_column: Optional[str]
30+
action_if_not_found: Optional[str]
31+
action_if_multiple: Optional[str]
32+
output_config: Optional[Dict[str, OutputConfig]]
33+
34+
35+
class Configuration(BaseModel):
36+
settings: Optional[Settings]
37+
columns: Optional[Columns]
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (c) 2023, CloudBlue LLC
4+
# All rights reserved.
5+
#
6+
from connect_transformations.utils import (
7+
build_error_response,
8+
does_not_contain_required_keys,
9+
has_invalid_basic_structure,
10+
)
11+
12+
13+
ASSET_LOOKUP = {
14+
'external_id': 'CloudBlue Subscription External ID',
15+
'id': 'CloudBlue Subscription ID',
16+
}
17+
18+
19+
NOT_FOUND_CHOICES = ['leave_empty', 'fail']
20+
MULTIPLE_CHOICES = ['leave_empty', 'fail', 'use_most_actual']
21+
22+
23+
def validate_lookup_billing_request(data): # noqa: CCR001
24+
data = data.dict(by_alias=True)
25+
26+
if has_invalid_basic_structure(data):
27+
return build_error_response('Invalid input data')
28+
29+
if (
30+
does_not_contain_required_keys(
31+
data['settings'],
32+
[
33+
'parameter',
34+
'parameter_column',
35+
'action_if_not_found',
36+
'action_if_multiple',
37+
'output_config',
38+
],
39+
)
40+
):
41+
return build_error_response(
42+
'The settings must have `parameter`, `parameter_column`, `item`, `item_column`, '
43+
'`action_if_not_found` and `action_if_multiple` fields',
44+
)
45+
46+
values = ASSET_LOOKUP.keys()
47+
if data['settings'].get('asset_type') and data['settings'].get('asset_type') not in values:
48+
return build_error_response(
49+
f'The settings `asset_type` allowed values {", ".join(values)}',
50+
)
51+
52+
if data['settings']['action_if_not_found'] not in NOT_FOUND_CHOICES:
53+
return build_error_response(
54+
f'The settings `action_if_not_found` allowed values {", ".join(NOT_FOUND_CHOICES)}',
55+
)
56+
57+
if data['settings']['action_if_multiple'] not in MULTIPLE_CHOICES:
58+
return build_error_response(
59+
f'The settings `action_if_multiple` allowed values {", ".join(MULTIPLE_CHOICES)}',
60+
)
61+
62+
if (
63+
does_not_contain_required_keys(data['settings'], ['parameter'])
64+
or not isinstance(data['settings']['parameter'], dict)
65+
or does_not_contain_required_keys(data['settings']['parameter'], ['id', 'name'])
66+
):
67+
return build_error_response('The settings must have `parameter` with `id` and `name`')
68+
69+
if (
70+
does_not_contain_required_keys(data['settings'], ['item'])
71+
or not isinstance(data['settings']['item'], dict)
72+
or does_not_contain_required_keys(data['settings']['item'], ['id', 'name'])
73+
):
74+
return build_error_response('The settings must have `item` with `id` and `name`')
75+
76+
input_columns = data['columns']['input']
77+
available_input_columns = [c['name'] for c in input_columns]
78+
columns_names = [data['settings']['parameter_column'], data['settings']['item_column']]
79+
if data['settings'].get('asset_column'):
80+
columns_names.append(data['settings']['asset_column'])
81+
for column_name in columns_names:
82+
if column_name not in available_input_columns:
83+
return build_error_response(
84+
f'The settings contains an invalid column name {column_name}'
85+
' that does not exist on columns.input',
86+
)
87+
88+
output_columns = data['columns']['output']
89+
output_columns_config = data['settings']['output_config']
90+
91+
for out_column in output_columns:
92+
column_name = out_column['name']
93+
column_config = output_columns_config.get(column_name)
94+
if not column_config:
95+
return build_error_response(
96+
'The settings `output_config` does not contain '
97+
f'settings for the column {column_name}.',
98+
)
99+
100+
overview = 'Match by parameter "' + data['settings']['parameter']['name'] + '"\n'
101+
if data['settings'].get('asset_type'):
102+
overview += 'And "' + ASSET_LOOKUP[data['settings']['asset_type']] + '"\n'
103+
overview += (
104+
'If not found = '
105+
+ data['settings']['action_if_not_found'].replace('_', ' ').capitalize()
106+
+ '\n'
107+
)
108+
overview += (
109+
'If multiple found = '
110+
+ data['settings']['action_if_multiple'].replace('_', ' ').capitalize()
111+
+ '\n'
112+
)
113+
114+
total = len(data["columns"]["output"])
115+
overview += f'And populate {total} column{"s" if total > 1 else ""}'
116+
117+
return {
118+
'overview': overview,
119+
}

connect_transformations/static/transformations/lookup_billing_request.421df6826da3f2351526.css

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)