diff --git a/eap/permissions.py b/eap/permissions.py index 2b7b7ecfc..3b86f3146 100644 --- a/eap/permissions.py +++ b/eap/permissions.py @@ -1,8 +1,8 @@ -from django.contrib.auth.models import Permission from rest_framework.permissions import BasePermission from api.models import Country from eap.models import EAPRegistration +from eap.utils import get_admin_ids def has_country_permission( @@ -12,15 +12,7 @@ def has_country_permission( if user.is_superuser or user.has_perm("api.ifrc_admin"): return True - country_admin_ids = [ - int(codename.replace("country_admin_", "")) - for codename in Permission.objects.filter( - group__user=user, - codename__startswith="country_admin_", - ).values_list("codename", flat=True) - ] - - return national_society_id in country_admin_ids + return national_society_id in get_admin_ids(user, "country_admin_") def has_regional_permission( @@ -30,22 +22,23 @@ def has_regional_permission( if user.is_superuser or user.has_perm("api.ifrc_admin"): return True - regional_admin_ids = [ - int(codename.replace("region_admin_", "")) - for codename in Permission.objects.filter( - group__user=user, - codename__startswith="region_admin_", - ).values_list("codename", flat=True) - ] + return region_id in get_admin_ids(user, "region_admin_") + - return region_id in regional_admin_ids +def has_creator_or_shared_permission( + user, + eap_registration: EAPRegistration, +) -> bool: + # NOTE: The creator and the users the EAP is shared with can update it, same as in Dref. + return eap_registration.created_by_id == user.id or eap_registration.users.filter(id=user.id).exists() class EAPRegistrationPermissions(BasePermission): message = "You need to be country admin or IFRC admin or superuser to create/update EAP Registration" def has_permission(self, request, view) -> bool: - if request.method not in ["PUT", "PATCH", "POST"]: + # NOTE: Updates are checked per object instead, so shared users are allowed through. + if request.method != "POST": return True user = request.user @@ -63,30 +56,65 @@ def has_permission(self, request, view) -> bool: ) ) - -class EAPBasePermission(BasePermission): - message = "You don't have permission to create/update EAP" - def has_object_permission(self, request, view, obj) -> bool: if request.method not in ["PUT", "PATCH", "POST"]: return True user = request.user - eap_reg_id = request.data.get("eap_registration", None) or obj.eap_registration_id - eap_registration = EAPRegistration.objects.filter(id=eap_reg_id).first() - assert eap_registration is not None, "EAP Registration does not exist" - national_society_id = eap_registration.national_society_id + return ( + user.is_superuser + or has_creator_or_shared_permission(user=user, eap_registration=obj) + or has_country_permission(user=user, national_society_id=obj.national_society_id) + or has_regional_permission( + user=user, + region_id=obj.national_society.region.pk, + ) + ) + + +class EAPBasePermission(BasePermission): + message = "You don't have permission to create/update EAP" + + def can_write(self, user, eap_reg_id) -> bool: + eap_registration = EAPRegistration.objects.filter(id=eap_reg_id).first() + if not eap_registration: + return False return ( user.is_superuser - or has_country_permission(user=user, national_society_id=national_society_id) + or has_creator_or_shared_permission(user=user, eap_registration=eap_registration) + or has_country_permission(user=user, national_society_id=eap_registration.national_society_id) or has_regional_permission( user=user, region_id=eap_registration.national_society.region.pk, ) ) + def has_permission(self, request, view) -> bool: + # NOTE: has_object_permission is never called on create, so the EAP Registration in the + # payload has to be checked here. + if request.method != "POST": + return True + + return self.can_write(request.user, request.data.get("eap_registration")) + + def has_object_permission(self, request, view, obj) -> bool: + if request.method not in ["PUT", "PATCH", "POST"]: + return True + + return self.can_write( + request.user, + obj.eap_registration_id, + ) + + +class EAPRevisePermission(EAPBasePermission): + message = "You don't have permission to revise this EAP" + + def has_permission(self, request, view) -> bool: + return True + class EAPValidatedBudgetPermission(BasePermission): message = "You don't have permission to upload validated budget file for this EAP" @@ -96,3 +124,17 @@ def has_permission(self, request, view) -> bool: if user.is_superuser or user.has_perm("api.ifrc_admin"): return True return False + + +def has_submit_permission( + user, + eap_registration: EAPRegistration, +) -> bool: + # NOTE: Submitting for review is open to the same people who can edit the EAP: the + # National Society's admins, its region's admins, the creator and the shared users. + return ( + user.is_superuser + or has_creator_or_shared_permission(user=user, eap_registration=eap_registration) + or has_country_permission(user=user, national_society_id=eap_registration.national_society_id) + or has_regional_permission(user=user, region_id=eap_registration.national_society.region_id) + ) diff --git a/eap/serializers.py b/eap/serializers.py index 6d348d527..c3a771179 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -41,6 +41,7 @@ TimeFrame, YearsTimeFrameChoices, ) +from eap.permissions import has_submit_permission from eap.tasks import ( generate_eap_summary_pdf, generate_export_diff_pdf, @@ -56,7 +57,6 @@ send_technical_validation_email, ) from eap.utils import ( - has_country_permission, is_user_ifrc_admin, validate_file_extention, validate_file_object, @@ -1024,7 +1024,7 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t if (current_status, new_status) not in valid_transitions: raise serializers.ValidationError( - gettext("EAP status cannot be changed from %s to %s.") + gettext("You cannot change EAP status from %s to %s.") % (EAPRegistration.Status(current_status).label, EAPRegistration.Status(new_status).label) ) @@ -1039,6 +1039,11 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t EAPRegistration.Status.UNDER_DEVELOPMENT, EAPRegistration.Status.UNDER_REVIEW, ): + if not has_submit_permission(user, self.instance): + raise PermissionDenied( + gettext("You do not have permission to change status to %s.") % EAPRegistration.Status(new_status).label + ) + if self.instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: self.instance.latest_simplified_eap.is_locked = True self.instance.latest_simplified_eap.save(update_fields=["is_locked"]) @@ -1118,7 +1123,7 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t EAPRegistration.Status.NS_ADDRESSING_COMMENTS, EAPRegistration.Status.UNDER_REVIEW, ): - if not (has_country_permission(user, self.instance.national_society_id) or is_user_ifrc_admin(user)): + if not has_submit_permission(user, self.instance): raise PermissionDenied( gettext("You do not have permission to change status to %s.") % EAPRegistration.Status(new_status).label ) diff --git a/eap/test_views.py b/eap/test_views.py index a7c5d242a..83cd6f83b 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -14,7 +14,7 @@ from api.factories.disaster_type import DisasterTypeFactory from api.factories.district import DistrictFactory from api.factories.region import RegionFactory -from api.models import Export +from api.models import Export, RegionName from deployments.factories.user import UserFactory from eap.factories import ( EAPFileFactory, @@ -32,6 +32,7 @@ DaysTimeFrameChoices, EAPAction, EAPFile, + EAPRegistration, EAPStatus, EAPType, EmailRecipient, @@ -43,6 +44,7 @@ TimeFrame, YearsTimeFrameChoices, ) +from eap.permissions import has_submit_permission from main.test_case import APITestCase @@ -128,7 +130,7 @@ def test_list_eap_registration(self): modified_by=self.country_admin, ) url = "/api/v2/eap-registration/" - self.authenticate() + self.authenticate(self.country_admin) response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data["results"]), 5) @@ -390,7 +392,7 @@ def test_active_eaps(self): eap_registration_2.save() url = "/api/v2/active-eap/" - self.authenticate() + self.authenticate(self.country_admin) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(len(response.data["results"]), 2, response.data["results"]) @@ -429,7 +431,7 @@ def test_share_eap(self, send_eap_share_email): user3.id, ], } - self.authenticate() + self.authenticate(self.country_admin) with self.capture_on_commit_callbacks(execute=True): response = self.client.post(url, data, format="json") @@ -467,7 +469,7 @@ def test_share_eap(self, send_eap_share_email): # NOTE: test list of EAP Share Users url = "/api/v2/eap-share-users/" - self.authenticate() + self.authenticate(self.country_admin) response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertEqual(response.data["count"], 1, response.data) @@ -477,7 +479,7 @@ def test_share_eap(self, send_eap_share_email): # NOTE: test with filter by EAP Registration Id url = f"/api/v2/eap-share-users/?id={eap_registration.id}" - self.authenticate() + self.authenticate(self.country_admin) response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertEqual(response.data["count"], 1, response.data) @@ -531,7 +533,7 @@ def test_list_simplified_eap(self): ) url = "/api/v2/simplified-eap/" - self.authenticate() + self.authenticate(self.country_admin) response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data["results"]), 5) @@ -2462,7 +2464,7 @@ def test_list_full_eap(self): ) url = "/api/v2/full-eap/" - self.authenticate() + self.authenticate(self.country_admin) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(len(response.data["results"]), 5) @@ -2965,8 +2967,8 @@ def test_snapshot_full_eap(self): ) # M2M districts deeply cloned (each selection carries its own description) - orig_districts = list(original.districts.all()) - snapshot_districts = list(snapshot.districts.all()) + orig_districts = list(original.districts.order_by("id")) + snapshot_districts = list(snapshot.districts.order_by("id")) self.assertEqual(len(orig_districts), len(snapshot_districts)) self.assertNotEqual(orig_districts[0].pk, snapshot_districts[0].pk) self.assertEqual( @@ -2979,8 +2981,8 @@ def test_snapshot_full_eap(self): ) # M2M Actors deeply cloned - orig_actors = list(original.key_actors.all()) - snapshot_actors = list(snapshot.key_actors.all()) + orig_actors = list(original.key_actors.order_by("id")) + snapshot_actors = list(snapshot.key_actors.order_by("id")) self.assertEqual(len(orig_actors), len(snapshot_actors)) self.assertNotEqual(orig_actors[0].pk, snapshot_actors[0].pk) self.assertEqual( @@ -2993,8 +2995,9 @@ def test_snapshot_full_eap(self): ) # M2M early actions deeply cloned - orig_early_actions = list(original.early_actions.all()) - snapshot_early_actions = list(snapshot.early_actions.all()) + # NOTE: EAPAction has no default ordering, so order explicitly before comparing. + orig_early_actions = list(original.early_actions.order_by("id")) + snapshot_early_actions = list(snapshot.early_actions.order_by("id")) self.assertEqual(len(orig_early_actions), len(snapshot_early_actions)) self.assertNotEqual(orig_early_actions[0].pk, snapshot_early_actions[0].pk) self.assertEqual( @@ -3007,21 +3010,25 @@ def test_snapshot_full_eap(self): self.assertFalse(snapshot.review_checklist_file) # Assert previous_id for all M2M objects - for orig, snap in zip(original.key_actors.all(), snapshot.key_actors.all()): + # NOTE: these M2Ms have no default ordering, so pair them by id rather than by + # whatever order the database happens to return. + for orig, snap in zip(original.key_actors.order_by("id"), snapshot.key_actors.order_by("id")): self.assertEqual(snap.previous_id, orig.pk) - for orig, snap in zip(original.enabling_approaches.all(), snapshot.enabling_approaches.all()): + for orig, snap in zip(original.enabling_approaches.order_by("id"), snapshot.enabling_approaches.order_by("id")): self.assertEqual(snap.previous_id, orig.pk) - for orig, snap in zip(original.early_actions.all(), snapshot.early_actions.all()): + for orig, snap in zip(original.early_actions.order_by("id"), snapshot.early_actions.order_by("id")): self.assertEqual(snap.previous_id, orig.pk) - for orig, snap in zip(original.districts.all(), snapshot.districts.all()): + for orig, snap in zip(original.districts.order_by("id"), snapshot.districts.order_by("id")): self.assertEqual(snap.previous_id, orig.pk) - for orig_op, snap_op in zip(original.planned_operations.all(), snapshot.planned_operations.all()): + for orig_op, snap_op in zip(original.planned_operations.order_by("id"), snapshot.planned_operations.order_by("id")): self.assertEqual(snap_op.previous_id, orig_op.pk) - for orig_act, snap_act in zip(orig_op.readiness_activities.all(), snap_op.readiness_activities.all()): + for orig_act, snap_act in zip( + orig_op.readiness_activities.order_by("id"), snap_op.readiness_activities.order_by("id") + ): self.assertEqual(snap_act.previous_id, orig_act.pk) @@ -3142,3 +3149,160 @@ def test_unique_constraint(self): # full_clean won't catch DB constraint, so test save with self.assertRaises(IntegrityError): duplicate.save() + + +class EAPVisibilityTestCase(APITestCase): + """A user only sees and edits the EAPs of their own National Society.""" + + def setUp(self): + super().setUp() + self.region_1 = RegionFactory.create(name=RegionName.ASIA_PACIFIC) + self.region_2 = RegionFactory.create(name=RegionName.EUROPE) + + self.ns_1 = CountryFactory.create(name="ns-1", iso3="NS1", iso="N1", region=self.region_1) + self.ns_2 = CountryFactory.create(name="ns-2", iso3="NS2", iso="N2", region=self.region_2) + self.disaster_type = DisasterTypeFactory.create(name="disaster1") + + management.call_command("make_permissions") + + self.ns_1_admin = self._create_admin("country_admin_%s" % self.ns_1.id, "%s Admins" % self.ns_1.name) + self.ns_2_admin = self._create_admin("country_admin_%s" % self.ns_2.id, "%s Admins" % self.ns_2.name) + self.region_1_admin = self._create_admin("region_admin_%s" % self.region_1.id, "%s Regional Admins" % self.region_1.name) + + # NOTE: self.ifrc_user from the base test case is a superuser, so use a plain user + # holding only the ifrc_admin permission to cover the non-superuser path. + self.ifrc_admin = UserFactory.create() + self.ifrc_admin.user_permissions.add(Permission.objects.get(codename="ifrc_admin")) + + self.shared_user = UserFactory.create() + self.outsider = UserFactory.create() + + self.eap_1 = self._create_eap(self.ns_1, self.ns_1_admin) + self.eap_2 = self._create_eap(self.ns_2, self.ns_2_admin) + self.eap_1.users.add(self.shared_user) + + def _create_admin(self, codename, group_name): + user = UserFactory.create() + user.user_permissions.add(Permission.objects.filter(codename=codename).first()) + user.groups.add(Group.objects.filter(name=group_name).first()) + return user + + def _create_eap(self, national_society, created_by): + return EAPRegistrationFactory.create( + country=national_society, + national_society=national_society, + disaster_type=self.disaster_type, + eap_type=EAPType.SIMPLIFIED_EAP, + created_by=created_by, + modified_by=created_by, + ) + + def _list_ids(self, user, url="/api/v2/eap-registration/"): + self.authenticate(user) + response = self.client.get(url) + self.assertEqual(response.status_code, 200, response.data) + return {result["id"] for result in response.data["results"]} + + def test_registration_visibility_per_role(self): + both = {self.eap_1.id, self.eap_2.id} + for user, expected in [ + (self.ns_1_admin, {self.eap_1.id}), + (self.ns_2_admin, {self.eap_2.id}), + (self.region_1_admin, {self.eap_1.id}), + (self.shared_user, {self.eap_1.id}), + (self.outsider, set()), + (self.ifrc_admin, both), + (self.root_user, both), + ]: + with self.subTest(user=user.id): + self.assertEqual(self._list_ids(user), expected) + + # Out of scope means 404, not 403, so ids cannot be probed + self.authenticate(self.ns_2_admin) + self.assertEqual(self.client.get(f"/api/v2/eap-registration/{self.eap_1.id}/").status_code, 404) + + # active-eap is scoped the same way + EAPRegistration.objects.filter(id__in=both).update(status=EAPStatus.PROJECT_AGREEMENT_SIGNED) + self.assertEqual(self._list_ids(self.ns_1_admin, "/api/v2/active-eap/"), {self.eap_1.id}) + + def test_application_visibility_follows_registration(self): + simplified_eap = SimplifiedEAPFactory.create( + eap_registration=self.eap_1, + created_by=self.ns_1_admin, + modified_by=self.ns_1_admin, + budget_file=EAPFileFactory._create_file(created_by=self.ns_1_admin, modified_by=self.ns_1_admin), + ) + FullEAPFactory.create( + eap_registration=self.eap_2, + created_by=self.ns_2_admin, + modified_by=self.ns_2_admin, + budget_file=EAPFileFactory._create_file(created_by=self.ns_2_admin, modified_by=self.ns_2_admin), + ) + + self.assertEqual(self._list_ids(self.shared_user, "/api/v2/simplified-eap/"), {simplified_eap.id}) + self.assertEqual(self._list_ids(self.shared_user, "/api/v2/full-eap/"), set()) + + self.authenticate(self.ns_2_admin) + self.assertEqual(self.client.get(f"/api/v2/simplified-eap/{simplified_eap.id}/").status_code, 404) + + def test_creator_and_shared_user_can_update(self): + url = f"/api/v2/eap-registration/{self.eap_1.id}/" + + self.authenticate(self.shared_user) + response = self.client.patch(url, {"disaster_sub_type": "by shared"}, format="json") + self.assertEqual(response.status_code, 200, response.data) + + # The creator keeps access even once their country admin group is revoked + self.ns_1_admin.groups.clear() + self.ns_1_admin.user_permissions.clear() + self.authenticate(self.ns_1_admin) + response = self.client.patch(url, {"disaster_sub_type": "by creator"}, format="json") + self.assertEqual(response.status_code, 200, response.data) + + self.authenticate(self.outsider) + response = self.client.patch(url, {"disaster_sub_type": "nope"}, format="json") + self.assertEqual(response.status_code, 404, response.data) + + self.eap_1.refresh_from_db() + self.assertEqual(self.eap_1.disaster_sub_type, "by creator") + + def test_submit_for_review_allows_everyone_with_write_access(self): + # NOTE: Both "submit for review" transitions share this check. They used to differ - + # the resubmit path omitted region admins, so the same person could submit a new EAP + # but not resubmit it after IFRC comments. + for user, expected in [ + (self.ns_1_admin, True), + (self.region_1_admin, True), + (self.shared_user, True), + (self.ifrc_admin, True), + (self.root_user, True), + (self.ns_2_admin, False), + (self.outsider, False), + ]: + with self.subTest(user=user.id): + self.assertIs(has_submit_permission(user, self.eap_1), expected) + + def test_creating_application_requires_access(self): + # NOTE: has_object_permission is not called on create, and creating an application locks + # the registration, so an unauthorised create would brick the National Society's EAP. + data = { + "eap_registration": self.eap_1.id, + "national_society_contact_name": "Name", + "national_society_contact_title": "Title", + "national_society_contact_email": "contact@example.com", + "seap_timeframe": 1, + "potential_risks": [], + "early_actions": [], + } + + for user in [self.outsider, self.ns_2_admin]: + with self.subTest(user=user.id): + self.authenticate(user) + response = self.client.post("/api/v2/simplified-eap/", data, format="json") + self.assertEqual(response.status_code, 403, response.data) + + self.assertFalse(SimplifiedEAP.objects.filter(eap_registration=self.eap_1).exists()) + + self.authenticate(self.ns_1_admin) + response = self.client.post("/api/v2/simplified-eap/", data, format="json") + self.assertEqual(response.status_code, 201, response.data) diff --git a/eap/utils.py b/eap/utils.py index 2d7da8c22..35fa3cc2a 100644 --- a/eap/utils.py +++ b/eap/utils.py @@ -14,6 +14,46 @@ from utils.file_check import validate_file_type +def get_admin_ids(user, codename_prefix: str) -> list[int]: + """Ids of the countries/regions the user administers, eg. "country_admin_", "region_admin_".""" + return [ + int(codename.replace(codename_prefix, "")) + for codename in Permission.objects.filter( + group__user=user, + codename__startswith=codename_prefix, + ).values_list("codename", flat=True) + ] + + +def filter_eap_registration_queryset_by_user_access(user, queryset): + """ + EAP Registrations the user can see: their own National Society's, the ones of the + regions they administer, the ones they created and the ones shared with them. + """ + if user.is_superuser or user.has_perm("api.ifrc_admin"): + return queryset + + return queryset.filter( + models.Q(created_by=user) + | models.Q(users=user) + | models.Q(national_society__in=get_admin_ids(user, "country_admin_")) + | models.Q(national_society__region__in=get_admin_ids(user, "region_admin_")) + ).distinct() + + +def filter_eap_queryset_by_user_access(user, queryset): + """Same rule for SimplifiedEAP/FullEAP, which follow their EAP Registration.""" + if user.is_superuser or user.has_perm("api.ifrc_admin"): + return queryset + + return queryset.filter( + models.Q(eap_registration__created_by=user) + | models.Q(eap_registration__users=user) + | models.Q(eap_registration__national_society__in=get_admin_ids(user, "country_admin_")) + | models.Q(eap_registration__national_society__region__in=get_admin_ids(user, "region_admin_")) + ).distinct() + + def get_emails_by_type(email_type: EmailRecipient.EmailType) -> list[str]: return list(EmailRecipient.objects.filter(type=email_type).values_list("email", flat=True)) diff --git a/eap/views.py b/eap/views.py index 19f1f3536..05e7ea8e5 100644 --- a/eap/views.py +++ b/eap/views.py @@ -28,6 +28,7 @@ from eap.permissions import ( EAPBasePermission, EAPRegistrationPermissions, + EAPRevisePermission, EAPValidatedBudgetPermission, ) from eap.serializers import ( @@ -43,6 +44,10 @@ MiniEAPSerializer, SimplifiedEAPSerializer, ) +from eap.utils import ( + filter_eap_queryset_by_user_access, + filter_eap_registration_queryset_by_user_access, +) from main.permissions import DenyGuestUserMutationPermission, DenyGuestUserPermission @@ -65,8 +70,7 @@ class ActiveEAPViewSet(viewsets.GenericViewSet, mixins.ListModelMixin): def get_queryset(self) -> QuerySet[EAPRegistration]: return ( - super() - .get_queryset() + filter_eap_registration_queryset_by_user_access(self.request.user, super().get_queryset()) .filter(status=EAPStatus.PROJECT_AGREEMENT_SIGNED) .select_related("disaster_type", "country") .annotate( @@ -98,8 +102,7 @@ class EAPRegistrationViewSet(EAPModelViewSet): def get_queryset(self) -> QuerySet[EAPRegistration]: base_qs = ( - super() - .get_queryset() + filter_eap_registration_queryset_by_user_access(self.request.user, super().get_queryset()) .select_related( "created_by", "modified_by", @@ -224,7 +227,12 @@ class EAPShareUserViewSet( filterset_class = EAPShareUserFilterSet def get_queryset(self) -> QuerySet[EAPRegistration]: - return super().get_queryset().prefetch_related("users").order_by("-created_at").distinct() + return ( + filter_eap_registration_queryset_by_user_access(self.request.user, super().get_queryset()) + .prefetch_related("users") + .order_by("-created_at") + .distinct() + ) class SimplifiedEAPViewSet(EAPModelViewSet): @@ -240,8 +248,7 @@ class SimplifiedEAPViewSet(EAPModelViewSet): def get_queryset(self) -> QuerySet[SimplifiedEAP]: return ( - super() - .get_queryset() + filter_eap_queryset_by_user_access(self.request.user, super().get_queryset()) .select_related( "created_by", "modified_by", @@ -310,7 +317,7 @@ def get_queryset(self) -> QuerySet[SimplifiedEAP]: permission_classes=[ permissions.IsAuthenticated, DenyGuestUserMutationPermission, - EAPBasePermission, + EAPRevisePermission, ], ) def revise( @@ -359,8 +366,7 @@ class FullEAPViewSet(EAPModelViewSet): def get_queryset(self) -> QuerySet[FullEAP]: return ( - super() - .get_queryset() + filter_eap_queryset_by_user_access(self.request.user, super().get_queryset()) .select_related( "created_by", "modified_by", @@ -436,7 +442,7 @@ def get_queryset(self) -> QuerySet[FullEAP]: permission_classes=[ permissions.IsAuthenticated, DenyGuestUserMutationPermission, - EAPBasePermission, + EAPRevisePermission, ], ) def revise( diff --git a/go-static/files/dref/budget_template.xlsm b/go-static/files/dref/budget_template.xlsm index b87f002f4..c1b485070 100644 Binary files a/go-static/files/dref/budget_template.xlsm and b/go-static/files/dref/budget_template.xlsm differ