Skip to content

Commit

Permalink
Merge pull request #1691 from BLSQ/IA-3446-out-restriction-back
Browse files Browse the repository at this point in the history
IA-3446 Org Unit Type restrictions in `Profile` (backend)
  • Loading branch information
beygorghor authored Oct 24, 2024
2 parents 445c7a9 + a9e046c commit 272a624
Show file tree
Hide file tree
Showing 10 changed files with 348 additions and 36 deletions.
66 changes: 61 additions & 5 deletions iaso/api/org_unit_change_request_configurations/views_mobile.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from itertools import chain

import django_filters
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema

from rest_framework import filters
from rest_framework import viewsets
from rest_framework.mixins import ListModelMixin
from rest_framework.request import Request
Expand All @@ -17,12 +18,12 @@
)
from iaso.api.query_params import APP_ID
from iaso.api.serializers import AppIdSerializer
from iaso.models import OrgUnitChangeRequestConfiguration
from iaso.models import OrgUnitChangeRequestConfiguration, Project


class MobileOrgUnitChangeRequestConfigurationViewSet(ListModelMixin, viewsets.GenericViewSet):
permission_classes = [HasOrgUnitsChangeRequestConfigurationReadPermission]
filter_backends = [filters.OrderingFilter, django_filters.rest_framework.DjangoFilterBackend]
filter_backends = [django_filters.rest_framework.DjangoFilterBackend]
serializer_class = MobileOrgUnitChangeRequestConfigurationListSerializer
pagination_class = OrgUnitChangeRequestConfigurationPagination

Expand All @@ -42,9 +43,64 @@ def get_queryset(self):
.prefetch_related(
"possible_types", "possible_parent_types", "group_sets", "editable_reference_forms", "other_groups"
)
.order_by("id")
.order_by("org_unit_type_id")
)

@swagger_auto_schema(manual_parameters=[app_id_param])
def list(self, request: Request, *args, **kwargs) -> Response:
return super().list(request, *args, **kwargs)
"""
Because some Org Unit Type restrictions are also configurable at the `Profile` level,
we implement the following logic in the list view:
1. If `Profile.editable_org_unit_types` empty:
- return `OrgUnitChangeRequestConfiguration` content
2. If `Profile.editable_org_unit_types` not empty:
a. for org_unit_type not in `Profile.editable_org_unit_types`:
- return a dynamic configuration that says `org_units_editable: False`
- regardless of any existing `OrgUnitChangeRequestConfiguration`
b. for org_unit_type in `Profile.editable_org_unit_types`:
- return either the existing `OrgUnitChangeRequestConfiguration` content or nothing
"""
app_id = AppIdSerializer(data=self.request.query_params).get_app_id(raise_exception=True)
queryset = self.get_queryset()

user_editable_org_unit_type_ids = set(
self.request.user.iaso_profile.editable_org_unit_types.values_list("id", flat=True)
)

if user_editable_org_unit_type_ids:
project_org_unit_types = set(Project.objects.get(app_id=app_id).unit_types.values_list("id", flat=True))
non_editable_org_unit_type_ids = project_org_unit_types - user_editable_org_unit_type_ids

dynamic_configurations = [
OrgUnitChangeRequestConfiguration(org_unit_type_id=org_unit_type_id, org_units_editable=False)
for org_unit_type_id in non_editable_org_unit_type_ids
]

# Because we're merging unsaved instances with a queryset (which is a representation of a database query),
# we have to sort the resulting list manually to keep the pagination working properly.
queryset = list(
chain(
queryset.exclude(org_unit_type__in=non_editable_org_unit_type_ids),
dynamic_configurations,
)
)
# Unsaved instances do not have an `id`, so we're sorting on `org_unit_type_id` in all cases.
queryset = sorted(queryset, key=lambda item: item.org_unit_type_id)

queryset = self.filter_queryset(queryset)

page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)

serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
22 changes: 19 additions & 3 deletions iaso/api/org_units.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,9 +423,18 @@ def treesearch(self, request, **kwargs):
def partial_update(self, request, pk=None):
errors = []
org_unit = get_object_or_404(self.get_queryset(), id=pk)
profile = request.user.iaso_profile

self.check_object_permissions(request, org_unit)

if org_unit.org_unit_type and not profile.has_org_unit_write_permission(org_unit.org_unit_type.pk):
errors.append(
{
"errorKey": "org_unit_type_id",
"errorMessage": _("You cannot create or edit an Org unit of this type"),
}
)

original_copy = deepcopy(org_unit)

if "name" in request.data:
Expand Down Expand Up @@ -528,7 +537,6 @@ def partial_update(self, request, pk=None):
org_unit.parent = parent_org_unit
else:
# User that are restricted to parts of the hierarchy cannot create root orgunit
profile = request.user.iaso_profile
if profile.org_units.all():
errors.append(
{
Expand Down Expand Up @@ -674,8 +682,6 @@ def create_org_unit(self, request):
else:
org_unit.validation_status = validation_status

org_unit_type_id = request.data.get("org_unit_type_id", None)

reference_instance_id = request.data.get("reference_instance_id", None)

parent_id = request.data.get("parent_id", None)
Expand All @@ -697,9 +703,19 @@ def create_org_unit(self, request):
if latitude and longitude:
org_unit.location = Point(x=longitude, y=latitude, z=altitude, srid=4326)

org_unit_type_id = request.data.get("org_unit_type_id", None)

if not org_unit_type_id:
errors.append({"errorKey": "org_unit_type_id", "errorMessage": _("Org unit type is required")})

if not profile.has_org_unit_write_permission(org_unit_type_id):
errors.append(
{
"errorKey": "org_unit_type_id",
"errorMessage": _("You cannot create or edit an Org unit of this type"),
}
)

if parent_id:
parent_org_unit = get_object_or_404(self.get_queryset(), id=parent_id)
if org_unit.version_id != parent_org_unit.version_id:
Expand Down
15 changes: 7 additions & 8 deletions iaso/api/profiles/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ class ProfilesViewSet(viewsets.ViewSet):

def get_queryset(self):
account = self.request.user.iaso_profile.account
return Profile.objects.filter(account=account)
return Profile.objects.filter(account=account).prefetch_related("editable_org_unit_types")

def list(self, request):
limit = request.GET.get("limit", None)
Expand Down Expand Up @@ -275,6 +275,7 @@ def list(self, request):
"org_units__parent__org_unit_type",
"org_units__parent__parent__org_unit_type",
"projects",
"editable_org_unit_types",
)
if request.GET.get("csv"):
return self.list_export(queryset=queryset, file_format=FileFormatEnum.CSV)
Expand Down Expand Up @@ -500,13 +501,11 @@ def validate_projects(self, request, profile):
return result

def validate_editable_org_unit_types(self, request):
result = []
editable_org_unit_type_ids = request.data.get("editable_org_unit_type_ids", None)
if editable_org_unit_type_ids:
for editable_org_unit_type_id in editable_org_unit_type_ids:
item = get_object_or_404(OrgUnitType, pk=editable_org_unit_type_id)
result.append(item)
return result
editable_org_unit_type_ids = request.data.get("editable_org_unit_type_ids", [])
editable_org_unit_types = OrgUnitType.objects.filter(pk__in=editable_org_unit_type_ids)
if editable_org_unit_types.count() != len(editable_org_unit_type_ids):
raise ValidationError("Invalid editable org unit type submitted.")
return editable_org_unit_types

@staticmethod
def update_user_own_profile(request):
Expand Down
21 changes: 13 additions & 8 deletions iaso/models/base.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import datetime
import mimetypes
import operator
import random
import re
Expand All @@ -15,7 +13,6 @@
from bs4 import BeautifulSoup as Soup # type: ignore
from django import forms as dj_forms
from django.contrib import auth
from django.contrib.auth import models as authModels
from django.contrib.auth.models import AnonymousUser, User
from django.contrib.gis.db.models.fields import PointField
from django.contrib.gis.geos import Point
Expand All @@ -33,7 +30,6 @@
from phonenumber_field.modelfields import PhoneNumberField
from phonenumbers.phonenumberutil import region_code_for_number

from hat.audit.models import INSTANCE_API, log_modification
from hat.menupermissions.constants import MODULES
from iaso.models.data_source import DataSource, SourceVersion
from iaso.models.org_unit import OrgUnit, OrgUnitReferenceInstance
Expand All @@ -43,7 +39,6 @@
from .. import periods
from ..utils.emoji import fix_emoji
from ..utils.jsonlogic import jsonlogic_to_q
from ..utils.models.common import get_creator_name
from .device import Device, DeviceOwnership
from .forms import Form, FormVersion
from .project import Project
Expand Down Expand Up @@ -1467,7 +1462,7 @@ def as_dict(self, small=False):
"phone_number": self.phone_number.as_e164 if self.phone_number else None,
"country_code": region_code_for_number(self.phone_number).lower() if self.phone_number else None,
"projects": [p.as_dict() for p in self.projects.all().order_by("name")],
"editable_org_unit_type_ids": list(self.editable_org_unit_types.values_list("id", flat=True)),
"editable_org_unit_type_ids": [out.pk for out in self.editable_org_unit_types.all()],
}
else:
return {
Expand All @@ -1490,7 +1485,7 @@ def as_dict(self, small=False):
"phone_number": self.phone_number.as_e164 if self.phone_number else None,
"country_code": region_code_for_number(self.phone_number).lower() if self.phone_number else None,
"projects": [p.as_dict() for p in self.projects.all()],
"editable_org_unit_type_ids": list(self.editable_org_unit_types.values_list("id", flat=True)),
"editable_org_unit_type_ids": [out.pk for out in self.editable_org_unit_types.all()],
}

def as_short_dict(self):
Expand All @@ -1504,7 +1499,7 @@ def as_short_dict(self):
"user_id": self.user.id,
"phone_number": self.phone_number.as_e164 if self.phone_number else None,
"country_code": region_code_for_number(self.phone_number).lower() if self.phone_number else None,
"editable_org_unit_type_ids": list(self.editable_org_unit_types.values_list("id", flat=True)),
"editable_org_unit_type_ids": [out.pk for out in self.editable_org_unit_types.all()],
}

def has_a_team(self):
Expand All @@ -1513,6 +1508,16 @@ def has_a_team(self):
return True
return False

def has_org_unit_write_permission(
self, org_unit_type_id: int, prefetched_editable_org_unit_type_ids: list = None
) -> bool:
editable_org_unit_type_ids = prefetched_editable_org_unit_type_ids or list(
self.editable_org_unit_types.values_list("id", flat=True)
)
if not editable_org_unit_type_ids:
return True
return org_unit_type_id in editable_org_unit_type_ids


class ExportRequest(models.Model):
id = models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")
Expand Down
23 changes: 22 additions & 1 deletion iaso/tasks/org_units_bulk_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,25 @@ def org_units_bulk_update(
raise Exception("Modification on read only source are not allowed")

total = queryset.count()
editable_org_unit_type_ids = list(user.iaso_profile.editable_org_unit_types.values_list("id", flat=True))
skipped_messages = []

# FIXME Task don't handle rollback properly if task is killed by user or other error
with transaction.atomic():
for index, org_unit in enumerate(queryset.iterator()):
if org_unit.org_unit_type and not user.iaso_profile.has_org_unit_write_permission(
org_unit_type_id=org_unit.org_unit_type.pk,
prefetched_editable_org_unit_type_ids=editable_org_unit_type_ids,
):
skipped_messages.append(
(
f"Org unit `{org_unit.name}` (#{org_unit.pk}) silently skipped "
f"because user `{user.username}` (#{user.pk}) cannot edit "
f"an org unit of type `{org_unit.org_unit_type.name}` (#{org_unit.org_unit_type.pk})."
)
)
continue

res_string = "%.2f sec, processed %i org units" % (time() - start, index)
task.report_progress_and_stop_if_killed(progress_message=res_string, end_value=total, progress_value=index)
update_single_unit_from_bulk(
Expand All @@ -96,4 +111,10 @@ def org_units_bulk_update(
groups_ids_removed=groups_ids_removed,
)

task.report_success(message="%d modified" % total)
message = f"{total} modified"
if skipped_messages:
message = f"{total - len(skipped_messages)} modified\n"
message += f"{len(skipped_messages)} skipped\n"
message += "\n".join(skipped_messages)

task.report_success(message=message)
Loading

0 comments on commit 272a624

Please sign in to comment.