[PMSH] Create subscription API changes with IPv4, IPv6 updates 99/123099/4
authorSagarS <sagar.shetty@est.tech>
Wed, 4 Aug 2021 11:59:55 +0000 (12:59 +0100)
committerSagarS <sagar.shetty@est.tech>
Thu, 12 Aug 2021 15:28:33 +0000 (16:28 +0100)
Issue-ID: DCAEGEN2-2819
Signed-off-by: SagarS <sagar.shetty@est.tech>
Change-Id: Id0db2ae3f57786e0eeea70589644bf1f7fa92de8

24 files changed:
components/pm-subscription-handler/Changelog.md
components/pm-subscription-handler/pmsh_service/mod/__init__.py
components/pm-subscription-handler/pmsh_service/mod/api/controllers/subscription_controller.py [new file with mode: 0644]
components/pm-subscription-handler/pmsh_service/mod/api/custom_exception.py [new file with mode: 0644]
components/pm-subscription-handler/pmsh_service/mod/api/db_models.py
components/pm-subscription-handler/pmsh_service/mod/api/pmsh_swagger.yml
components/pm-subscription-handler/pmsh_service/mod/api/services/measurement_group_service.py [new file with mode: 0644]
components/pm-subscription-handler/pmsh_service/mod/api/services/nf_service.py [new file with mode: 0644]
components/pm-subscription-handler/pmsh_service/mod/api/services/subscription_service.py [new file with mode: 0644]
components/pm-subscription-handler/pmsh_service/mod/network_function.py
components/pm-subscription-handler/pmsh_service/mod/network_function_filter.py [new file with mode: 0644]
components/pm-subscription-handler/pmsh_service/mod/subscription.py
components/pm-subscription-handler/pom.xml
components/pm-subscription-handler/setup.py
components/pm-subscription-handler/tests/controllers/test_subscription_controller.py [new file with mode: 0644]
components/pm-subscription-handler/tests/data/create_subscription_request.json [new file with mode: 0644]
components/pm-subscription-handler/tests/data/pm_subscription_event.json
components/pm-subscription-handler/tests/services/test_measurement_group_service.py [new file with mode: 0644]
components/pm-subscription-handler/tests/services/test_nf_service.py [new file with mode: 0644]
components/pm-subscription-handler/tests/services/test_subscription_service.py [new file with mode: 0644]
components/pm-subscription-handler/tests/test_network_function.py
components/pm-subscription-handler/tests/test_subscription.py
components/pm-subscription-handler/tests/test_subscription_handler.py
components/pm-subscription-handler/version.properties

index 58d40cc..421aed3 100755 (executable)
@@ -5,6 +5,9 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](http://keepachangelog.com/)
 and this project adheres to [Semantic Versioning](http://semver.org/).
 
+## [1.4.0]
+### Changed
+* Enhanced API for PMSH subscription management (DCAEGEN2-2802)
 
 ## [1.3.2]
 ### Changed
index 5f78ca1..f7455fc 100644 (file)
@@ -1,5 +1,5 @@
 # ============LICENSE_START===================================================
-#  Copyright (C) 2019-2021 Nordix Foundation.
+#  Copyright (C) 2020-2021 Nordix Foundation.
 # ============================================================================
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -42,6 +42,7 @@ def _get_app():
 
 def launch_api_server(app_config):
     connex_app = _get_app()
+    connex_app.app.config['app_config'] = app_config
     connex_app.add_api('api/pmsh_swagger.yml')
     if app_config.enable_tls:
         logger.info('Launching secure http API server')
diff --git a/components/pm-subscription-handler/pmsh_service/mod/api/controllers/subscription_controller.py b/components/pm-subscription-handler/pmsh_service/mod/api/controllers/subscription_controller.py
new file mode 100644 (file)
index 0000000..c824f62
--- /dev/null
@@ -0,0 +1,46 @@
+# ============LICENSE_START===================================================
+#  Copyright (C) 2020-2021 Nordix Foundation.
+# ============================================================================
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# ============LICENSE_END=====================================================
+
+from http import HTTPStatus
+from mod.api.services import subscription_service
+from connexion import NoContent
+from mod.api.custom_exception import InvalidDataException, DuplicateDataException
+
+
+def post_subscription(body):
+    """
+        Creates a subscription
+
+        Args:
+            body (dict): subscription request body to save.
+
+        Returns:
+            Success : NoContent, 201
+            Invalid Data : List of Invalid messages, 400
+
+        Raises:
+            Error: If anything fails in the server.
+    """
+    response = NoContent, HTTPStatus.CREATED.value
+    try:
+        subscription_service.create_subscription(body['subscription'])
+    except DuplicateDataException as exception:
+        response = exception.duplicate_fields_info, HTTPStatus.CONFLICT.value
+    except InvalidDataException as exception:
+        response = exception.invalid_messages, HTTPStatus.BAD_REQUEST.value
+    return response
diff --git a/components/pm-subscription-handler/pmsh_service/mod/api/custom_exception.py b/components/pm-subscription-handler/pmsh_service/mod/api/custom_exception.py
new file mode 100644 (file)
index 0000000..f893c94
--- /dev/null
@@ -0,0 +1,38 @@
+# ============LICENSE_START===================================================
+#  Copyright (C) 2020-2021 Nordix Foundation.
+# ============================================================================
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# ============LICENSE_END=====================================================
+
+class InvalidDataException(Exception):
+    """Exception raised for invalid inputs.
+
+    Attributes:
+        message -- detail on invalid fields
+    """
+
+    def __init__(self, invalid_messages):
+        self.invalid_messages = invalid_messages
+
+
+class DuplicateDataException(Exception):
+    """Exception raised for invalid inputs.
+
+    Attributes:
+        message -- detail on duplicate fields
+    """
+
+    def __init__(self, duplicate_fields_info):
+        self.duplicate_fields_info = duplicate_fields_info
index a9dd6ef..ce4081b 100755 (executable)
@@ -1,5 +1,5 @@
 # ============LICENSE_START===================================================
-#  Copyright (C) 2019-2020 Nordix Foundation.
+#  Copyright (C) 2020-2021 Nordix Foundation.
 # ============================================================================
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -16,7 +16,7 @@
 # SPDX-License-Identifier: Apache-2.0
 # ============LICENSE_END=====================================================
 
-from sqlalchemy import Column, Integer, String, ForeignKey
+from sqlalchemy import Column, Integer, String, ForeignKey, JSON
 from sqlalchemy.orm import relationship
 
 from mod import db
@@ -33,6 +33,16 @@ class SubscriptionModel(db.Model):
         cascade='all, delete-orphan',
         backref='subscription')
 
+    network_filter = relationship(
+        'NetworkFunctionFilterModel',
+        cascade='all, delete-orphan',
+        backref='subscription')
+
+    measurement_groups = relationship(
+        'MeasurementGroupModel',
+        cascade='all, delete-orphan',
+        backref='subscription')
+
     def __init__(self, subscription_name, status):
         self.subscription_name = subscription_name
         self.status = status
@@ -57,7 +67,8 @@ class NetworkFunctionModel(db.Model):
     __tablename__ = 'network_functions'
     id = Column(Integer, primary_key=True, autoincrement=True)
     nf_name = Column(String(100), unique=True)
-    ip_address = Column(String(50))
+    ipv4_address = Column(String(50))
+    ipv6_address = Column(String(50))
     model_invariant_id = Column(String(100))
     model_version_id = Column(String(100))
     model_name = Column(String(100))
@@ -70,11 +81,12 @@ class NetworkFunctionModel(db.Model):
         cascade='all, delete-orphan',
         backref='nf')
 
-    def __init__(self, nf_name, ip_address, model_invariant_id,
+    def __init__(self, nf_name, ipv4_address, ipv6_address, model_invariant_id,
                  model_version_id, model_name, sdnc_model_name,
                  sdnc_model_version, retry_count=0):
         self.nf_name = nf_name
-        self.ip_address = ip_address
+        self.ipv4_address = ipv4_address
+        self.ipv6_address = ipv6_address
         self.model_invariant_id = model_invariant_id
         self.model_version_id = model_version_id
         self.model_name = model_name
@@ -90,7 +102,8 @@ class NetworkFunctionModel(db.Model):
         return NetworkFunction(sdnc_model_name=self.sdnc_model_name,
                                sdnc_model_version=self.sdnc_model_version,
                                **{'nf_name': self.nf_name,
-                                  'ip_address': self.ip_address,
+                                  'ipv4_address': self.ipv4_address,
+                                  'ipv6_address': self.ipv6_address,
                                   'model_invariant_id': self.model_invariant_id,
                                   'model_version_id': self.model_version_id})
 
@@ -129,10 +142,117 @@ class NfSubRelationalModel(db.Model):
             NetworkFunctionModel.nf_name == self.nf_name).one_or_none()
         db.session.remove()
         return {'nf_name': self.nf_name,
-                'ip_address': nf.ip_address,
+                'ipv4_address': nf.ipv4_address,
+                'ipv6_address': nf.ipv6_address,
                 'nf_sub_status': self.nf_sub_status,
                 'model_invariant_id': nf.model_invariant_id,
                 'model_version_id': nf.model_version_id,
                 'model_name': nf.model_name,
                 'sdnc_model_name': nf.sdnc_model_name,
                 'sdnc_model_version': nf.sdnc_model_version}
+
+
+class NetworkFunctionFilterModel(db.Model):
+    __tablename__ = 'nf_filter'
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    subscription_name = Column(
+        String,
+        ForeignKey(SubscriptionModel.subscription_name, ondelete='cascade', onupdate='cascade'),
+        unique=True
+    )
+    nf_names = Column(String(100))
+    model_invariant_ids = Column(String(100))
+    model_version_ids = Column(String(100))
+    model_names = Column(String(100))
+
+    def __init__(self, subscription_name, nf_names, model_invariant_ids, model_version_ids,
+                 model_names):
+        self.subscription_name = subscription_name
+        self.nf_names = nf_names
+        self.model_invariant_ids = model_invariant_ids
+        self.model_version_ids = model_version_ids
+        self.model_names = model_names
+
+    def __repr__(self):
+        return f'subscription_name: {self.subscription_name}, ' \
+            f'nf_names: {self.nf_names}, model_invariant_ids: {self.model_invariant_ids}' \
+               f'model_version_ids: {self.model_version_ids}, model_names: {self.model_names}'
+
+    def serialize(self):
+        return {'subscription_name': self.subscription_name, 'nf_names': self.nf_names,
+                'model_invariant_ids': self.model_invariant_ids,
+                'model_version_ids': self.model_version_ids, 'model_names': self.model_names}
+
+
+class MeasurementGroupModel(db.Model):
+    __tablename__ = 'measurement_group'
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    subscription_name = Column(
+        String,
+        ForeignKey(SubscriptionModel.subscription_name, ondelete='cascade', onupdate='cascade')
+    )
+    measurement_group_name = Column(String(100), unique=True)
+    administrative_state = Column(String(20))
+    file_based_gp = Column(Integer)
+    file_location = Column(String(100))
+    measurement_type = Column(JSON)
+    managed_object_dns_basic = Column(JSON)
+
+    def __init__(self, subscription_name, measurement_group_name,
+                 administrative_state, file_based_gp, file_location,
+                 measurement_type, managed_object_dns_basic):
+        self.subscription_name = subscription_name
+        self.measurement_group_name = measurement_group_name
+        self.administrative_state = administrative_state
+        self.file_based_gp = file_based_gp
+        self.file_location = file_location
+        self.measurement_type = measurement_type
+        self.managed_object_dns_basic = managed_object_dns_basic
+
+    def __repr__(self):
+        return f'subscription_name: {self.subscription_name}, ' \
+               f'measurement_group_name: {self.measurement_group_name},' \
+               f'administrative_state: {self.administrative_state},' \
+               f'file_based_gp: {self.file_based_gp},' \
+               f'file_location: {self.file_location},' \
+               f'measurement_type: {self.measurement_type}' \
+               f'managed_object_dns_basic: {self.managed_object_dns_basic}'
+
+    def serialize(self):
+        return {'subscription_name': self.subscription_name,
+                'measurement_group_name': self.measurement_group_name,
+                'administrative_state': self.administrative_state,
+                'file_based_gp': self.file_based_gp,
+                'file_location': self.file_location,
+                'measurement_type': self.measurement_type,
+                'managed_object_dns_basic': self.managed_object_dns_basic}
+
+
+class NfMeasureGroupRelationalModel(db.Model):
+    __tablename__ = 'nf_to_measure_grp_rel'
+    __mapper_args__ = {
+        'confirm_deleted_rows': False
+    }
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    measurement_grp_name = Column(
+        String,
+        ForeignKey(MeasurementGroupModel.measurement_group_name, ondelete='cascade',
+                   onupdate='cascade')
+    )
+    nf_name = Column(
+        String,
+        ForeignKey(NetworkFunctionModel.nf_name, ondelete='cascade', onupdate='cascade')
+    )
+    nf_measure_grp_status = Column(String(20))
+    retry_count = Column(Integer)
+
+    def __init__(self, measurement_grp_name, nf_name, nf_measure_grp_status=None,
+                 retry_count=0):
+        self.measurement_grp_name = measurement_grp_name
+        self.nf_name = nf_name
+        self.nf_measure_grp_status = nf_measure_grp_status
+        self.retry_count = retry_count
+
+    def __repr__(self):
+        return f'measurement_grp_name: {self.measurement_grp_name}, ' \
+            f'nf_name: {self.nf_name}, nf_measure_grp_status: {self.nf_measure_grp_status}'
index 58e6a78..3936497 100644 (file)
@@ -1,5 +1,5 @@
 # ============LICENSE_START=======================================================
-#  Copyright (C) 2020 Nordix Foundation.
+#  Copyright (C) 2021 Nordix Foundation.
 # ================================================================================
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -87,3 +87,108 @@ paths:
                 enum: [healthy, unhealthy]
         503:
           description: the pmsh service is unavailable
+
+  /subscription:
+    post:
+      tags:
+        - "Subscriptions"
+      description: >-
+        Create a PM Subscription
+      operationId: mod.api.controllers.subscription_controller.post_subscription
+      consumes:
+        - "application/json"
+      produces:
+        - "application/json"
+      parameters:
+        - in: "body"
+          name: "body"
+          description: "Subscription object to be created"
+          required: true
+          schema:
+            type: object
+            properties:
+              subscription:
+                type: object
+                properties:
+                  subscriptionName:
+                    type: string
+                  nfFilter:
+                    type: object
+                    properties:
+                      nfNames:
+                        type: array
+                        items:
+                          type: string
+                      modelInvariantIDs:
+                        type: array
+                        items:
+                          type: string
+                      modelVersionIDs:
+                        type: array
+                        items:
+                          type: string
+                      modelNames:
+                        type: array
+                        items:
+                          type: string
+                    additionalProperties: false
+                  measurementGroups:
+                    type: array
+                    minItems: 1
+                    items:
+                      type: object
+                      properties:
+                        measurementGroup:
+                          type: object
+                          properties:
+                            administrativeState:
+                              allOf:
+                                - type: string
+                                - enum:
+                                    - UNLOCKED
+                                    - LOCKED
+                                    - FILTERING
+                            fileBasedGP:
+                              type: integer
+                            fileLocation:
+                              type: string
+                            measurementTypes:
+                              type: array
+                              minItems: 1
+                              items:
+                                type: object
+                                properties:
+                                  measurementType:
+                                    type: string
+                                required:
+                                  - measurementType
+                            managedObjectDNsBasic:
+                              type: array
+                              minItems: 1
+                              items:
+                                type: object
+                                properties:
+                                  DN:
+                                    type: string
+                                required:
+                                  - DN
+                          required:
+                            - administrativeState
+                            - fileBasedGP
+                            - fileLocation
+                            - measurementTypes
+                            - managedObjectDNsBasic
+                      required:
+                        - measurementGroup
+                required:
+                  - subscriptionName
+                  - nfFilter
+                  - measurementGroups
+      responses:
+        201:
+          description: successfully created PM Subscription
+        409:
+          description: Duplicate data
+        400:
+          description: Invalid input
+
diff --git a/components/pm-subscription-handler/pmsh_service/mod/api/services/measurement_group_service.py b/components/pm-subscription-handler/pmsh_service/mod/api/services/measurement_group_service.py
new file mode 100644 (file)
index 0000000..a4ba4c4
--- /dev/null
@@ -0,0 +1,100 @@
+# ============LICENSE_START===================================================
+#  Copyright (C) 2020-2021 Nordix Foundation.
+# ============================================================================
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# ============LICENSE_END=====================================================
+
+from mod.api.db_models import MeasurementGroupModel, NfMeasureGroupRelationalModel
+from mod import db
+from mod.subscription import SubNfState
+from mod.api.services import nf_service
+from flask import current_app
+
+
+def check_duplicate_fields(measurement_group, subscription_name):
+    """
+      validates the measurement group content if already present
+      and if present raises an exception to indicate duplicate request
+
+      Args:
+          measurement_group (dict): measurement group to validate
+          subscription_name (string): subscription name to check
+      Returns:
+          invalid_messages: list of duplicate data details.
+      """
+    duplicate_field_details = []
+    existing_measurement_grp = (MeasurementGroupModel.query.filter(
+        MeasurementGroupModel.measurement_group_name == measurement_group['measurementGroupName'],
+        MeasurementGroupModel.subscription_name == subscription_name)
+        .one_or_none())
+    if existing_measurement_grp is not None:
+        duplicate_field_details.append(f'Measurement Group: '
+                                       f'{measurement_group["measurementGroupName"]} '
+                                       f' for Subscription: {subscription_name} '
+                                       f'already exists.')
+    return duplicate_field_details
+
+
+def save_measurement_group(measurement_group, subscription_name):
+    """
+    Saves the measurement_group data request
+
+    Args:
+        measurement_group (dict) : measurement group to save
+        subscription_name (string) : subscription name to associate with measurement group.
+    """
+    new_measurement_group = MeasurementGroupModel(
+        subscription_name=subscription_name,
+        measurement_group_name=measurement_group['measurementGroupName'],
+        administrative_state=measurement_group['administrativeState'],
+        file_based_gp=measurement_group['fileBasedGP'],
+        file_location=measurement_group['fileLocation'],
+        measurement_type=measurement_group['measurementTypes'],
+        managed_object_dns_basic=measurement_group['managedObjectDNsBasic'])
+    db.session.add(new_measurement_group)
+
+
+def apply_nf(nf, measurement_group):
+    """
+        Associate and saves the measurement group with Network function
+
+        Args:
+            nf (dict): list of filtered network functions to save.
+            measurement_group (string): measurement group to associate with nf
+    """
+    new_nf_measure_grp_rel = NfMeasureGroupRelationalModel(
+        measurement_grp_name=measurement_group['measurementGroupName'],
+        nf_name=nf.nf_name,
+        nf_measure_grp_status=SubNfState.PENDING_CREATE.value
+    )
+    db.session.add(new_nf_measure_grp_rel)
+
+
+def publish_measurement_group(subscription_name, measurement_group, nfs):
+    """
+        Publishes an event for measurement groups against nfs to MR
+
+       Args:
+           subscription_name (string): subscription name to publish against nfs
+           measurement_group (dict): measurement group to publish
+           nfs (dict): list of filtered network functions to publish.
+   """
+    app_conf = current_app.config['app_config']
+    event_body = {"subscriptionName": subscription_name,
+                  "measurementGroup": measurement_group,
+                  "networkFunctions": [nf_service.create_nf_event_body(nf, 'CREATE')
+                                       for nf in nfs]}
+    policy_mr_pub = app_conf.get_mr_pub('policy_pm_publisher')
+    policy_mr_pub.publish_to_topic(event_body)
diff --git a/components/pm-subscription-handler/pmsh_service/mod/api/services/nf_service.py b/components/pm-subscription-handler/pmsh_service/mod/api/services/nf_service.py
new file mode 100644 (file)
index 0000000..6993630
--- /dev/null
@@ -0,0 +1,111 @@
+# ============LICENSE_START===================================================
+#  Copyright (C) 2020-2021 Nordix Foundation.
+# ============================================================================
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# ============LICENSE_END=====================================================
+from mod import db, aai_client
+from mod.api.db_models import NetworkFunctionFilterModel, NetworkFunctionModel
+from flask import current_app
+from mod.network_function_filter import NetworkFunctionFilter
+
+
+def save_nf_filter(nf_filter, subscription_name):
+    """
+    Saves the nf_filter data request
+
+    Args:
+        nf_filter (dict) : network unction filter to save
+        subscription_name (string) : subscription name to associate with nf filter.
+    """
+    new_filter = NetworkFunctionFilterModel(subscription_name=subscription_name,
+                                            nf_names=nf_filter['nfNames'],
+                                            model_invariant_ids=nf_filter['modelInvariantIDs'],
+                                            model_version_ids=nf_filter['modelVersionIDs'],
+                                            model_names=nf_filter['modelNames'])
+    db.session.add(new_filter)
+
+
+def capture_filtered_nfs(nf_filter):
+    """
+    Retrieves network functions from AAI client and
+    returns a list of filtered NetworkFunctions using the Filter
+
+    Args:
+        nf_filter (dict): the nf json data from AAI.
+    Returns:
+        NetworkFunction (list): a list of filtered NetworkFunction Objects.
+    """
+    filtered_nfs = None
+    app_conf = current_app.config['app_config']
+    nfs_in_aai = aai_client._get_all_aai_nf_data(app_conf)
+    if nfs_in_aai is not None:
+        nf_filter_module = NetworkFunctionFilter(**nf_filter)
+        filtered_nfs = nf_filter_module.filter_nfs(nfs_in_aai, app_conf)
+    return filtered_nfs
+
+
+def create_nf_event_body(nf, change_type):
+    """
+    Creates a network function event body to publish on MR
+
+    Args:
+        nf (dict): the Network function to include in the event.
+        change_type (string): define the change type to be applied on node
+    Returns:
+        NetworkFunctionEvent (dict): etwork function event body to publish on MR.
+    """
+    app_conf = current_app.config['app_config']
+    return {'networkFunction': {'nfName': nf.nf_name,
+                                'ipv4Address': nf.ipv4_address,
+                                'ipv6Address': nf.ipv6_address,
+                                'blueprintName': nf.sdnc_model_name,
+                                'blueprintVersion': nf.sdnc_model_version,
+                                'policyName': app_conf.operational_policy_name,
+                                'changeType': change_type,
+                                'closedLoopControlName': app_conf.control_loop_name}}
+
+
+def save_nf(nf):
+    """
+    Saves the network function request
+    and also updates model names if missing
+    Args:
+        nf (dict) : requested network function to save
+    """
+    network_function = NetworkFunctionModel.query.filter(
+        NetworkFunctionModel.nf_name == nf.nf_name).one_or_none()
+    if network_function is None:
+        network_function = NetworkFunctionModel(nf_name=nf.nf_name,
+                                                ipv4_address=nf.ipv4_address,
+                                                ipv6_address=nf.ipv6_address,
+                                                model_invariant_id=nf.model_invariant_id,
+                                                model_version_id=nf.model_version_id,
+                                                model_name=nf.model_name,
+                                                sdnc_model_name=nf.sdnc_model_name,
+                                                sdnc_model_version=nf.sdnc_model_version)
+        db.session.add(network_function)
+    elif network_function.model_name is None:
+        NetworkFunctionModel.query.filter(NetworkFunctionModel.nf_name == nf.nf_name)\
+            .update({NetworkFunctionModel.sdnc_model_name: nf.sdnc_model_name,
+                     NetworkFunctionModel.sdnc_model_version: nf.sdnc_model_version,
+                     NetworkFunctionModel.model_name: nf.model_name},
+                    synchronize_session='evaluate')
+
+
+def validate_nf_filter(nf_filter):
+    invalid_info = []
+    if not [filter_name for filter_name, val in nf_filter.items() if len(val) > 0]:
+        invalid_info.append("At least one filter within nfFilter must not be empty")
+    return invalid_info
diff --git a/components/pm-subscription-handler/pmsh_service/mod/api/services/subscription_service.py b/components/pm-subscription-handler/pmsh_service/mod/api/services/subscription_service.py
new file mode 100644 (file)
index 0000000..5babe71
--- /dev/null
@@ -0,0 +1,171 @@
+# ============LICENSE_START===================================================
+#  Copyright (C) 2020-2021 Nordix Foundation.
+# ============================================================================
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# ============LICENSE_END=====================================================
+
+from mod import db, logger
+from mod.api.db_models import SubscriptionModel, NfSubRelationalModel
+from mod.api.services import measurement_group_service, nf_service
+from mod.api.custom_exception import InvalidDataException, DuplicateDataException
+from mod.subscription import AdministrativeState
+
+
+def create_subscription(subscription):
+    """
+        Creates a subscription
+
+        Args:
+            subscription (dict): subscription to save.
+
+        Raises:
+            Error: If anything fails in the server.
+    """
+    perform_validation(subscription)
+    try:
+        save_subscription_request(subscription)
+        filtered_nfs = nf_service.capture_filtered_nfs(subscription["nfFilter"])
+        if len(filtered_nfs) > 0:
+            save_filtered_nfs(filtered_nfs)
+            apply_subscription_to_nfs(filtered_nfs, subscription["subscriptionName"])
+            apply_measurement_grp_to_nfs(subscription["subscriptionName"],
+                                         filtered_nfs, subscription.get('measurementGroups'))
+        db.session.commit()
+    except Exception as e:
+        db.session.rollback()
+        logger.error(f'Failed to create subscription '
+                     f'{subscription["subscriptionName"]} in the DB: {e}', exc_info=True)
+        raise e
+    finally:
+        db.session.remove()
+
+
+def save_filtered_nfs(filtered_nfs):
+    """
+        Saves a network function
+
+        Args:
+            filtered_nfs (dict): list of filtered network functions to save.
+    """
+    for nf in filtered_nfs:
+        nf_service.save_nf(nf)
+
+
+def apply_subscription_to_nfs(filtered_nfs, subscription_name):
+    """
+        Associate and saves the subscription with Network functions
+
+        Args:
+            filtered_nfs (dict): list of filtered network functions to save.
+            subscription_name (string): subscription name to save against nfs
+    """
+    for nf in filtered_nfs:
+        new_nf_sub_rel = NfSubRelationalModel(subscription_name=subscription_name,
+                                              nf_name=nf.nf_name)
+        db.session.add(new_nf_sub_rel)
+
+
+def apply_measurement_grp_to_nfs(subscription_name, filtered_nfs, measurement_groups):
+    """
+        Publishes an event for measurement groups against nfs
+        And saves the successful trigger action as PENDING_CREATE
+
+        Args:
+            subscription_name (string): subscription name to publish against nfs
+            filtered_nfs (dict): list of filtered network functions to publish.
+            measurement_groups (dict): list of measurement group to publish
+    """
+    if measurement_groups:
+        for measurement_group in measurement_groups:
+            measurement_group_details = measurement_group['measurementGroup']
+            if measurement_group_details['administrativeState'] \
+                    == AdministrativeState.UNLOCKED.value:
+                measurement_group_service.publish_measurement_group(
+                    subscription_name, measurement_group_details, filtered_nfs)
+                for nf in filtered_nfs:
+                    measurement_group_service.apply_nf(nf, measurement_group_details)
+
+
+def perform_validation(subscription):
+    """
+    validates the subscription and if invalid raises an exception
+    to indicate duplicate/invalid request
+
+    Args:
+        subscription (Subscription): subscription to validate
+
+    Raises:
+        DuplicateDataException: exception containing the list of duplicate data fields.
+        InvalidDataException: exception containing the list of invalid data.
+    """
+    duplicate_messages = check_duplicate_fields(subscription)
+    if duplicate_messages:
+        raise DuplicateDataException(duplicate_messages)
+    invalid_messages = nf_service.validate_nf_filter(subscription["nfFilter"])
+    if invalid_messages:
+        raise InvalidDataException(invalid_messages)
+
+
+def save_subscription_request(subscription):
+    """
+        Saves the subscription request consisting of:
+        network function filter and measurement groups
+
+        Args:
+            subscription (dict): subscription request to be saved.
+    """
+    save_subscription(subscription)
+    nf_service.save_nf_filter(subscription["nfFilter"], subscription["subscriptionName"])
+    if subscription.get('measurementGroups'):
+        for measurement_group in subscription['measurementGroups']:
+            measurement_group_service \
+                .save_measurement_group(measurement_group['measurementGroup'],
+                                        subscription["subscriptionName"])
+
+
+def check_duplicate_fields(subscription):
+    """
+        validates the subscription content if already present
+        and captures duplicate fields
+
+        Args:
+            subscription (Subscription): subscription to validate
+
+        Returns:
+            invalid_messages: list of invalid data details.
+        """
+    duplicate_field_details = []
+    existing_subscription = (SubscriptionModel.query.filter(
+        SubscriptionModel.subscription_name == subscription['subscriptionName']).one_or_none())
+    if existing_subscription is not None:
+        duplicate_field_details.append(f'subscription Name: {subscription["subscriptionName"]}'
+                                       f' already exists.')
+    if subscription.get('measurementGroups'):
+        for measurement_group in subscription['measurementGroups']:
+            duplicate_field_details.extend(measurement_group_service.check_duplicate_fields(
+                measurement_group['measurementGroup'], subscription['subscriptionName']))
+    return duplicate_field_details
+
+
+def save_subscription(subscription):
+    """
+        Saves the subscription data
+
+        Args:
+            subscription (dict): subscription request to be saved.
+    """
+    new_subscription = SubscriptionModel(subscription_name=subscription["subscriptionName"],
+                                         status='LOCKED')
+    db.session.add(new_subscription)
index 83130a8..9a08249 100755 (executable)
@@ -26,7 +26,8 @@ class NetworkFunction:
     def __init__(self, sdnc_model_name=None, sdnc_model_version=None, **kwargs):
         """ Object representation of the NetworkFunction. """
         self.nf_name = kwargs.get('nf_name')
-        self.ip_address = kwargs.get('ip_address')
+        self.ipv4_address = kwargs.get('ipv4_address')
+        self.ipv6_address = kwargs.get('ipv6_address')
         self.model_invariant_id = kwargs.get('model_invariant_id')
         self.model_version_id = kwargs.get('model_version_id')
         self.model_name = kwargs.get('model_name')
@@ -35,13 +36,14 @@ class NetworkFunction:
 
     @classmethod
     def nf_def(cls):
-        return cls(nf_name=None, ip_address=None, model_invariant_id=None,
-                   model_version_id=None, model_name=None,
+        return cls(nf_name=None, ipv4_address=None, ipv6_address=None,
+                   model_invariant_id=None, model_version_id=None, model_name=None,
                    sdnc_model_name=None, sdnc_model_version=None)
 
     def __str__(self):
         return f'nf-name: {self.nf_name}, ' \
-               f'ipaddress-v4-oam: {self.ip_address}, ' \
+               f'ipaddress-v4-oam: {self.ipv4_address}, ' \
+               f'ipaddress-v6-oam: {self.ipv6_address}, ' \
                f'model-invariant-id: {self.model_invariant_id}, ' \
                f'model-version-id: {self.model_version_id}, ' \
                f'model-name: {self.model_name}, ' \
@@ -51,7 +53,8 @@ class NetworkFunction:
     def __eq__(self, other):
         return \
             self.nf_name == other.nf_name and \
-            self.ip_address == other.ip_address and \
+            self.ipv4_address == other.ipv4_address and \
+            self.ipv6_address == other.ipv6_address and \
             self.model_invariant_id == other.model_invariant_id and \
             self.model_version_id == other.model_version_id and \
             self.model_name == other.model_name and \
@@ -59,8 +62,8 @@ class NetworkFunction:
             self.sdnc_model_version == other.sdnc_model_version
 
     def __hash__(self):
-        return hash((self.nf_name, self.ip_address, self.model_invariant_id,
-                     self.model_version_id, self.model_name,
+        return hash((self.nf_name, self.ipv4_address, self.ipv6_address,
+                     self.model_invariant_id, self.model_version_id, self.model_name,
                      self.sdnc_model_name, self.sdnc_model_version))
 
     def create(self):
@@ -70,7 +73,8 @@ class NetworkFunction:
 
         if existing_nf is None:
             new_nf = NetworkFunctionModel(nf_name=self.nf_name,
-                                          ip_address=self.ip_address,
+                                          ipv4_address=self.ipv6_address,
+                                          ipv6_address=self.ipv6_address,
                                           model_invariant_id=self.model_invariant_id,
                                           model_version_id=self.model_version_id,
                                           model_name=self.model_name,
diff --git a/components/pm-subscription-handler/pmsh_service/mod/network_function_filter.py b/components/pm-subscription-handler/pmsh_service/mod/network_function_filter.py
new file mode 100644 (file)
index 0000000..893fc63
--- /dev/null
@@ -0,0 +1,118 @@
+# ============LICENSE_START===================================================
+#  Copyright (C) 2021 Nordix Foundation.
+# ============================================================================
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# ============LICENSE_END=====================================================
+
+import re
+from mod import logger, aai_client
+from mod.network_function import NetworkFunction
+from mod.api.db_models import NetworkFunctionModel
+
+
+class NetworkFunctionFilter:
+    def __init__(self, **kwargs):
+        self.nf_names = kwargs.get('nfNames')
+        self.model_invariant_ids = kwargs.get('modelInvariantIDs')
+        self.model_version_ids = kwargs.get('modelVersionIDs')
+        self.model_names = kwargs.get('modelNames')
+        self.regex_matcher = re.compile('|'.join(raw_regex for raw_regex in self.nf_names))
+
+    def filter_nfs(self, nf_data, app_conf):
+        """
+            Returns a list of filtered NetworkFunctions using the Filter initialised in the class
+
+            Args:
+                nf_data (dict): the nf json data from AAI.
+                app_conf (App_config): the config for making AAI call.
+            Returns:
+                NetworkFunction (list): a list of filtered NetworkFunction Objects.
+
+            Raises:
+                KeyError: if AAI data cannot be parsed.
+            """
+        nf_list = []
+        try:
+            for nf in nf_data['results']:
+                if nf['properties'].get('orchestration-status') != 'Active':
+                    continue
+                name_identifier = 'pnf-name' if nf['node-type'] == 'pnf' else 'vnf-name'
+                new_nf = NetworkFunction(
+                    nf_name=nf['properties'].get(name_identifier),
+                    ipv4_address=nf['properties'].get('ipaddress-v4-oam'),
+                    ipv6_address=nf['properties'].get('ipaddress-v6-oam'),
+                    model_invariant_id=nf['properties'].get('model-invariant-id'),
+                    model_version_id=nf['properties'].get('model-version-id'))
+                if self.is_nf_in_filter(new_nf) \
+                        and self.is_sdnc_model_in_filter(new_nf, app_conf):
+                    nf_list.append(new_nf)
+        except KeyError as e:
+            logger.error(f'Failed to parse AAI data: {e}', exc_info=True)
+            raise
+        return nf_list
+
+    def is_nf_in_filter(self, nf):
+        """Match the nf fields against the Filter values initialised in the class
+
+        Args:
+            nf (NetworkFunction): The NF to be filtered.
+
+        Returns:
+            bool: True if matched, else False.
+        """
+        match = True
+        if self.nf_names and self.regex_matcher.search(nf.nf_name) is None:
+            match = False
+        if self.model_invariant_ids and nf.model_invariant_id not in self.model_invariant_ids:
+            match = False
+        if self.model_version_ids and nf.model_version_id not in self.model_version_ids:
+            match = False
+        return match
+
+    def is_sdnc_model_in_filter(self, new_nf, app_conf):
+        """
+            saves NetworkFunction model details and confirms.
+
+            Args:
+                new_nf (NetworkFunction): the network function to check.
+                app_conf (App_config): the config for making AAI call.
+            Returns:
+                Boolean : true if model name satisfies
+
+            Raises:
+                KeyError: if AAI data cannot be parsed and logs it.
+            """
+        match = True
+        network_function = NetworkFunctionModel.query.filter(
+            NetworkFunctionModel.nf_name == new_nf.nf_name).one_or_none()
+
+        if network_function is None or network_function.model_name is None:
+            sdnc_model_data = aai_client.get_aai_model_data(app_conf, new_nf.model_invariant_id,
+                                                            new_nf.model_version_id, new_nf.nf_name)
+            try:
+                new_nf.sdnc_model_name = sdnc_model_data['sdnc-model-name']
+                new_nf.sdnc_model_version = sdnc_model_data['sdnc-model-version']
+                new_nf.model_name = sdnc_model_data['model-name']
+            except KeyError as e:
+                logger.info(f'Skipping NF {new_nf.nf_name} as there is no '
+                            f'sdnc-model data associated in AAI: {e}', exc_info=True)
+        else:
+            new_nf.sdnc_model_name = network_function.sdnc_model_name
+            new_nf.sdnc_model_version = network_function.sdnc_model_name
+            new_nf.model_name = network_function.model_name
+
+        if self.model_names and new_nf.model_name not in self.model_names:
+            match = False
+        return match
index fdc1394..225bedd 100755 (executable)
@@ -134,7 +134,8 @@ class Subscription:
             else:
                 change_type = 'CREATE'
             sub_event = {'nfName': nf.nf_name,
-                         'ipv4Address': nf.ip_address,
+                         'ipv4Address': nf.ipv4_address,
+                         'ipv6Address': nf.ipv6_address,
                          'blueprintName': nf.sdnc_model_name,
                          'blueprintVersion': nf.sdnc_model_version,
                          'policyName': app_conf.operational_policy_name,
index fbf4497..9f92d90 100644 (file)
@@ -32,7 +32,7 @@
   <groupId>org.onap.dcaegen2.services</groupId>
   <artifactId>pmsh</artifactId>
   <name>dcaegen2-services-pm-subscription-handler</name>
-  <version>1.3.1-SNAPSHOT</version>
+  <version>1.4.0-SNAPSHOT</version>
   <properties>
     <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
     <sonar.sources>.</sonar.sources>
index 2b8d24a..b0028e0 100644 (file)
@@ -22,7 +22,7 @@ from setuptools import setup, find_packages
 
 setup(
     name="pm_subscription_handler",
-    version="1.3.2",
+    version="1.4.0",
     packages=find_packages(),
     author="lego@est.tech",
     author_email="lego@est.tech",
diff --git a/components/pm-subscription-handler/tests/controllers/test_subscription_controller.py b/components/pm-subscription-handler/tests/controllers/test_subscription_controller.py
new file mode 100644 (file)
index 0000000..d8cf7f3
--- /dev/null
@@ -0,0 +1,64 @@
+# ============LICENSE_START===================================================
+#  Copyright (C) 2019-2021 Nordix Foundation.
+# ============================================================================
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# ============LICENSE_END=====================================================
+
+import json
+import os
+from unittest.mock import patch, MagicMock
+
+from mod import aai_client
+from tests.base_setup import BaseClassSetup
+from mod.api.controllers import subscription_controller
+from flask import current_app
+
+
+class SubscriptionControllerTestCase(BaseClassSetup):
+
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+
+    def setUp(self):
+        super().setUp()
+        current_app.config['app_config'] = self.app_conf
+        with open(os.path.join(os.path.dirname(__file__),
+                               '../data/create_subscription_request.json'), 'r') as data:
+            self.subscription_request = data.read()
+        with open(os.path.join(os.path.dirname(__file__), '../data/aai_xnfs.json'),
+                  'r') as data:
+            self.aai_response_data = data.read()
+
+    def tearDown(self):
+        super().tearDown()
+
+    @classmethod
+    def tearDownClass(cls):
+        super().tearDownClass()
+
+    @patch('mod.api.services.subscription_service.create_subscription',
+           MagicMock(return_value=None))
+    def test_post_subscription(self):
+        response = subscription_controller.post_subscription(json.loads(self.subscription_request))
+        self.assertEqual(response[1], 201)
+
+    @patch.object(aai_client,
+                  '_get_all_aai_nf_data')
+    def test_post_subscription_duplicate_sub(self, mock_aai):
+        mock_aai.return_value = json.loads(self.aai_response_data)
+        response = subscription_controller.post_subscription(json.loads(self.subscription_request))
+        self.assertEqual(response[1], 409)
+        self.assertEqual(response[0][0], 'subscription Name: ExtraPM-All-gNB-R2B already exists.')
diff --git a/components/pm-subscription-handler/tests/data/create_subscription_request.json b/components/pm-subscription-handler/tests/data/create_subscription_request.json
new file mode 100644 (file)
index 0000000..e825c30
--- /dev/null
@@ -0,0 +1,42 @@
+{
+  "subscription": {
+    "subscriptionName": "ExtraPM-All-gNB-R2B",
+    "nfFilter": {
+      "nfNames": [
+        "^pnf.*",
+        "^vnf.*"
+      ],
+      "modelInvariantIDs": [
+        "8lk4578-d396-4efb-af02-6b83499b12f8",
+        "687kj45-d396-4efb-af02-6b83499b12f8"
+
+      ],
+      "modelVersionIDs": [
+        "e80a6ae3-cafd-4d24-850d-e14c084a5ca9"
+      ],
+      "modelNames": [
+        "PNF102"
+      ]
+    },
+    "measurementGroups": [
+      {
+        "measurementGroup": {
+            "measurementGroupName": "msrmt_grp_name",
+            "fileBasedGP":15,
+            "fileLocation":"pm.xml",
+            "administrativeState": "UNLOCKED",
+          "measurementTypes": [
+            {
+              "measurementType": "counter_a"
+            }
+          ],
+          "managedObjectDNsBasic": [
+            {
+              "DN": "string"
+            }
+          ]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
index cd547d9..2ccb54e 100755 (executable)
@@ -6,6 +6,7 @@
    "changeType":"CREATE",\r
    "closedLoopControlName":"pmsh-control-loop",\r
    "ipv4Address": "1.2.3.4",\r
+   "ipv6Address": "1.2.3.4.5.6",\r
    "subscription":{\r
       "subscriptionName":"ExtraPM-All-gNB-R2B",\r
       "administrativeState":"UNLOCKED",\r
diff --git a/components/pm-subscription-handler/tests/services/test_measurement_group_service.py b/components/pm-subscription-handler/tests/services/test_measurement_group_service.py
new file mode 100644 (file)
index 0000000..8499618
--- /dev/null
@@ -0,0 +1,86 @@
+# ============LICENSE_START===================================================
+#  Copyright (C) 2020-2021 Nordix Foundation.
+# ============================================================================
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# ============LICENSE_END=====================================================
+
+import json
+import os
+from unittest.mock import patch
+from flask import current_app
+from mod import aai_client
+from tests.base_setup import BaseClassSetup
+from mod.api.services import subscription_service, nf_service, measurement_group_service
+
+
+class MeasurementGroupServiceTestCase(BaseClassSetup):
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+
+    def setUp(self):
+        super().setUp()
+        current_app.config['app_config'] = self.app_conf
+        with open(os.path.join(os.path.dirname(__file__),
+                               '../data/create_subscription_request.json'), 'r') as data:
+            self.subscription_request = data.read()
+        with open(os.path.join(os.path.dirname(__file__), '../data/aai_xnfs.json'), 'r') as data:
+            self.aai_response_data = data.read()
+        with open(os.path.join(os.path.dirname(__file__), '../data/aai_model_info.json'),
+                  'r') as data:
+            self.good_model_info = data.read()
+
+    def tearDown(self):
+        super().tearDown()
+
+    @classmethod
+    def tearDownClass(cls):
+        super().tearDownClass()
+
+    def create_test_subs(self, new_sub_name, new_msrmt_grp_name):
+        subscription = self.subscription_request.replace('ExtraPM-All-gNB-R2B', new_sub_name)
+        subscription = subscription.replace('msrmt_grp_name', new_msrmt_grp_name)
+        return subscription
+
+    @patch.object(aai_client, '_get_all_aai_nf_data')
+    @patch.object(aai_client, 'get_aai_model_data')
+    def test_capture_filtered_nfs(self, mock_model_aai, mock_aai):
+        mock_aai.return_value = json.loads(self.aai_response_data)
+        mock_model_aai.return_value = json.loads(self.good_model_info)
+        subscription = json.loads(self.subscription_request)['subscription']
+        filtered_nfs = nf_service.capture_filtered_nfs(subscription["nfFilter"])
+        self.assertEqual(len(filtered_nfs), 2)
+        self.assertEqual(filtered_nfs[0].nf_name, 'pnf201')
+        self.assertEqual(filtered_nfs[1].nf_name, 'pnf_33_ericsson')
+
+    def test_validate_measurement_group(self):
+        subscription = self.create_test_subs('xtraPM-All-gNB-R2B-new2', 'msrmt_grp_name-new2')
+        subscription = json.loads(subscription)['subscription']
+        measurement1 = subscription['measurementGroups'][0]
+        msg = measurement_group_service.check_duplicate_fields(
+            measurement1['measurementGroup'], subscription["subscriptionName"])
+        self.assertEqual(len(msg), 0)
+
+    @patch.object(nf_service, 'save_nf_filter')
+    def test_validate_measurement_group_invalid(self, mock_save_filter):
+        mock_save_filter.return_value = None
+        subscription = self.create_test_subs('xtraPM-All-gNB-R2B-new2', 'msrmt_grp_name-new2')
+        subscription = json.loads(subscription)['subscription']
+        subscription_service.save_subscription_request(subscription)
+        measurement1 = subscription['measurementGroups'][0]
+        msg = measurement_group_service.check_duplicate_fields(
+            measurement1['measurementGroup'], subscription["subscriptionName"])
+        self.assertEqual(msg[0], 'Measurement Group: msrmt_grp_name-new2  for '
+                                 'Subscription: xtraPM-All-gNB-R2B-new2 already exists.')
diff --git a/components/pm-subscription-handler/tests/services/test_nf_service.py b/components/pm-subscription-handler/tests/services/test_nf_service.py
new file mode 100644 (file)
index 0000000..0523b10
--- /dev/null
@@ -0,0 +1,123 @@
+# ============LICENSE_START===================================================
+#  Copyright (C) 2020-2021 Nordix Foundation.
+# ============================================================================
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# ============LICENSE_END=====================================================
+
+import json
+import os
+from unittest.mock import patch
+from flask import current_app
+from mod.api.db_models import NetworkFunctionModel
+from mod import aai_client
+from tests.base_setup import BaseClassSetup
+from mod.api.services import nf_service
+
+
+class NetworkFunctionServiceTestCase(BaseClassSetup):
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+
+    def setUp(self):
+        super().setUp()
+        current_app.config['app_config'] = self.app_conf
+        with open(os.path.join(os.path.dirname(__file__),
+                               '../data/create_subscription_request.json'), 'r') as data:
+            self.subscription_request = data.read()
+        with open(os.path.join(os.path.dirname(__file__), '../data/aai_xnfs.json'), 'r') as data:
+            self.aai_response_data = data.read()
+        with open(os.path.join(os.path.dirname(__file__), '../data/aai_model_info.json'),
+                  'r') as data:
+            self.good_model_info = data.read()
+
+    def tearDown(self):
+        super().tearDown()
+
+    @classmethod
+    def tearDownClass(cls):
+        super().tearDownClass()
+
+    def create_test_subs(self, new_sub_name, new_msrmt_grp_name):
+        subscription = self.subscription_request.replace('ExtraPM-All-gNB-R2B', new_sub_name)
+        subscription = subscription.replace('msrmt_grp_name', new_msrmt_grp_name)
+        return subscription
+
+    @patch.object(aai_client, '_get_all_aai_nf_data')
+    @patch.object(aai_client, 'get_aai_model_data')
+    def test_capture_filtered_nfs(self, mock_model_aai, mock_aai):
+        mock_aai.return_value = json.loads(self.aai_response_data)
+        mock_model_aai.return_value = json.loads(self.good_model_info)
+        subscription = json.loads(self.subscription_request)['subscription']
+        filtered_nfs = nf_service.capture_filtered_nfs(subscription["nfFilter"])
+        self.assertEqual(len(filtered_nfs), 2)
+        self.assertEqual(filtered_nfs[0].nf_name, 'pnf201')
+        self.assertEqual(filtered_nfs[1].nf_name, 'pnf_33_ericsson')
+
+    @patch.object(aai_client, '_get_all_aai_nf_data')
+    def test_capture_filtered_nfs_with_no_aai_nodes(self, mock_aai):
+        mock_aai.return_value = None
+        subscription = json.loads(self.subscription_request)['subscription']
+        filtered_nfs = nf_service.capture_filtered_nfs(subscription["nfFilter"])
+        self.assertIsNone(filtered_nfs)
+
+    @patch.object(aai_client, '_get_all_aai_nf_data')
+    @patch.object(aai_client, 'get_aai_model_data')
+    def test_create_nf_event_body(self, mock_model_aai, mock_aai):
+        mock_aai.return_value = json.loads(self.aai_response_data)
+        mock_model_aai.return_value = json.loads(self.good_model_info)
+        subscription = json.loads(self.subscription_request)['subscription']
+        nf = nf_service.capture_filtered_nfs(subscription["nfFilter"])[0]
+        event_body = nf_service.create_nf_event_body(nf, 'CREATE')
+        self.assertEqual(event_body['networkFunction']['nfName'], nf.nf_name)
+        self.assertEqual(event_body['networkFunction']['ipv4Address'], nf.ipv4_address)
+        self.assertEqual(event_body['networkFunction']['ipv6Address'], nf.ipv6_address)
+        self.assertEqual(event_body['networkFunction']['blueprintName'], nf.sdnc_model_name)
+        self.assertEqual(event_body['networkFunction']['blueprintVersion'], nf.sdnc_model_version)
+        self.assertEqual(event_body['networkFunction']['policyName'],
+                         self.app_conf.operational_policy_name)
+        self.assertEqual(event_body['networkFunction']['changeType'], 'CREATE')
+        self.assertEqual(event_body['networkFunction']['closedLoopControlName'],
+                         self.app_conf.control_loop_name)
+
+    @patch.object(aai_client, '_get_all_aai_nf_data')
+    @patch.object(aai_client, 'get_aai_model_data')
+    def test_save_nf_new_nf(self, mock_model_aai, mock_aai):
+        mock_aai.return_value = json.loads(self.aai_response_data)
+        mock_model_aai.return_value = json.loads(self.good_model_info)
+        subscription = json.loads(self.subscription_request)['subscription']
+        nf = nf_service.capture_filtered_nfs(subscription["nfFilter"])[0]
+        nf.nf_name = 'newnf1'
+        nf_service.save_nf(nf)
+        network_function = NetworkFunctionModel.query.filter(
+            NetworkFunctionModel.nf_name == nf.nf_name).one_or_none()
+        self.assertIsNotNone(network_function)
+
+    @patch.object(aai_client, '_get_all_aai_nf_data')
+    @patch.object(aai_client, 'get_aai_model_data')
+    def test_save_nf_missing_model(self, mock_model_aai, mock_aai):
+        mock_aai.return_value = json.loads(self.aai_response_data)
+        mock_model_aai.return_value = json.loads(self.good_model_info)
+        subscription = json.loads(self.subscription_request)['subscription']
+        nf = nf_service.capture_filtered_nfs(subscription["nfFilter"])[0]
+        nf.nf_name = 'newnf2'
+        nf.model_name = None
+        nf_service.save_nf(nf)
+        nf.model_name = 'new_model_name'
+        nf_service.save_nf(nf)
+        network_function = NetworkFunctionModel.query.filter(
+            NetworkFunctionModel.nf_name == nf.nf_name).one_or_none()
+        self.assertIsNotNone(network_function)
+        self.assertEqual(network_function.model_name, 'new_model_name')
diff --git a/components/pm-subscription-handler/tests/services/test_subscription_service.py b/components/pm-subscription-handler/tests/services/test_subscription_service.py
new file mode 100644 (file)
index 0000000..9627e9c
--- /dev/null
@@ -0,0 +1,165 @@
+# ============LICENSE_START===================================================
+#  Copyright (C) 2020-2021 Nordix Foundation.
+# ============================================================================
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# ============LICENSE_END=====================================================
+import copy
+import json
+import os
+from unittest.mock import patch, MagicMock
+from flask import current_app
+from mod.api.db_models import SubscriptionModel, MeasurementGroupModel, \
+    NfMeasureGroupRelationalModel
+from mod.subscription import SubNfState
+from mod import aai_client
+from mod.api.custom_exception import DuplicateDataException, InvalidDataException
+from mod.pmsh_utils import _MrPub
+from tests.base_setup import BaseClassSetup
+from mod.api.services import subscription_service, nf_service, measurement_group_service
+
+
+class SubscriptionServiceTestCase(BaseClassSetup):
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+
+    def setUp(self):
+        super().setUp()
+        current_app.config['app_config'] = self.app_conf
+        with open(os.path.join(os.path.dirname(__file__),
+                               '../data/create_subscription_request.json'), 'r') as data:
+            self.subscription_request = data.read()
+        with open(os.path.join(os.path.dirname(__file__), '../data/aai_xnfs.json'), 'r') as data:
+            self.aai_response_data = data.read()
+        with open(os.path.join(os.path.dirname(__file__), '../data/aai_model_info.json'),
+                  'r') as data:
+            self.good_model_info = data.read()
+
+    def tearDown(self):
+        super().tearDown()
+
+    @classmethod
+    def tearDownClass(cls):
+        super().tearDownClass()
+
+    def create_test_subs(self, new_sub_name, new_msrmt_grp_name):
+        subscription = self.subscription_request.replace('ExtraPM-All-gNB-R2B', new_sub_name)
+        subscription = subscription.replace('msrmt_grp_name', new_msrmt_grp_name)
+        return subscription
+
+    @patch('mod.api.services.nf_service.save_nf_filter', MagicMock(return_value=None))
+    @patch('mod.pmsh_utils._MrPub.publish_to_topic', MagicMock(return_value=None))
+    @patch.object(aai_client, '_get_all_aai_nf_data')
+    @patch.object(aai_client, 'get_aai_model_data')
+    def test_create_subscription(self, mock_model_aai, mock_aai):
+        mock_aai.return_value = json.loads(self.aai_response_data)
+        mock_model_aai.return_value = json.loads(self.good_model_info)
+        subscription = self.create_test_subs('xtraPM-All-gNB-R2B-new', 'msrmt_grp_name-new')
+        subscription_service.create_subscription(json.loads(subscription)['subscription'])
+        existing_subscription = (SubscriptionModel.query.filter(
+            SubscriptionModel.subscription_name == 'xtraPM-All-gNB-R2B-new').one_or_none())
+        self.assertIsNotNone(existing_subscription)
+        existing_measurement_grp = (MeasurementGroupModel.query.filter(
+            MeasurementGroupModel.measurement_group_name == 'msrmt_grp_name-new',
+            MeasurementGroupModel.subscription_name == 'xtraPM-All-gNB-R2B-new').one_or_none())
+        self.assertIsNotNone(existing_measurement_grp)
+        msr_grp_nf_rel = (NfMeasureGroupRelationalModel.query.filter(
+            NfMeasureGroupRelationalModel.measurement_grp_name == 'msrmt_grp_name-new')).all()
+        for pubslished_event in msr_grp_nf_rel:
+            self.assertEqual(pubslished_event.nf_measure_grp_status,
+                             SubNfState.PENDING_CREATE.value)
+
+    @patch('mod.api.services.nf_service.save_nf_filter', MagicMock(return_value=None))
+    @patch.object(aai_client, '_get_all_aai_nf_data')
+    def test_create_subscription_service_failed_rollback(self, mock_aai):
+        mock_aai.side_effect = InvalidDataException(["AAI call failed"])
+        subscription = self.create_test_subs('xtraPM-All-gNB-R2B-fail', 'msrmt_grp_name-fail')
+        try:
+            subscription_service.create_subscription(json.loads(subscription)['subscription'])
+        except InvalidDataException as exception:
+            self.assertEqual(exception.invalid_messages, ["AAI call failed"])
+
+        # Checking Rollback
+        existing_subscription = (SubscriptionModel.query.filter(
+            SubscriptionModel.subscription_name == 'xtraPM-All-gNB-R2B-fail').one_or_none())
+        self.assertIsNone(existing_subscription)
+
+    def test_perform_validation_existing_sub(self):
+        try:
+            subscription_service.create_subscription(json.loads(self.subscription_request)
+                                                     ['subscription'])
+        except DuplicateDataException as exception:
+            self.assertEqual(exception.duplicate_fields_info[0],
+                             "subscription Name: ExtraPM-All-gNB-R2B already exists.")
+
+    @patch.object(nf_service, 'save_nf_filter')
+    def test_save_subscription_request(self, mock_save_filter):
+        mock_save_filter.return_value = None
+        subscription = self.create_test_subs('xtraPM-All-gNB-R2B-new1', 'msrmt_grp_name-new1')
+        subscription_service.save_subscription_request(json.loads(subscription)['subscription'])
+        existing_subscription = (SubscriptionModel.query.filter(
+            SubscriptionModel.subscription_name == 'xtraPM-All-gNB-R2B-new1').one_or_none())
+        self.assertIsNotNone(existing_subscription)
+        self.assertTrue(mock_save_filter.called)
+        existing_measurement_grp = (MeasurementGroupModel.query.filter(
+            MeasurementGroupModel.measurement_group_name == 'msrmt_grp_name-new1',
+            MeasurementGroupModel.subscription_name == 'xtraPM-All-gNB-R2B-new1').one_or_none())
+        self.assertIsNotNone(existing_measurement_grp)
+
+    @patch.object(nf_service, 'save_nf_filter')
+    def test_save_subscription_request_no_measure_grp(self, mock_save_filter):
+        mock_save_filter.return_value = None
+        subscription = self.create_test_subs('xtraPM-All-gNB-R2B-new1', 'msrmt_grp_name-new1')
+        subscription = json.loads(subscription)['subscription']
+        del subscription['measurementGroups']
+        subscription_service.save_subscription_request(subscription)
+        existing_subscription = (SubscriptionModel.query.filter(
+            SubscriptionModel.subscription_name == 'xtraPM-All-gNB-R2B-new1').one_or_none())
+        self.assertIsNotNone(existing_subscription)
+        self.assertTrue(mock_save_filter.called)
+        existing_measurement_grp = (MeasurementGroupModel.query.filter(
+            MeasurementGroupModel.measurement_group_name == 'msrmt_grp_name-new1',
+            MeasurementGroupModel.subscription_name == 'xtraPM-All-gNB-R2B-new1').one_or_none())
+        self.assertIsNone(existing_measurement_grp)
+
+    @patch.object(measurement_group_service, 'apply_nf')
+    @patch.object(_MrPub, 'publish_to_topic')
+    @patch.object(aai_client, '_get_all_aai_nf_data')
+    @patch.object(aai_client, 'get_aai_model_data')
+    def test_apply_measurement_grp_to_nfs(self, mock_model_aai, mock_aai,
+                                          mock_publish, mock_apply_nf):
+        mock_aai.return_value = json.loads(self.aai_response_data)
+        mock_model_aai.return_value = json.loads(self.good_model_info)
+        mock_publish.return_value = None
+        mock_apply_nf.return_value = None
+        subscription = self.create_test_subs('xtraPM-All-gNB-R2B-new2', 'msrmt_grp_name-new2')
+        subscription = json.loads(subscription)['subscription']
+        measurement1 = subscription['measurementGroups'][0]
+        measurement2 = self.create_measurement_grp(measurement1, 'meas2', 'UNLOCKED')
+        measurement3 = self.create_measurement_grp(measurement1, 'meas3', 'LOCKED')
+        subscription['measurementGroups'].extend([measurement2, measurement3])
+        filtered_nfs = nf_service.capture_filtered_nfs(subscription["nfFilter"])
+        subscription_service.apply_measurement_grp_to_nfs(
+            subscription["subscriptionName"], filtered_nfs, subscription.get('measurementGroups'))
+        # Two unlocked measurement Group published
+        self.assertEqual(mock_publish.call_count, 2)
+        # 2 measurement group with 2 nfs each contribute 4 calls
+        self.assertEqual(mock_apply_nf.call_count, 4)
+
+    def create_measurement_grp(self, measurement, measurement_name, admin_status):
+        new_measurement = copy.deepcopy(measurement)
+        new_measurement['measurementGroup']['measurementGroupName'] = measurement_name
+        new_measurement['measurementGroup']['administrativeState'] = admin_status
+        return new_measurement
index 5a1a6ba..990cbbb 100755 (executable)
@@ -33,12 +33,14 @@ class NetworkFunctionTests(BaseClassSetup):
         super().setUp()
         self.nf_1 = NetworkFunction(sdnc_model_name='blah', sdnc_model_version=1.0,
                                     **{'nf_name': 'pnf_1',
-                                       'ip_address': '1.2.3.4',
+                                       'ipv4_address': '1.2.3.4',
+                                       'ipv6_address': '1.2.3.4.5.6',
                                        'model_invariant_id': 'some_id',
                                        'model_version_id': 'some_other_id'})
         self.nf_2 = NetworkFunction(sdnc_model_name='blah', sdnc_model_version=2.0,
                                     **{'nf_name': 'pnf_2',
-                                       'ip_address': '1.2.3.4',
+                                       'ipv4_address': '1.2.3.4',
+                                       'ipv6_address': '1.2.3.4.5.6',
                                        'model_invariant_id': 'some_id',
                                        'model_version_id': 'some_other_id'})
         with open(os.path.join(os.path.dirname(__file__), 'data/aai_model_info.json'), 'r') as data:
index b18f41e..d45c273 100755 (executable)
@@ -140,7 +140,8 @@ class SubscriptionTest(BaseClassSetup):
                                'data/pm_subscription_event.json'), 'r') as data:
             expected_sub_event = json.load(data)
         nf = NetworkFunction(nf_name='pnf_1',
-                             ip_address='1.2.3.4',
+                             ipv4_address='1.2.3.4',
+                             ipv6_address='1.2.3.4.5.6',
                              model_invariant_id='some-id',
                              model_version_id='some-id')
         nf.sdnc_model_name = 'some-name'
index 2293ee5..ecc45f6 100644 (file)
@@ -109,7 +109,8 @@ class SubscriptionHandlerTest(BaseClassSetup):
            MagicMock(return_value=NetworkFunctionModel(nf_name='pnf_1',
                                                        model_invariant_id='some-id',
                                                        model_version_id='some-id',
-                                                       ip_address='ip_address',
+                                                       ipv4_address='ip_address4',
+                                                       ipv6_address='ip_address6',
                                                        model_name='model_name',
                                                        sdnc_model_name='sdnc_model_name',
                                                        sdnc_model_version='sdnc_model_version')))
@@ -145,7 +146,8 @@ class SubscriptionHandlerTest(BaseClassSetup):
            MagicMock(return_value=NetworkFunctionModel(nf_name='pnf_1',
                                                        model_invariant_id='some-id',
                                                        model_version_id='some-id',
-                                                       ip_address='ip_address',
+                                                       ipv4_address='ip_address4',
+                                                       ipv6_address='ip_address6',
                                                        model_name='model_name',
                                                        sdnc_model_name='sdnc_model_name',
                                                        sdnc_model_version='sdnc_model_version',
index ef20baa..9e0d73d 100644 (file)
@@ -1,6 +1,6 @@
 major=1
-minor=3
-patch=2
+minor=4
+patch=0
 base_version=${major}.${minor}.${patch}
 release_version=${base_version}
 snapshot_version=${base_version}-SNAPSHOT