diff --git a/api/drf_views.py b/api/drf_views.py index 0b1c1f600..21f543664 100644 --- a/api/drf_views.py +++ b/api/drf_views.py @@ -297,11 +297,16 @@ def get_queryset(self): user = getattr(self.request, "user", None) if not user or not user.is_authenticated: - snip_qs = RegionSnippet.objects.filter(visibility=VisibilityChoices.PUBLIC) + # Guests: only PUBLIC and exclude backend-gated power_bi embeds entirely + snip_qs = RegionSnippet.objects.filter(visibility=VisibilityChoices.PUBLIC).exclude( + snippet__contains='data-snippet-type="power_bi"' + ) else: profile = getattr(user, "profile", None) if profile and profile.limit_access_to_guest: - snip_qs = RegionSnippet.objects.filter(visibility=VisibilityChoices.PUBLIC) + snip_qs = RegionSnippet.objects.filter(visibility=VisibilityChoices.PUBLIC).exclude( + snippet__contains='data-snippet-type="power_bi"' + ) elif is_user_ifrc(user): snip_qs = RegionSnippet.objects.all() else: @@ -318,6 +323,8 @@ def get_queryset(self): snip_qs = snip_qs.exclude( Q(visibility=VisibilityChoices.IFRC_NS) & ~Q(region_id__in=allowed_region_ids_for_ifrc_ns) ) + # Exclude power_bi embedded snippets if marked auth-required and user is not IFRC + snip_qs = snip_qs.exclude(snippet__contains='data-snippet-type="power_bi"') return self.queryset.prefetch_related(models.Prefetch("snippets", queryset=snip_qs)) @@ -679,6 +686,16 @@ def get_serializer_class(self): return RegionSnippetTableauSerializer return RegionSnippetSerializer + def get_queryset(self): + qs = super().get_queryset() + user = getattr(self.request, "user", None) + if not user or not user.is_authenticated or (getattr(user, "profile", None) and user.profile.limit_access_to_guest): + return qs.exclude(snippet__contains='data-snippet-type="power_bi"') + # Non-IFRC auth users: still exclude power_bi when present and auth_required + if not is_user_ifrc(user): + return qs.exclude(snippet__contains='data-snippet-type="power_bi"') + return qs + class CountrySnippetViewset(ReadOnlyVisibilityViewset): authentication_classes = (TokenAuthentication,) @@ -691,6 +708,15 @@ def get_serializer_class(self): return CountrySnippetTableauSerializer return CountrySnippetSerializer + def get_queryset(self): + qs = super().get_queryset() + user = getattr(self.request, "user", None) + if not user or not user.is_authenticated or (getattr(user, "profile", None) and user.profile.limit_access_to_guest): + return qs.exclude(snippet__contains='data-snippet-type="power_bi"') + if not is_user_ifrc(user): + return qs.exclude(snippet__contains='data-snippet-type="power_bi"') + return qs + class DistrictViewset(viewsets.ReadOnlyModelViewSet): queryset = District.objects.select_related("country").filter(country__is_deprecated=False).filter(is_deprecated=False) @@ -888,6 +914,15 @@ class EventSnippetViewset(ReadOnlyVisibilityViewset): visibility_model_class = Snippet ordering_fields = "__all__" + def get_queryset(self): + qs = super().get_queryset() + user = getattr(self.request, "user", None) + if not user or not user.is_authenticated or (getattr(user, "profile", None) and user.profile.limit_access_to_guest): + return qs.exclude(snippet__contains='data-snippet-type="power_bi"') + if not is_user_ifrc(user): + return qs.exclude(snippet__contains='data-snippet-type="power_bi"') + return qs + class SituationReportTypeViewset(viewsets.ReadOnlyModelViewSet): queryset = SituationReportType.objects.all() diff --git a/api/serializers.py b/api/serializers.py index cd0ba0d73..030d9b807 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -12,7 +12,7 @@ # from api.utils import pdf_exporter from api.tasks import generate_url -from api.utils import CountryValidator, RegionValidator +from api.utils import CountryValidator, RegionValidator, parse_snippet_embed from deployments.models import EmergencyProject, Personnel, PersonnelDeployment from dref.models import Dref, DrefFinalReport, DrefOperationalUpdate from lang.models import String @@ -485,6 +485,7 @@ class Meta: class RegionSnippetSerializer(ModelSerializer): visibility_display = serializers.CharField(source="get_visibility_display", read_only=True) + embed = serializers.SerializerMethodField() class Meta: model = RegionSnippet @@ -494,6 +495,7 @@ class Meta: "image", "visibility", "visibility_display", + "embed", "id", ) @@ -501,6 +503,10 @@ def validate_image(self, image): validate_file_type(image) return image + @staticmethod + def get_embed(obj): + return parse_snippet_embed(obj.snippet) + class RegionEmergencySnippetSerializer(ModelSerializer): class Meta: @@ -553,6 +559,7 @@ class Meta: class CountrySnippetSerializer(ModelSerializer): visibility_display = serializers.CharField(source="get_visibility_display", read_only=True) + embed = serializers.SerializerMethodField() class Meta: model = CountrySnippet @@ -562,6 +569,7 @@ class Meta: "image", "visibility", "visibility_display", + "embed", "id", ) @@ -569,6 +577,10 @@ def validate_image(self, image): validate_file_type(image) return image + @staticmethod + def get_embed(obj): + return parse_snippet_embed(obj.snippet) + class RegionLinkSerializer(ModelSerializer): class Meta: @@ -883,6 +895,7 @@ class SnippetSerializer(ModelSerializer): visibility_display = serializers.CharField(source="get_visibility_display", read_only=True) position_display = serializers.CharField(source="get_position_display", read_only=True) tab_display = serializers.CharField(source="get_tab_display", read_only=True) + embed = serializers.SerializerMethodField() class Meta: model = Snippet @@ -897,12 +910,17 @@ class Meta: "position_display", "tab", "tab_display", + "embed", ) def validate_image(self, image): validate_file_type(image) return image + @staticmethod + def get_embed(obj): + return parse_snippet_embed(obj.snippet) + class EventContactSerializer(ModelSerializer): class Meta: diff --git a/api/test_snippet_embed.py b/api/test_snippet_embed.py new file mode 100644 index 000000000..a8c13f1e9 --- /dev/null +++ b/api/test_snippet_embed.py @@ -0,0 +1,42 @@ +from api.utils import parse_snippet_embed + + +def test_parse_power_bi_with_report_id(): + html = ( + '
' + ) + res = parse_snippet_embed(html) + assert res is not None + assert res.get("type") == "power_bi" + assert res.get("report_id") == "00000000-0000-0000-0000-000000000000" + assert res.get("auth_required") is True + + +def test_parse_power_bi_with_embed_url_and_default_auth(): + html = ( + '' + ) + res = parse_snippet_embed(html) + assert res is not None + assert res.get("type") == "power_bi" + assert res.get("report_id") is None + assert res.get("embed_url", "").startswith("https://") + # default is True when data-auth-required missing + assert res.get("auth_required") is True + + +def test_parse_non_power_bi_returns_none(): + html = '' + assert parse_snippet_embed(html) is None + + +def test_parse_empty_or_none_returns_none(): + assert parse_snippet_embed("") is None + # Defensive handling: function returns None for falsy html + # NOTE: pass a whitespace-only string instead of None to satisfy type check + assert parse_snippet_embed(" ") is None diff --git a/api/utils.py b/api/utils.py index c0f43f674..4d47b09e2 100644 --- a/api/utils.py +++ b/api/utils.py @@ -156,3 +156,57 @@ class RegionValidator(TypedDict): class CountryValidator(TypedDict): country: int local_unit_types: list[int] + + +# --- Snippet embed helpers --- +def parse_snippet_embed(html: str) -> Optional[dict]: + """ + Parse supported embed metadata from an HTML snippet. + + Convention: use a lightweight container tag with data-attributes, e.g. + + + Returns a dict like {"type": "power_bi", "report_id": "...", "auth_required": True} + if detected; otherwise None. + + Notes: + - No script parsing; explicit data- attributes only. + - auth_required defaults to True when missing. + """ + if not html: + return None + + try: + # Simple, safe regex extraction without executing or parsing scripts + import re + + # Look for a tag with data-snippet-type="power_bi" + type_match = re.search(r'data-snippet-type\s*=\s*"(power_bi)"', html, re.IGNORECASE) + if not type_match: + return None + + # Extract report-id or embed-url + report_id_match = re.search(r'data-report-id\s*=\s*"([^"]+)"', html) + embed_url_match = re.search(r'data-embed-url\s*=\s*"([^"]+)"', html) + + report_id = report_id_match.group(1) if report_id_match else None + embed_url = embed_url_match.group(1) if embed_url_match else None + + # auth-required (defaults true) + auth_match = re.search(r'data-auth-required\s*=\s*"?(true|false)"?', html, re.IGNORECASE) + auth_required = True + if auth_match: + auth_required = auth_match.group(1).lower() == "true" + + result = { + "type": "power_bi", + "auth_required": auth_required, + } + if report_id: + result["report_id"] = report_id + if embed_url: + result["embed_url"] = embed_url + return result + except Exception: + # Be resilient – any parsing issue just returns None + return None