-
Notifications
You must be signed in to change notification settings - Fork 11
refactor: nest role fields in UserProjectMembership API response #1096
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
bf08d8a
c60c2a7
78b10a0
a55dbdc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -274,6 +274,23 @@ def ensure_owner_membership(self): | |
| if self.owner and not self.members.filter(id=self.owner.pk).exists(): | ||
| self.members.add(self.owner) | ||
|
|
||
| def members_with_roles(self): | ||
| """ | ||
| Get users who are members of this project with a role assigned. | ||
|
|
||
| This filters out memberships where the user has no role groups assigned, | ||
| which indicates a data inconsistency. | ||
|
|
||
| Returns: | ||
| User queryset filtered to users with roles | ||
|
|
||
| Example: | ||
| project.members_with_roles() | ||
| project.members_with_roles().values_list('email', flat=True) | ||
| """ | ||
| memberships_with_roles = self.project_memberships.with_role() | ||
| return User.objects.filter(project_memberships__in=memberships_with_roles).distinct() | ||
|
|
||
| def deployments_count(self) -> int: | ||
| return self.deployments.count() | ||
|
|
||
|
|
@@ -489,6 +506,78 @@ class Meta: | |
| ] | ||
|
|
||
|
|
||
| class UserProjectMembershipQuerySet(BaseQuerySet): | ||
| """Custom queryset for UserProjectMembership with role filtering.""" | ||
|
|
||
| def with_role(self, log_invalid=False): | ||
| """ | ||
| Filter memberships to only include users who have a role assigned. | ||
|
|
||
| Memberships without roles indicate a data inconsistency between the | ||
| UserProjectMembership table and permission groups (roles). | ||
|
|
||
| Args: | ||
| log_invalid: If True, log warnings for memberships without roles | ||
|
|
||
| Returns: | ||
| Queryset filtered to members with at least one role for their project | ||
|
|
||
| Example: | ||
| # In a ViewSet: | ||
| UserProjectMembership.objects.filter(project=project).with_role(log_invalid=True) | ||
|
|
||
| # In a management command: | ||
| UserProjectMembership.objects.filter(project=project).with_role() | ||
| """ | ||
| from django.contrib.auth.models import Group | ||
|
|
||
| # TODO: Once we migrate to FK-based role-project associations (custom Group model | ||
| # with project_id FK), this method should be updated to use a direct FK join: | ||
| # | ||
| # has_role=Exists( | ||
| # Group.objects.filter( | ||
| # user=OuterRef('user'), | ||
| # project_id=OuterRef('project_id') # Direct FK instead of string parsing | ||
| # ) | ||
| # ) | ||
| # | ||
| # This will be more efficient and eliminate the need for name prefix matching. | ||
| # Related refactor: Remove string-based group naming pattern in ami/users/roles.py:24 | ||
| # Subquery: Check if user has ANY group for their project | ||
| # Groups follow naming pattern: {project_id}_{project_name}_{RoleClassName} | ||
| # See: ami/users/roles.py:24 (Role.get_group_name) | ||
| queryset = self.annotate( | ||
| has_role=Exists( | ||
| Group.objects.filter( | ||
| user=OuterRef("user"), | ||
| name__startswith=models.functions.Concat(OuterRef("project_id"), models.Value("_")), | ||
| ) | ||
| ) | ||
| ) | ||
|
|
||
| # Log invalid memberships if requested (before filtering them out) | ||
| if log_invalid: | ||
| invalid = queryset.filter(has_role=False).select_related("user", "project") | ||
| if invalid.exists(): | ||
| for membership in invalid: | ||
| logger.warning( | ||
| f"Data inconsistency detected: UserProjectMembership {membership.pk} " | ||
| f"for user '{membership.user.email}' in project '{membership.project.name}' " | ||
| f"(ID: {membership.project.pk}) has no role assigned. This indicates " | ||
| f"the permission groups are out of sync. " | ||
| f"Fix by running: python manage.py update_roles --project-id={membership.project.pk}" | ||
| ) | ||
|
Comment on lines
+558
to
+569
|
||
|
|
||
| # Return only members with valid roles | ||
| return queryset.filter(has_role=True) | ||
|
|
||
|
|
||
| class UserProjectMembershipManager(models.Manager.from_queryset(UserProjectMembershipQuerySet)): | ||
| """Custom manager for UserProjectMembership.""" | ||
|
|
||
| pass | ||
|
|
||
|
|
||
| class UserProjectMembership(BaseModel): | ||
| """ | ||
| Through model connecting User <-> Project. | ||
|
|
@@ -508,6 +597,9 @@ class UserProjectMembership(BaseModel): | |
| related_name="project_memberships", | ||
| ) | ||
|
|
||
| # Add custom manager | ||
| objects = UserProjectMembershipManager() | ||
|
|
||
| def check_permission(self, user: AbstractUser | AnonymousUser, action: str) -> bool: | ||
| project = self.project | ||
| # Allow viewing membership details if the user has view permission on the project | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -109,8 +109,6 @@ class UserProjectMembershipSerializer(DefaultSerializer): | |
|
|
||
| user = MemberUserSerializer(read_only=True) | ||
| role = serializers.SerializerMethodField(read_only=True) | ||
| role_display_name = serializers.SerializerMethodField(read_only=True) | ||
| role_description = serializers.SerializerMethodField(read_only=True) | ||
|
|
||
| class Meta: | ||
| model = UserProjectMembership | ||
|
|
@@ -121,8 +119,6 @@ class Meta: | |
| "user", | ||
| "project", | ||
| "role", | ||
| "role_display_name", | ||
| "role_description", | ||
| "created_at", | ||
| "updated_at", | ||
| ] | ||
|
|
@@ -132,8 +128,6 @@ class Meta: | |
| "created_at", | ||
| "updated_at", | ||
| "role", | ||
| "role_display_name", | ||
| "role_description", | ||
| ] | ||
|
|
||
| def validate_email(self, value): | ||
|
|
@@ -158,22 +152,23 @@ def validate_role_id(self, value): | |
| return value | ||
|
|
||
| def get_role(self, obj): | ||
| from ami.users.roles import Role | ||
|
|
||
| role_cls = Role.get_primary_role(obj.project, obj.user) | ||
| return role_cls.__name__ if role_cls else None | ||
|
|
||
| def get_role_display_name(self, obj): | ||
| from ami.users.roles import Role | ||
|
|
||
| role_cls = Role.get_primary_role(obj.project, obj.user) | ||
| return role_cls.display_name if role_cls else None | ||
| """ | ||
| Get the primary role for this membership. | ||
|
|
||
| def get_role_description(self, obj): | ||
| Note: Due to queryset filtering in UserProjectMembershipViewSet.get_queryset(), | ||
| this method should never return None in API responses. However, we maintain | ||
| the None check for safety (e.g., when called outside API context). | ||
| """ | ||
|
Comment on lines
+158
to
+161
|
||
| from ami.users.roles import Role | ||
|
|
||
| role_cls = Role.get_primary_role(obj.project, obj.user) | ||
| return role_cls.description if role_cls else None | ||
| if role_cls is None: | ||
| return None | ||
| return { | ||
| "id": role_cls.__name__, | ||
| "name": role_cls.display_name, | ||
| "description": role_cls.description, | ||
| } | ||
|
|
||
| def validate(self, attrs): | ||
| project = self.context["project"] | ||
|
|
@@ -207,17 +202,13 @@ def validate(self, attrs): | |
| class UserProjectMembershipListSerializer(UserProjectMembershipSerializer): | ||
| user = MemberUserSerializer(read_only=True) | ||
| role = serializers.SerializerMethodField() | ||
| role_display_name = serializers.SerializerMethodField() | ||
| role_description = serializers.SerializerMethodField() | ||
|
|
||
| class Meta: | ||
| model = UserProjectMembership | ||
| fields = [ | ||
| "id", | ||
| "user", | ||
| "role", | ||
| "role_display_name", | ||
| "role_description", | ||
| "created_at", | ||
| "updated_at", | ||
| ] | ||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -36,7 +36,7 @@ class UserProjectMembershipViewSet(DefaultViewSet, ProjectMixin): | |||||||||||
|
|
||||||||||||
| def get_queryset(self): | ||||||||||||
| project = self.get_active_project() | ||||||||||||
| return UserProjectMembership.objects.filter(project=project).select_related("user") | ||||||||||||
| return UserProjectMembership.objects.filter(project=project).select_related("user").with_role(log_invalid=True) | ||||||||||||
|
||||||||||||
| return UserProjectMembership.objects.filter(project=project).select_related("user").with_role(log_invalid=True) | |
| queryset = UserProjectMembership.objects.filter(project=project).select_related("user") | |
| if getattr(self, "action", None) == "list": | |
| queryset = queryset.with_role(log_invalid=True) | |
| return queryset |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
memberships without a role are invalid and will be fixed by signals o BE functions. also can be hand fixed in the Django admin
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| export type Role = { | ||
| name: string | ||
| id: string | ||
| description?: string | ||
| description: string | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
with_role()checks for any group whose name starts with"{project_id}_", butRole.get_primary_role()/Role.has_role()rely on exact group names includingproject.name(Role.get_group_name). If a project is renamed (or groups drift),with_role()can treat a membership as having a role whileget_primary_role()still returnsNone, defeating the “should never be None” assumption and skippinglog_invalid. Consider making the predicate consistent withRole.get_group_name(or updating role group naming to be stable).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@coderabbitai is this valid?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
I need to examine the actual implementation of the role system to verify Copilot's concern. Let me search for the relevant code.
🏁 Script executed:
Length of output: 7594
🏁 Script executed:
Length of output: 2217
🏁 Script executed:
Length of output: 2946
@mihowYes, Copilot's concern is valid. I can confirm the issue by examining the code:The Problem
Group naming pattern (Line 25 in
ami/users/roles.py):The inconsistency occurs when a project is renamed:
Original state: Project ID=5, name="Wildlife Project"
5_Wildlife Project_BasicMemberAfter rename: Project renamed to "Forest Animals"
5_Wildlife Project_BasicMemberwith_role()matches it (only checks prefix5_*) ✅has_role()looks for5_Forest Animals_BasicMember(doesn't exist!) ❌Result: A membership passes
with_role()filtering butRole.get_primary_role()returnsNone, defeating the data consistency check.Root Cause
The
has_role()method (line 48) always uses the current project name:While
with_role()only checks the project ID prefix, making it tolerant of stale group names.The TODO Comment
The TODO in the diff already acknowledges this is technical debt and proposes the proper solution: migrating to FK-based role-project associations instead of string-based group names.
Recommended Actions
Project.save()methodWould you like me to search for whether there's already any project rename handling logic in the codebase?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe there is already a signal or function on the save method to update group names when projects are renamed, correct @mohamedelabbas1996?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.