Skip to content

Dev server#3031

Merged
awindsr merged 2 commits into
productionfrom
dev-server
Jul 19, 2026
Merged

Dev server#3031
awindsr merged 2 commits into
productionfrom
dev-server

Conversation

@awindsr

@awindsr awindsr commented Jul 19, 2026

Copy link
Copy Markdown
Member

No description provided.

awindsr and others added 2 commits July 19, 2026 22:12
@awindsr
awindsr merged commit eabd994 into production Jul 19, 2026
3 checks passed
@greptile-apps

greptile-apps Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR widens access to InterestGroupGetAPI so that any IG lead (platform-wide or per-IG) can read and partially update interest groups, not just Admins. It introduces _can_view_ig / _can_manage_ig role-check helpers and adds a new side-effect to the PATCH handler that syncs the {code} IGLead UserRoleLink entries whenever the leads field is updated.

  • The @role_required([RoleType.ADMIN.value]) decorator is removed from both GET and PATCH on InterestGroupGetAPI, replaced by inline JWT role checks using the new helpers.
  • PATCH gains a leads role-sync block that creates/revokes UserRoleLink rows and sends notifications, but this multi-table write is not wrapped in transaction.atomic.

Confidence Score: 3/5

The PATCH handler's multi-table write is unprotected: a mid-flight DB error leaves the IG updated while assigned leads never receive their role.

The leads sync writes to three tables plus sends notifications without an enclosing transaction. A failure after serializer.save() commits the IG change but leaves leads without the {code} IGLead role they were just assigned.

api/dashboard/ig/dash_ig_view.py — the InterestGroupGetAPI.patch handler where serializer save and leads role-sync side-effects should be wrapped together in transaction.atomic.

Important Files Changed

Filename Overview
api/dashboard/ig/dash_ig_view.py Broadens GET and PATCH permissions to IG leads via new helpers, and adds a lead role-sync side-effect in PATCH; the multi-table write is missing transaction.atomic and the caller User lookup is re-queried inside the leads loop.

Reviews (1): Last reviewed commit: "Merge pull request #3030 from gtech-mule..." | Re-trigger Greptile

Comment on lines 440 to +511
if serializer.is_valid():
serializer.save()

# ── Side-effect: sync the per-IG lead role with the leads list ──────
# Without this the Leads field is cosmetic: the muids are stored on
# the IG row, but the user never receives "{code} IGLead" and so
# cannot edit the IG they were just made lead of.
#
# When "leads" is present it is treated as the COMPLETE set of leads
# for this IG — anyone dropped from it loses the role. A payload
# without the key leaves lead roles untouched, so unrelated partial
# updates are safe. The frontend only sends "leads" when it changed.
if "leads" in request.data:
raw_leads = request.data.get("leads")
if isinstance(raw_leads, str):
try:
raw_leads = json.loads(raw_leads)
except Exception:
raw_leads = []
if not isinstance(raw_leads, list):
raw_leads = []

lead_role, _ = Role.objects.get_or_create(
title=RoleType.IG_LEAD_ROLE(ig.code),
defaults={
"id": str(uuid.uuid4()),
"description": f"{ig.name} Interest Group Lead",
"created_by_id": user_id,
"updated_by_id": user_id,
},
)

desired_lead_muids = {
(item.get("muid") if isinstance(item, dict) else item)
for item in raw_leads
}
desired_lead_muids.discard(None)
desired_lead_muids.discard("")

# Revoke first, so a lead moved out of the list loses access even
# if their replacement's muid turns out to be invalid. Deleted
# rather than deactivated to match the role-transfer logic in
# dash_campus_helper.assign_ig_campus_lead().
UserRoleLink.objects.filter(role=lead_role).exclude(
user__muid__in=desired_lead_muids
).delete()

for muid in desired_lead_muids:
target_user = User.objects.filter(muid=muid).first()
if not target_user:
continue

_, lead_link_created = UserRoleLink.objects.get_or_create(
user=target_user,
role=lead_role,
defaults={"verified": True, "created_by_id": user_id},
)

if lead_link_created:
caller = User.objects.filter(id=user_id).first()
caller_name = caller.full_name if caller else "An admin"
NotificationUtils.insert_notification(
user=target_user,
title=f"IG Lead: {ig.name}"[:50],
description=(
f"{caller_name} has assigned you as a Lead for "
f"{ig.name}. You can now edit this interest group."
)[:200],
button='View',
url=f'/interest-groups/{ig.id}/',
created_by=caller,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Multi-table write missing transaction.atomic

serializer.save() persists the IG update, then the leads role-sync block writes to Role, UserRoleLink, and Notification — all outside any transaction. If UserRoleLink.objects.get_or_create or NotificationUtils.insert_notification raises after the serializer save has committed, the IG row is updated but the assigned leads never receive the {code} IGLead role, leaving the system in a silently inconsistent state. The established pattern for multi-step writes in this codebase (see InterestGroupAPI.post) is to wrap everything in with transaction.atomic():.

Comment on lines +487 to +500
for muid in desired_lead_muids:
target_user = User.objects.filter(muid=muid).first()
if not target_user:
continue

_, lead_link_created = UserRoleLink.objects.get_or_create(
user=target_user,
role=lead_role,
defaults={"verified": True, "created_by_id": user_id},
)

if lead_link_created:
caller = User.objects.filter(id=user_id).first()
caller_name = caller.full_name if caller else "An admin"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 caller = User.objects.filter(id=user_id).first() is inside the for muid in desired_lead_muids loop and is re-executed for every newly assigned lead. If N leads are created in a single request, this fires N extra queries fetching the same row each time. The caller does not change between iterations — fetch it once before the loop.

Suggested change
for muid in desired_lead_muids:
target_user = User.objects.filter(muid=muid).first()
if not target_user:
continue
_, lead_link_created = UserRoleLink.objects.get_or_create(
user=target_user,
role=lead_role,
defaults={"verified": True, "created_by_id": user_id},
)
if lead_link_created:
caller = User.objects.filter(id=user_id).first()
caller_name = caller.full_name if caller else "An admin"
caller = User.objects.filter(id=user_id).first()
caller_name = caller.full_name if caller else "An admin"
for muid in desired_lead_muids:
target_user = User.objects.filter(muid=muid).first()
if not target_user:
continue
_, lead_link_created = UserRoleLink.objects.get_or_create(
user=target_user,
role=lead_role,
defaults={"verified": True, "created_by_id": user_id},
)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +388 to +391
if not _can_view_ig(roles):
return CustomResponse(
general_message="You do not have permission to view this Interest Group"
).get_failure_response()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Auth denial uses wrong response helper

Per the codebase conventions, permission denials should return get_unauthorized_response() (HTTP 403), not get_failure_response() (HTTP 400). The same applies to the matching denial in patch at line 413. Using the wrong helper breaks the status-code contract that frontend clients rely on to distinguish "missing resource" from "forbidden".

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant