Skip to content

Commit 8ee84b9

Browse files
authored
Merge pull request #175 from vintasoftware/feat/self-service-booking-policies
feat(calendar): self-service booking policies for members
2 parents a6f2257 + 3090024 commit 8ee84b9

3 files changed

Lines changed: 251 additions & 6 deletions

File tree

calendar_integration/permissions.py

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,118 @@
66
from organizations.models import get_active_organization_membership
77

88

9+
def _member_can_manage_booking_policy_target(
10+
*,
11+
user,
12+
membership,
13+
organization_id: int,
14+
calendar_id=None,
15+
membership_user_id=None,
16+
calendar_group_id=None,
17+
is_organization_default: bool = False,
18+
) -> bool:
19+
"""Decide whether ``user`` may create/update/delete a policy for a target.
20+
21+
Single source of truth for the self-service rule, shared by the create-time
22+
``has_permission`` (target read from the request body) and the object-level
23+
``has_object_permission`` (target read from the existing policy row):
24+
25+
- **Org admins** may manage a policy for **any** target.
26+
- A **non-admin member** may manage only their *own* personal policies:
27+
- a ``calendar`` policy for a calendar they **own** (an active
28+
``CalendarOwnership`` links their membership to it), or
29+
- a ``membership`` policy for **their own** membership
30+
(``membership_user_id == user.id``).
31+
- ``calendar_group`` and ``is_organization_default`` policies are **admin
32+
only** — a non-admin never reaches a grant branch for them.
33+
"""
34+
if membership is None:
35+
return False
36+
if membership.is_admin:
37+
return True
38+
39+
# Non-admin: only self-owned calendar or own-membership targets.
40+
if calendar_id is not None:
41+
return (
42+
CalendarOwnership.objects.filter_by_organization(organization_id)
43+
.filter(membership_user_id=user.id, calendar_fk_id=calendar_id)
44+
.exists()
45+
)
46+
if membership_user_id is not None:
47+
return int(membership_user_id) == user.id
48+
49+
# calendar_group / is_organization_default (or no recognizable target) → admin only.
50+
return False
51+
52+
953
class BookingPolicyPermission(BasePermission):
1054
"""Permission for ``BookingPolicyViewSet``.
1155
1256
Reads (GET/HEAD/OPTIONS — list/retrieve) are open to any authenticated user;
1357
``get_queryset()`` already restricts visibility to the caller's org.
1458
15-
Writes (POST/PUT/PATCH/DELETE — create/update/destroy) require the caller to
16-
be an **organization admin** (per SPEC use-case 3, consistent with sibling
17-
org-wide config writes gated by ``IsOrganizationAdmin``).
59+
Writes (POST/PUT/PATCH/DELETE — create/update/destroy) are **self-service**:
60+
61+
- Org admins may manage policies for any target (calendar, membership,
62+
calendar group, or the organization default).
63+
- Non-admin members may manage only their **own** personal policies — a
64+
policy targeting a calendar they own, or their own membership. Policies for
65+
calendar groups and the organization default stay **admin only**.
66+
67+
The per-target decision lives in ``_member_can_manage_booking_policy_target``.
68+
Create reads the target from the request body here; update/delete read it from
69+
the existing policy row in ``has_object_permission``.
1870
1971
Membership-less (gated) users are allowed through on safe methods: the
2072
queryset returns [] rather than 403, which is the consistent pattern used by
2173
``CalendarEventViewSet`` and ``BlockedTimeViewSet``.
2274
"""
2375

2476
def has_permission(self, request, view) -> bool:
25-
"""Safe methods: any authenticated user. Unsafe methods: org admin only."""
77+
"""Safe methods: any authenticated user. Unsafe methods: self or admin."""
2678
if not request.user or not request.user.is_authenticated:
2779
return False
2880
if request.method in SAFE_METHODS:
2981
return True
82+
3083
membership = get_active_organization_membership(request.user)
31-
return membership is not None and membership.is_admin
84+
if membership is None:
85+
return False
86+
if membership.is_admin:
87+
return True
88+
89+
# Detail writes (update/delete) are gated per-object in
90+
# ``has_object_permission`` — allow a non-admin member to proceed to it.
91+
if request.method not in ("POST",):
92+
return True
93+
94+
# Create: the target lives in the request body.
95+
return _member_can_manage_booking_policy_target(
96+
user=request.user,
97+
membership=membership,
98+
organization_id=membership.organization_id,
99+
calendar_id=request.data.get("calendar"),
100+
membership_user_id=request.data.get("membership_user_id"),
101+
calendar_group_id=request.data.get("calendar_group"),
102+
is_organization_default=bool(request.data.get("is_organization_default", False)),
103+
)
104+
105+
def has_object_permission(self, request, view, obj) -> bool:
106+
"""Detail writes: admins always; members only for their own target."""
107+
if request.method in SAFE_METHODS:
108+
return True
109+
membership = get_active_organization_membership(request.user)
110+
if membership is None or obj.organization_id != membership.organization_id:
111+
return False
112+
return _member_can_manage_booking_policy_target(
113+
user=request.user,
114+
membership=membership,
115+
organization_id=obj.organization_id,
116+
calendar_id=obj.calendar_fk_id,
117+
membership_user_id=obj.membership_user_id,
118+
calendar_group_id=obj.calendar_group_fk_id,
119+
is_organization_default=obj.is_organization_default,
120+
)
32121

33122

34123
class ExternalEventChangeRequestPermission(BasePermission):

calendar_integration/tests/test_booking_policy_api.py

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@
2222
from rest_framework.test import APIClient
2323

2424
from calendar_integration.factories import create_booking_policy
25-
from calendar_integration.models import BookingPolicy, Calendar, CalendarGroup
25+
from calendar_integration.models import (
26+
BookingPolicy,
27+
Calendar,
28+
CalendarGroup,
29+
CalendarOwnership,
30+
)
2631
from organizations.models import Organization, OrganizationMembership, OrganizationRole
2732
from users.factories import UserFactory
2833

@@ -606,3 +611,149 @@ def test_unauthenticated_delete_returns_401(self):
606611
client = APIClient()
607612
response = client.delete(_detail_url(1))
608613
assert response.status_code == status.HTTP_401_UNAUTHORIZED
614+
615+
616+
def _own(org: Organization, calendar: Calendar, membership: OrganizationMembership) -> None:
617+
"""Link ``membership`` to ``calendar`` as an owner."""
618+
CalendarOwnership.objects.create(
619+
organization=org,
620+
calendar=calendar,
621+
membership_user_id=membership.user_id,
622+
is_default=True,
623+
)
624+
625+
626+
@pytest.mark.django_db
627+
class TestBookingPolicySelfService:
628+
"""Non-admin members manage their OWN personal/calendar policies; group and
629+
organization-default policies stay admin-only."""
630+
631+
# -- create ------------------------------------------------------------
632+
633+
def test_member_can_create_policy_for_owned_calendar(self):
634+
org, membership = _make_org_with_member(is_admin=False)
635+
cal = _make_calendar(org)
636+
_own(org, cal, membership)
637+
client = _auth_client(membership)
638+
639+
response = client.post(_list_url(), {"calendar": cal.pk, "lead_time_seconds": 60})
640+
641+
assert response.status_code == status.HTTP_201_CREATED, response.json()
642+
assert response.json()["lead_time_seconds"] == 60
643+
644+
def test_member_cannot_create_policy_for_unowned_calendar(self):
645+
org, membership = _make_org_with_member(is_admin=False)
646+
cal = _make_calendar(org) # not owned by this member
647+
client = _auth_client(membership)
648+
649+
response = client.post(_list_url(), {"calendar": cal.pk, "lead_time_seconds": 60})
650+
651+
assert response.status_code == status.HTTP_403_FORBIDDEN
652+
653+
def test_member_can_create_policy_for_own_membership(self):
654+
_, membership = _make_org_with_member(is_admin=False)
655+
client = _auth_client(membership)
656+
657+
response = client.post(
658+
_list_url(),
659+
{"membership_user_id": membership.user_id, "lead_time_seconds": 120},
660+
)
661+
662+
assert response.status_code == status.HTTP_201_CREATED, response.json()
663+
664+
def test_member_cannot_create_policy_for_another_membership(self):
665+
org, membership = _make_org_with_member(is_admin=False)
666+
other_user = UserFactory().create_user()
667+
other_membership = OrganizationMembership.objects.create(
668+
user=other_user, organization=org, role=OrganizationRole.MEMBER, is_active=True
669+
)
670+
client = _auth_client(membership)
671+
672+
response = client.post(
673+
_list_url(),
674+
{"membership_user_id": other_membership.user_id, "lead_time_seconds": 120},
675+
)
676+
677+
assert response.status_code == status.HTTP_403_FORBIDDEN
678+
679+
def test_member_cannot_create_calendar_group_policy(self):
680+
org, membership = _make_org_with_member(is_admin=False)
681+
group = _make_group(org)
682+
client = _auth_client(membership)
683+
684+
response = client.post(_list_url(), {"calendar_group": group.pk, "lead_time_seconds": 60})
685+
686+
assert response.status_code == status.HTTP_403_FORBIDDEN
687+
688+
def test_member_cannot_create_org_default_policy(self):
689+
_, membership = _make_org_with_member(is_admin=False)
690+
client = _auth_client(membership)
691+
692+
response = client.post(
693+
_list_url(), {"is_organization_default": True, "lead_time_seconds": 60}
694+
)
695+
696+
assert response.status_code == status.HTTP_403_FORBIDDEN
697+
698+
def test_admin_can_create_group_and_org_default(self):
699+
org, membership = _make_org_with_member(is_admin=True)
700+
group = _make_group(org)
701+
client = _auth_client(membership)
702+
703+
group_resp = client.post(_list_url(), {"calendar_group": group.pk, "lead_time_seconds": 60})
704+
org_resp = client.post(
705+
_list_url(), {"is_organization_default": True, "lead_time_seconds": 60}
706+
)
707+
708+
assert group_resp.status_code == status.HTTP_201_CREATED, group_resp.json()
709+
assert org_resp.status_code == status.HTTP_201_CREATED, org_resp.json()
710+
711+
# -- update ------------------------------------------------------------
712+
713+
def test_member_can_update_own_calendar_policy(self):
714+
org, membership = _make_org_with_member(is_admin=False)
715+
cal = _make_calendar(org)
716+
_own(org, cal, membership)
717+
policy = create_booking_policy(calendar=cal, lead_time_seconds=60)
718+
client = _auth_client(membership)
719+
720+
response = client.patch(_detail_url(policy.pk), {"lead_time_seconds": 999})
721+
722+
assert response.status_code == status.HTTP_200_OK, response.json()
723+
assert response.json()["lead_time_seconds"] == 999
724+
725+
def test_member_cannot_update_group_policy(self):
726+
org, membership = _make_org_with_member(is_admin=False)
727+
group = _make_group(org)
728+
policy = create_booking_policy(calendar_group=group, lead_time_seconds=60)
729+
client = _auth_client(membership)
730+
731+
response = client.patch(_detail_url(policy.pk), {"lead_time_seconds": 999})
732+
733+
assert response.status_code == status.HTTP_403_FORBIDDEN
734+
735+
# -- delete ------------------------------------------------------------
736+
737+
def test_member_can_delete_own_calendar_policy(self):
738+
org, membership = _make_org_with_member(is_admin=False)
739+
cal = _make_calendar(org)
740+
_own(org, cal, membership)
741+
policy = create_booking_policy(calendar=cal)
742+
client = _auth_client(membership)
743+
744+
response = client.delete(_detail_url(policy.pk))
745+
746+
assert response.status_code == status.HTTP_204_NO_CONTENT
747+
assert (
748+
not BookingPolicy.objects.filter_by_organization(org.id).filter(pk=policy.pk).exists()
749+
)
750+
751+
def test_member_cannot_delete_org_default_policy(self):
752+
org, membership = _make_org_with_member(is_admin=False)
753+
policy = create_booking_policy(is_organization_default=True, organization=org)
754+
client = _auth_client(membership)
755+
756+
response = client.delete(_detail_url(policy.pk))
757+
758+
assert response.status_code == status.HTTP_403_FORBIDDEN
759+
assert BookingPolicy.objects.filter_by_organization(org.id).filter(pk=policy.pk).exists()

calendar_integration/views.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2171,6 +2171,11 @@ def destroy(
21712171
except BookingPolicy.DoesNotExist:
21722172
policy = None
21732173

2174+
# Absent policy → idempotent no-op (204). A present policy the caller may
2175+
# not manage → 403 (non-admin deleting a group/org/other-member policy).
2176+
if policy is not None:
2177+
self.check_object_permissions(request, policy)
2178+
21742179
service.delete_booking_policy(policy)
21752180
return Response(status=status.HTTP_204_NO_CONTENT)
21762181

0 commit comments

Comments
 (0)