Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions ami/main/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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("_")),
)
)
Comment on lines +549 to +555
Copy link

Copilot AI Jan 22, 2026

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}_", but Role.get_primary_role()/Role.has_role() rely on exact group names including project.name (Role.get_group_name). If a project is renamed (or groups drift), with_role() can treat a membership as having a role while get_primary_role() still returns None, defeating the “should never be None” assumption and skipping log_invalid. Consider making the predicate consistent with Role.get_group_name (or updating role group naming to be stable).

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@coderabbitai is this valid?

Copy link
Contributor

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:

#!/bin/bash
# Find the Role class and group naming implementation
fd -e py roles.py --exec cat {} \; | head -200

Length of output: 7594


🏁 Script executed:

#!/bin/bash
# Search for get_group_name, has_role, and get_primary_role methods
rg -n -A 10 "def get_group_name|def has_role|def get_primary_role" --type=py

Length of output: 2217


🏁 Script executed:

#!/bin/bash
# Search for how groups are created/named in the role system
rg -n -B 3 -A 10 "Group\.objects\..*create|group.*name.*=.*project" --type=py | head -100

Length of output: 2946


@mihow Yes, 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):

return f"{project.pk}_{project.name}_{cls.__name__}"

The inconsistency occurs when a project is renamed:

  1. Original state: Project ID=5, name="Wildlife Project"

    • Group created: 5_Wildlife Project_BasicMember
  2. After rename: Project renamed to "Forest Animals"

    • Old group still exists: 5_Wildlife Project_BasicMember
    • with_role() matches it (only checks prefix 5_*) ✅
    • has_role() looks for 5_Forest Animals_BasicMember (doesn't exist!) ❌
  3. Result: A membership passes with_role() filtering but Role.get_primary_role() returns None, defeating the data consistency check.

Root Cause

The has_role() method (line 48) always uses the current project name:

group_name = cls.get_group_name(project)  # Uses current project.name
return user.groups.filter(name=group_name).exists()

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

  1. Short-term: Add a migration/management command to update group names when projects are renamed, or handle this in the Project.save() method
  2. Long-term: Implement the FK-based approach mentioned in the TODO comment
  3. Immediate: The current implementation could cause silent failures where users appear to have roles but permissions don't work

Would you like me to search for whether there's already any project rename handling logic in the codebase?

Copy link
Collaborator Author

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?

Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

)

# 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
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

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

When log_invalid=True, this does an invalid.exists() query and then iterates invalid (second query) and logs one warning per row. In API request paths this can add overhead and generate noisy logs. Consider logging a single summary (count + project id) and/or avoiding the double-query pattern (e.g., iterate once or limit results).

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hopefully this is a rare thing

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@copilot open a new pull request to apply changes based on this feedback

Comment on lines +559 to +569
Copy link
Contributor

@coderabbitai coderabbitai bot Jan 22, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid logging user emails in warnings.

User emails are PII; logging them can violate privacy/compliance expectations. Prefer user_id (or a hashed/obfuscated value) in logs.

🔐 Suggested redaction
-                        f"Data inconsistency detected: UserProjectMembership {membership.pk} "
-                        f"for user '{membership.user.email}' in project '{membership.project.name}' "
+                        f"Data inconsistency detected: UserProjectMembership {membership.pk} "
+                        f"for user_id={membership.user_id} in project '{membership.project.name}' "
                         f"(ID: {membership.project.pk}) has no role assigned. This indicates "
🤖 Prompt for AI Agents
In `@ami/main/models.py` around lines 559 - 569, The warning logs currently
include user emails (PII); update the logger call in the block that iterates
invalid = queryset.filter(has_role=False).select_related("user", "project") to
avoid logging membership.user.email and instead log a non-PII identifier such as
membership.user.pk (or a hashed/obfuscated value) and keep project info; modify
the logger.warning invocation (referencing membership, membership.user,
membership.project) to remove the email and replace it with the chosen non-PII
identifier and retain the existing context and remediation message.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I agree

Copy link
Contributor

Choose a reason for hiding this comment

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

@mihow, thank you for acknowledging this! Using membership.user_id instead of the email will help keep logs compliant with privacy best practices.


# 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.
Expand All @@ -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
Expand Down
35 changes: 13 additions & 22 deletions ami/users/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -121,8 +119,6 @@ class Meta:
"user",
"project",
"role",
"role_display_name",
"role_description",
"created_at",
"updated_at",
]
Expand All @@ -132,8 +128,6 @@ class Meta:
"created_at",
"updated_at",
"role",
"role_display_name",
"role_description",
]

def validate_email(self, value):
Expand All @@ -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
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

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

This docstring claims the method “should never return None” due to queryset filtering, but get_role() still returns None if Role.get_primary_role() can’t resolve a primary role (and current with_role() doesn’t guarantee that). Please either enforce the invariant (and adjust filtering accordingly) or update the comment to avoid misleading future callers.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@coderabbitai is this true?

Copy link
Contributor

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and examine the with_role() implementation in UserProjectMembershipQuerySet
ast-grep --pattern 'def with_role($$$)'

# Find and examine the Role.get_primary_role() implementation
ast-grep --pattern 'def get_primary_role($$$)'

# Find and examine the ViewSet's get_queryset() method
rg -n -A 10 'class UserProjectMembershipViewSet' ami/users/api/views.py
rg -n -A 10 'def get_queryset' ami/users/api/views.py

Length of output: 6153


@mihow Yes, Copilot's concern is valid. There's a subtle but important mismatch between the filtering logic.

The issue:

  1. with_role() filtering (line 553 in ami/main/models.py):

    • Uses a prefix match: checks if user has ANY group where name__startswith={project_id}_
  2. get_primary_role() resolution (ami/users/roles.py line 86):

    • Calls get_user_roles() which iterates through Role.__subclasses__()
    • For each role class, calls has_role() which checks for an exact match: {project_id}_{project_name}_{RoleClassName}

Scenarios where they diverge:

  1. Project renamed: A group 123_OldProjectName_BasicMember exists, but the project is now called NewProjectName. The prefix check passes, but has_role() looks for 123_NewProjectName_BasicMember (no match).

  2. Deleted role class: A group 123_ProjectName_ObsoleteRole exists, but ObsoleteRole was removed from the codebase. The prefix check passes, but Role.__subclasses__() won't include it.

  3. Malformed group name: A group 123_garbage exists. The prefix check passes, but no role class will match it.

In all these cases, with_role() includes the membership in the queryset, but get_role() returns None.

Recommendation:

Either:

  • Update the docstring to acknowledge that None is possible despite the filtering, or
  • Strengthen with_role() to verify that the group name matches an actual role pattern (more complex but enforces the invariant)

The current docstring creates a false sense of security for future developers.


🧬 Code Graph Analysis Results

ami/users/models.py
<line_range>10-44</line_range>

class User(AbstractUser):
    """
    Default custom user model for Automated Monitoring of Insects ML Platform.
    If adding fields that need to be filled at user signup,
    check forms.SignupForm and forms.SocialSignupForms accordingly.
    """

    # First and last name do not cover name patterns around the globe
    name = CharField(_("Name of User"), blank=True, max_length=255)
    first_name = None  # type: ignore
    last_name = None  # type: ignore
    email = EmailField(_("email address"), unique=True)
    username = None  # type: ignore
    image = models.ImageField(upload_to="users", blank=True, null=True)

    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = []

    objects = UserManager()

    def save(self, *args, **kwargs):
        if not self.email:
            raise ValueError("The Email field must be set")
        self.email = UserManager.normalize_email(self.email)
        super().save(*args, **kwargs)

    def get_absolute_url(self) -> str:
        """Get URL for user's detail view.

        Returns:
            str: URL for user detail.

        """
        # `@TODO` return frontend URL, not API URL
        return reverse("api:user-detail", kwargs={"id": self.pk})

ami/main/models.py
<line_range>581-622</line_range>

class UserProjectMembership(BaseModel):
    """
    Through model connecting User <-> Project.
    This model represents membership ONLY.
    Role assignment is handled separately via permission groups.
    """

    user = models.ForeignKey(
        User,
        on_delete=models.CASCADE,
        related_name="project_memberships",
    )

    project = models.ForeignKey(
        "main.Project",
        on_delete=models.CASCADE,
        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
        if action == "retrieve":
            return user.has_perm(Project.Permissions.VIEW_USER_PROJECT_MEMBERSHIP, project)
        # Allow users to delete their own membership
        if action == "destroy" and user == self.user:
            return True
        return super().check_permission(user, action)

    def get_user_object_permissions(self, user) -> list[str]:
        # Return delete permission if user is the same as the membership user
        user_permissions = super().get_user_object_permissions(user)
        if user == self.user:
            if "delete" not in user_permissions:
                user_permissions.append("delete")
        return user_permissions

    class Meta:
        unique_together = ("user", "project")

ami/users/roles.py
<line_range>12-89</line_range>

class Role:
    """Base class for all roles."""

    display_name = ""
    description = ""
    permissions = {Project.Permissions.VIEW_PROJECT}

    # `@TODO` : Refactor after adding the project <-> Group formal relationship
    `@classmethod`
    def get_group_name(cls, project):
        """
        Construct the name of the group that manages a role for a given project.
        """
        return f"{project.pk}_{project.name}_{cls.__name__}"

    `@classmethod`
    def assign_user(cls, user, project):
        # Get or create the Group
        # `@TODO` Make the relationship between the group and the project more formal (use a many-to-many field)
        group_name = cls.get_group_name(project)
        group, created = Group.objects.get_or_create(name=group_name)
        if created:
            logger.info(f"Created permission group {group_name} for project {project}")
        # Add user to group
        user.groups.add(group)

    `@classmethod`
    def unassign_user(cls, user, project):
        group_name = cls.get_group_name(project)
        group = Group.objects.get(name=group_name)
        # remove user from group
        user.groups.remove(group)

    `@classmethod`
    def has_role(cls, user, project):
        """Checks if the user has the role permissions on the given project."""
        group_name = cls.get_group_name(project)
        return user.groups.filter(name=group_name).exists()

    `@staticmethod`
    def user_has_any_role(user, project):
        """Checks if the user has any role assigned to a given project."""
        return any(role_class.has_role(user, project) for role_class in Role.__subclasses__())

    `@staticmethod`
    def get_supported_roles():
        """
        Returns all supported role classes in the system.
        """
        return list(Role.__subclasses__())

    `@staticmethod`
    def get_user_roles(project, user):
        """
        Returns the names of roles assigned to a user for a specific project.
        Or empty list if no role is found.
        """
        user_roles = []
        for role_cls in Role.__subclasses__():
            if role_cls.has_role(user, project):
                user_roles.append(role_cls)
        return user_roles

    `@staticmethod`
    def get_primary_role(project, user):
        """
        Return the role class with the most permissions for a user on a project.

        In practice, a user should only have one role per project, but in case of multiple roles,
        we return the one with the most permissions.

        The original design allowed multiple roles per user per project, but it was later decided to
        that from a UX and management perspective, a single role per user per project is preferable.
        """
        roles = Role.get_user_roles(project, user)
        if not roles:
            return None
        return max(roles, key=lambda r: len(r.permissions))

ami/base/serializers.py
<line_range>42-59</line_range>

class DefaultSerializer(serializers.HyperlinkedModelSerializer):
    url_field_name = "details"
    id = serializers.IntegerField(read_only=True)

    def get_permissions(self, instance, instance_data):
        request: Request = self.context["request"]
        user = request.user

        return add_object_level_permissions(
            user=user,
            instance=instance,
            response_data=instance_data,
        )

    def to_representation(self, instance):
        instance_data = super().to_representation(instance)
        instance_data = self.get_permissions(instance=instance, instance_data=instance_data)
        return instance_data

ami/base/serializers.py
<line_range>64-71</line_range>

class DefaultSerializer(serializers.HyperlinkedModelSerializer):
    url_field_name = "details"
    id = serializers.IntegerField(read_only=True)

    def get_permissions(self, instance, instance_data):
        request: Request = self.context["request"]
        user = request.user

        return add_object_level_permissions(
            user=user,
            instance=instance,
            response_data=instance_data,
        )

<no_relevant_code_snippets>

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"]
Expand Down Expand Up @@ -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",
]
2 changes: 1 addition & 1 deletion ami/users/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

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

get_queryset() now applies .with_role(log_invalid=True), which filters out memberships without a role group. This makes those members disappear from list/retrieve/update/delete (e.g., an inconsistent membership becomes impossible to PATCH to fix its role because it won’t be found). If the intended behavior is “return role: null when missing”, remove this filter (or scope it to list only) and rely on serializer nulls + explicit remediation tooling.

Suggested change
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

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

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


def get_serializer_class(self):
if self.action == "list":
Expand Down
20 changes: 16 additions & 4 deletions ami/users/tests/test_membership_management_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,28 @@ def setUp(self):
self.roles_url = "/api/v2/users/roles/"
self.members_url = f"/api/v2/projects/{self.project.pk}/members/"

def create_membership(self, user=None):
def create_membership(self, user=None, role_cls=None):
"""
Create a membership for a user in this project.
Used in tests to guarantee isolation.
Create a membership for a user in this project with a role assigned.

Args:
user: User to add as member (defaults to self.user1)
role_cls: Role class to assign (defaults to BasicMember)

Returns:
UserProjectMembership instance with role assigned
"""
if user is None:
user = self.user1
if role_cls is None:
role_cls = BasicMember # Default role for test memberships

membership = UserProjectMembership.objects.create(
project=self.project,
user=user,
)
# Assign role to ensure membership is valid
role_cls.assign_user(user, self.project)
return membership

def auth_super(self):
Expand Down Expand Up @@ -112,7 +122,9 @@ def test_update_membership_functionality(self):
self.assertEqual(resp.status_code, 200)

updated = resp.json()
self.assertEqual(updated["role"], ProjectManager.__name__)
self.assertEqual(updated["role"]["id"], ProjectManager.__name__)
self.assertEqual(updated["role"]["name"], ProjectManager.display_name)
self.assertEqual(updated["role"]["description"], ProjectManager.description)

membership = UserProjectMembership.objects.get(
project=self.project,
Expand Down
6 changes: 1 addition & 5 deletions ui/src/data-services/hooks/team/useMembers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,7 @@ const convertServerRecord = (record: ServerMember): Member => ({
id: `${record.id}`,
image: record.user.image,
name: record.user.name,
role: {
description: record.role_description,
id: record.role,
name: record.role_display_name,
},
role: record.role,
updatedAt: record.updated_at ? new Date(record.updated_at) : undefined,
userId: `${record.user.id}`,
})
Expand Down
15 changes: 14 additions & 1 deletion ui/src/data-services/models/member.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import { Role } from './role'
import { UserPermission } from 'utils/user/types'

export type ServerMember = any // TODO: Update this type
export type ServerMember = {
created_at: string
id: string
role: Role
updated_at: string
user: {
id: string
name: string
email: string
image?: string
}
user_permissions: UserPermission[]
}

export type Member = {
addedAt: Date
Expand Down
2 changes: 1 addition & 1 deletion ui/src/data-services/models/role.ts
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
}