diff --git a/Pipfile b/Pipfile index 1336d83497..5194334464 100644 --- a/Pipfile +++ b/Pipfile @@ -65,6 +65,7 @@ flower = "==1.2.0" # issue when locking GDAL = "==3.0.2" gunicorn = "<20.0" jsonschema = "*" +msal = "*" newrelic = "*" Pillow = "==9.3.0" psycopg2-binary = "==2.9.1" diff --git a/Pipfile.lock b/Pipfile.lock index 7399717771..d08f53c763 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "b1f155f5f7fd9dc3b5c8af68848568b58c076f4540e65b9be6d36afb87df52c9" + "sha256": "33c8e385ee9be3fe43abd7905e6f97109b76bd8b26f738a3029a0b3652faebcb" }, "pipfile-spec": 6, "requires": { @@ -842,6 +842,14 @@ "markers": "python_version >= '3.7'", "version": "==2.1.3" }, + "msal": { + "hashes": [ + "sha256:3064f80221a21cd535ad8c3fafbb3a3582cd9c7e9af0bb789ae14f726a0ca99b", + "sha256:80bbabe34567cb734efd2ec1869b2d98195c927455369d8077b3c542088c5c9d" + ], + "index": "pypi", + "version": "==1.28.0" + }, "newrelic": { "hashes": [ "sha256:1996ed51f92366f5ba9ad4992687aaca4d0bb3541e239ef4a40e0ae5da6939fc", @@ -1025,6 +1033,9 @@ "version": "==2.21" }, "pyjwt": { + "extras": [ + "crypto" + ], "hashes": [ "sha256:ba2b425b15ad5ef12f200dc67dd56af4e26de2331f965c5439994dad075876e1", "sha256:bd6ca4a3c4285c1a2d4349e5a035fdf8fb94e04ccd0fcbe6ba289dae9cc3e074" @@ -1481,6 +1492,13 @@ "markers": "python_version >= '3.8'", "version": "==6.3.2" }, + "types-cryptography": { + "hashes": [ + "sha256:09cc53f273dd4d8c29fa7ad11fefd9b734126d467960162397bc5e3e604dea75", + "sha256:b965d548f148f8e87f353ccf2b7bd92719fdf6c845ff7cedf2abb393a0643e4f" + ], + "version": "==3.3.23.2" + }, "typing-extensions": { "hashes": [ "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36", diff --git a/__pypackages__/.gitignore b/__pypackages__/.gitignore new file mode 100644 index 0000000000..d6b7ef32c8 --- /dev/null +++ b/__pypackages__/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/etools/applications/audit/management/commands/update_audit_permissions.py b/src/etools/applications/audit/management/commands/update_audit_permissions.py index 1cc9d56363..fba4f0159c 100644 --- a/src/etools/applications/audit/management/commands/update_audit_permissions.py +++ b/src/etools/applications/audit/management/commands/update_audit_permissions.py @@ -393,7 +393,7 @@ def assign_permissions(self): self.add_permissions( self.all_unicef_users, 'view', self.follow_up_page, - condition=final_engagement_condition + condition=self.engagement_comments_received_by_unicef() ) self.add_permissions( self.all_unicef_users, 'view', @@ -403,7 +403,7 @@ def assign_permissions(self): self.add_permissions( self.focal_point, 'edit', self.follow_up_editable_page, - condition=final_engagement_condition + condition=self.engagement_comments_received_by_unicef() ) # action points related permissions. editable by focal point, author, assignee and assigner diff --git a/src/etools/applications/last_mile/serializers.py b/src/etools/applications/last_mile/serializers.py index 2205610fe6..677c0bdbfb 100644 --- a/src/etools/applications/last_mile/serializers.py +++ b/src/etools/applications/last_mile/serializers.py @@ -324,7 +324,7 @@ def update(self, instance, validated_data): transfer_subtype=models.Transfer.SHORT, partner_organization=instance.partner_organization, waybill_id=instance.waybill_id, - unicef_release_order=f'sh-{instance.unicef_release_order}', + unicef_release_order=f'sh-{instance.unicef_release_order if instance.unicef_release_order else instance.pk}', origin_transfer=instance, origin_point=instance.origin_point, **validated_data @@ -341,7 +341,7 @@ def update(self, instance, validated_data): origin_transfer=instance, origin_point=instance.origin_point, waybill_id=instance.waybill_id, - unicef_release_order=f'su-{instance.unicef_release_order}', + unicef_release_order=f'su-{instance.unicef_release_order if instance.unicef_release_order else instance.pk}', **validated_data ) surplus_transfer.save() diff --git a/src/etools/applications/last_mile/urls.py b/src/etools/applications/last_mile/urls.py index 7542bc1e6a..9b29193a43 100644 --- a/src/etools/applications/last_mile/urls.py +++ b/src/etools/applications/last_mile/urls.py @@ -37,6 +37,11 @@ view=views_ext.VisionLMSMExport.as_view(http_method_names=['get'],), name="vision-export-data" ), + path( + 'pbi-data/', + view=views.PowerBIDataView.as_view(http_method_names=['get'],), + name="vision-export-data" + ), path( 'points-of-interest//items/', view=views.InventoryItemListView.as_view(http_method_names=['get'],), diff --git a/src/etools/applications/last_mile/views.py b/src/etools/applications/last_mile/views.py index 405af458d5..a6bfd86cf3 100644 --- a/src/etools/applications/last_mile/views.py +++ b/src/etools/applications/last_mile/views.py @@ -1,9 +1,12 @@ from functools import cache +from django.conf import settings from django.db import connection from django.db.models import CharField, OuterRef, Prefetch, Q, Subquery from django.shortcuts import get_object_or_404 +from django.utils.functional import cached_property +import requests from django_filters.rest_framework import DjangoFilterBackend from rest_framework import mixins, status from rest_framework.decorators import action @@ -11,6 +14,7 @@ from rest_framework.filters import SearchFilter from rest_framework.generics import ListAPIView from rest_framework.response import Response +from rest_framework.views import APIView from rest_framework.viewsets import GenericViewSet, ModelViewSet, ReadOnlyModelViewSet from unicef_restlib.pagination import DynamicPageNumberPagination @@ -18,6 +22,7 @@ from etools.applications.last_mile.filters import TransferFilter from etools.applications.last_mile.permissions import IsIPLMEditor from etools.applications.last_mile.tasks import notify_upload_waybill +from etools.applications.utils.pbi_auth import get_access_token, get_embed_token, get_embed_url, TokenRetrieveException class PointOfInterestTypeViewSet(ReadOnlyModelViewSet): @@ -313,3 +318,52 @@ def get_queryset(self): if not partner: return super().get_queryset().none() return super().get_queryset().filter(transfer__partner_organization=partner) + + +class PowerBIDataView(APIView): + permission_classes = [IsIPLMEditor] + + @staticmethod + def get_pbi_access_token(): + try: + return get_access_token() + except TokenRetrieveException: + raise PermissionDenied('Token cannot be retrieved') + + @cached_property + def pbi_headers(self): + return {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + self.get_pbi_access_token()} + + def get_embed_url(self, workspace_id, report_id): + url_to_call = f'https://api.powerbi.com/v1.0/myorg/groups/{workspace_id}/reports/{report_id}' + api_response = requests.get(url_to_call, headers=self.pbi_headers) + if api_response.status_code == 200: + r = api_response.json() + return r["embedUrl"], r["datasetId"] + + def get_embed_token(self, dataset_id, workspace_id, report_id): + embed_token_api = 'https://api.powerbi.com/v1.0/myorg/GenerateToken' + request_body = { + "datasets": [{'id': dataset_id}], + "reports": [{'id': report_id}], + "targetWorkspaces": [{'id': workspace_id}] + } + api_response = requests.post(embed_token_api, data=request_body, headers=self.pbi_headers) + if api_response.status_code == 200: + return api_response.json()["token"] + return None + + def get(self, request, *args, **kwargs): + try: + embed_url, dataset_id = get_embed_url(self.pbi_headers) + print(embed_url, 'embedurl') + embed_token = get_embed_token(dataset_id, self.pbi_headers) + print(embed_token) + except TokenRetrieveException: + raise PermissionDenied('Token cannot be retrieved') + resp_data = { + "report_id": settings.PBI_CONFIG["REPORT_ID"], + "embed_url": embed_url, + "access_token": embed_token + } + return Response(resp_data, status=status.HTTP_200_OK) diff --git a/src/etools/applications/partners/tests/test_v3_interventions.py b/src/etools/applications/partners/tests/test_v3_interventions.py index 290feb47c4..453c90df6a 100644 --- a/src/etools/applications/partners/tests/test_v3_interventions.py +++ b/src/etools/applications/partners/tests/test_v3_interventions.py @@ -1811,7 +1811,7 @@ def test_partner_details(self): realms__data=['IP Viewer'], profile__organization=intervention.agreement.partner.organization ) - with self.assertNumQueries(192): + with self.assertNumQueries(193): response = self.forced_auth_req( "patch", reverse('pmp_v3:intervention-detail', args=[intervention.pk]), @@ -1838,7 +1838,7 @@ def test_unicef_details(self): budget_owner = UserFactory(is_staff=True) office = OfficeFactory() section = SectionFactory() - with self.assertNumQueries(203): + with self.assertNumQueries(204): response = self.forced_auth_req( "patch", reverse('pmp_v3:intervention-detail', args=[intervention.pk]), @@ -1887,7 +1887,7 @@ def test_unicef_extended_details(self): site2 = LocationSiteFactory() site3 = LocationSiteFactory() - with self.assertNumQueries(253): + with self.assertNumQueries(254): response = self.forced_auth_req( "patch", reverse('pmp_v3:intervention-detail', args=[intervention.pk]), diff --git a/src/etools/applications/partners/validation/interventions.py b/src/etools/applications/partners/validation/interventions.py index 7d8535a96f..cb7a90ca9d 100644 --- a/src/etools/applications/partners/validation/interventions.py +++ b/src/etools/applications/partners/validation/interventions.py @@ -240,13 +240,14 @@ def start_date_related_agreement_valid(i): if i.number.find("-") > -1: has_valid_dates = i.start and i.agreement.start else: - has_valid_dates = i.start and i.agreement.start and i.start < i.agreement.start + has_valid_dates = i.start and i.agreement.start and i.start >= i.agreement.start # Check if there is a signed document or attachment - has_signed_document = i.signed_pd_document or i.signed_pd_attachment + has_signed_document = i.signed_pd_document or i.signed_pd_attachment.exists() - if is_pd_or_spd and not_contingency_pd and has_valid_dates and has_signed_document: - return False + if is_pd_or_spd and not_contingency_pd and has_signed_document: + if not has_valid_dates: + return False return True diff --git a/src/etools/applications/utils/pbi_auth.py b/src/etools/applications/utils/pbi_auth.py new file mode 100644 index 0000000000..99cc42588b --- /dev/null +++ b/src/etools/applications/utils/pbi_auth.py @@ -0,0 +1,82 @@ +from django.conf import settings +from django.core.cache import cache + +import msal +import requests + + +class TokenRetrieveException(BaseException): + """Exception thrown when migration is failing due validation""" + + +pbi_config = settings.PBI_CONFIG + + +def get_access_token(): + # try to get it from cache: + cache_key = 'lmsm_pbi_access_token' + access_token = cache.get(cache_key, None) + if not access_token: + # cache.add(cache_key, access_token, timeout=1800) + + required_keys = ['AUTHENTICATION_MODE', 'WORKSPACE_ID', + 'REPORT_ID', 'TENANT_ID', + 'CLIENT_ID', 'CLIENT_SECRET', 'SCOPE_BASE', 'AUTHORITY_URL'] + + if not all(key in pbi_config and pbi_config[key] for key in required_keys): + raise TokenRetrieveException('Token required keys: {}'.format(required_keys)) + try: + + # Service Principal auth is the recommended by Microsoft to achieve App Owns Data Power BI embedding + if pbi_config['AUTHENTICATION_MODE'].lower() == 'serviceprincipal': + authority = pbi_config['AUTHORITY_URL'].replace('organizations', pbi_config['TENANT_ID']) + clientapp = msal.ConfidentialClientApplication(pbi_config['CLIENT_ID'], + client_credential=pbi_config['CLIENT_SECRET'], + authority=authority) + + # Make a client call if Access token is not available in cache + response = clientapp.acquire_token_for_client(scopes=pbi_config['SCOPE_BASE']) + else: + raise TokenRetrieveException(f"Not supported authentication mode: {pbi_config['AUTHENTICATION_MODE']}") + + try: + token_return = response['access_token'] + except KeyError: + raise TokenRetrieveException(response['error_description']) + else: + cache.add(cache_key, token_return, timeout=1800) + return token_return + except Exception as ex: + raise TokenRetrieveException('Error retrieving Access token\n' + str(ex)) + + return access_token + + +def get_embed_url(pbi_headers): + workspace_id = pbi_config['WORKSPACE_ID'] + report_id = pbi_config['REPORT_ID'] + + url_to_call = f'https://api.powerbi.com/v1.0/myorg/groups/{workspace_id}/reports/{report_id}' + api_response = requests.get(url_to_call, headers=pbi_headers) + if api_response.status_code == 200: + r = api_response.json() + return r["embedUrl"], r["datasetId"] + else: + raise TokenRetrieveException('Error retrieving Embed URL') + + +def get_embed_token(dataset_id, pbi_headers): + workspace_id = pbi_config['WORKSPACE_ID'] + report_id = pbi_config['REPORT_ID'] + + embed_token_api = 'https://api.powerbi.com/v1.0/myorg/GenerateToken' + request_body = { + "datasets": [{'id': dataset_id}], + "reports": [{'id': report_id}], + "targetWorkspaces": [{'id': workspace_id}] + } + api_response = requests.post(embed_token_api, json=request_body, headers=pbi_headers) + if api_response.status_code == 200: + return api_response.json()["token"] + else: + raise TokenRetrieveException('Error retrieving Embed Token') diff --git a/src/etools/config/settings/base.py b/src/etools/config/settings/base.py index 2799a6c3f2..c6043106cb 100644 --- a/src/etools/config/settings/base.py +++ b/src/etools/config/settings/base.py @@ -682,3 +682,14 @@ def before_send(event, hint): "/api/pmp/v3", "/api/v2/funds", ] + +PBI_CONFIG = { + "AUTHENTICATION_MODE": 'ServicePrincipal', + "WORKSPACE_ID": get_from_secrets_or_env('PBI_LMSM_WORKSPACE_ID', ''), + "REPORT_ID": get_from_secrets_or_env('PBI_LMSM_REPORT_ID', ''), + "TENANT_ID": get_from_secrets_or_env('PBI_LMSM_TENANT_ID', ''), + "CLIENT_ID": get_from_secrets_or_env('PBI_LMSM_CLIENT_ID', ''), + "CLIENT_SECRET": get_from_secrets_or_env('PBI_LMSM_CLIENT_SECRET', ''), + "SCOPE_BASE": ['https://analysis.windows.net/powerbi/api/.default'], + "AUTHORITY_URL": f"https://login.microsoftonline.com/{get_from_secrets_or_env('PBI_LMSM_TENANT_ID', '')}" +}