Skip to content

Conversation

@mihow
Copy link
Collaborator

@mihow mihow commented Jan 22, 2026

Summary

This PR restructures the UserProjectMembership API to return role data as a nested object instead of three flattened fields, improving API consistency and simplifying the frontend data model.

Changes

Backend:

  • Updated UserProjectMembershipSerializer and UserProjectMembershipListSerializer to return role as nested object {"id": "...", "name": "...", "description": "..."}
  • Added null support for roles (returns null when no role assigned)
  • Maintained backward-compatible write operations using role_id

Frontend:

  • Updated TypeScript types: ServerMember and Member with proper role typing
  • Made Role.description required (always present when role exists)
  • Simplified data conversion in useMembers hook (direct assignment instead of manual object construction)
  • Added null safety to Manage Access dialog (handles blank role state)
  • Added null safety to team columns rendering (displays empty for null roles)

Tests:

  • Updated API test assertions to verify nested structure
  • All 15 tests passing

API Response Example

Before:
```json
{
"role": "ProjectManager",
"role_display_name": "Project Manager",
"role_description": "Manages project settings..."
}
```

After:
```json
{
"role": {
"id": "ProjectManager",
"name": "Project Manager",
"description": "Manages project settings..."
}
}
```

Risk Assessment

Low Risk:

  • This API is new in the current feature branch (no external consumers)
  • Write operations unchanged (still accepts role_id)
  • Only response structure modified
  • All identified UI usage locations updated
  • TypeScript compilation verified
  • All tests passing

Test Plan

  • ✅ Backend tests pass (15/15)
  • ✅ TypeScript compilation succeeds
  • ✅ Null role handling verified
  • ✅ Write operations (create/update) verified

Built with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Consolidated role fields in API responses: role is now a single object (id, name, description) instead of separate display/name fields.
  • New Features

    • Project membership listings now provide members only when a role is present and include detailed role info.
  • Chores

    • Strengthened frontend and backend type definitions for members and roles; role description is now required.
  • Tests

    • Membership tests updated to validate the new structured role response.

✏️ Tip: You can customize this high-level summary in your review settings.

@netlify
Copy link

netlify bot commented Jan 22, 2026

Deploy Preview for antenna-preview ready!

Name Link
🔨 Latest commit a55dbdc
🔍 Latest deploy log https://app.netlify.com/projects/antenna-preview/deploys/69747048ea5853000817fae1
😎 Deploy Preview https://deploy-preview-1096--antenna-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 62 (🔴 down 18 from production)
Accessibility: 80 (🔴 down 9 from production)
Best Practices: 100 (🟢 up 8 from production)
SEO: 92 (🔴 down 8 from production)
PWA: 80 (no change from production)
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 22, 2026

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

📝 Walkthrough

Walkthrough

Consolidates role fields in membership serialization into a single structured object (id, name, description). Adds membership queryset filtering for memberships that have roles. Frontend types and hooks updated to expect a structured role object and a concrete ServerMember shape.

Changes

Cohort / File(s) Summary
Backend Serializers
ami/users/api/serializers.py
Removed role_display_name and role_description fields from UserProjectMembershipSerializer and UserProjectMembershipListSerializer. get_role() now returns a dict { id, name, description } (handles None). Removed helper methods for display/description.
Backend Tests
ami/users/tests/test_membership_management_api.py
create_membership(self, user=None, role_cls=None) now accepts role_cls (defaults to BasicMember). Tests updated to expect role as a dict with id, name, description.
Backend Models & Views
ami/main/models.py, ami/users/api/views.py
Added UserProjectMembershipQuerySet.with_role(log_invalid=False) and UserProjectMembershipManager; attached manager to UserProjectMembership. Added Project.members_with_roles(). Views chain .with_role(log_invalid=True) in get_queryset().
Frontend Type Models
ui/src/data-services/models/member.ts, ui/src/data-services/models/role.ts
ServerMember changed from any to concrete shape including role: Role, user (with optional image), timestamps, and user_permissions: UserPermission[]. Role.description changed from optional to required string.
Frontend Data Hooks
ui/src/data-services/hooks/team/useMembers.ts
convertServerRecord now maps role directly from record.role (structured object), changing Member.role usage and typing downstream.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Client as Client
  participant View as API View
  participant QS as with_role() Queryset
  participant DB as Database
  participant Serializer as Serializer

  Client->>View: GET /projects/{id}/members
  View->>QS: .select_related('user').with_role(log_invalid=True)
  QS->>DB: Query memberships filtered by has_role / group names
  DB-->>QS: memberships rows
  QS-->>Serializer: membership instances
  Serializer->>Serializer: get_role(): build {id, name, description}
  Serializer-->>Client: JSON response with role object
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I hopped through code with curious paws,

Roles bundled tight, no scattered claws.
From DB to view to JSON art,
A tidy role now plays its part—
Hop, patch, and ship with joyful heart!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: restructuring the API response to use a nested role object instead of flattened fields.
Description check ✅ Passed The description covers required sections including summary, changes, API examples, risk assessment, and test plan; however, deployment notes and testing instructions are minimal.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@mihow mihow changed the base branch from feat/role-management-api to main January 22, 2026 08:13
Changed the UserProjectMembership API to return role data as a nested
object {"id": "...", "name": "...", "description": "..."} instead of
three flattened fields. This provides better API consistency with the
roles list endpoint and simplifies the frontend data model.

Changes:
- Backend: Updated serializers to return nested role object with null support
- Frontend: Updated TypeScript types to match new structure
- Frontend: Simplified data conversion in useMembers hook
- Frontend: Added null safety to UI components (manage access dialog, team columns)
- Tests: Updated assertions to verify nested structure

All tests passing. Write operations (role_id parameter) unchanged.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@mihow mihow force-pushed the feat/nest-role-fields-in-api branch from d0da91b to bf08d8a Compare January 22, 2026 08:15
@netlify
Copy link

netlify bot commented Jan 22, 2026

Deploy Preview for antenna-ssec ready!

Name Link
🔨 Latest commit a55dbdc
🔍 Latest deploy log https://app.netlify.com/projects/antenna-ssec/deploys/69747048cc443000083b305a
😎 Deploy Preview https://deploy-preview-1096--antenna-ssec.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

image?: string
name: string
role: Role
role: Role | null
Copy link
Member

Choose a reason for hiding this comment

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

Can we not assume a member will have a role?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It doesn't make logical sense in the application, but its technically possible since roles & project membership are maintained in two different systems (roles are built on the Django permission system, membership is our own system). One option could be to filter the members on the API side - only return ones that have a valid role.

Enforce business rule that all project members must have a role by
filtering the API queryset and logging warnings for data inconsistencies.

Changes:
- Add UserProjectMembershipQuerySet.with_role() method to filter
  memberships with valid role assignments
- Add Project.members_with_roles() convenience method for easy access
- Update UserProjectMembershipViewSet to use new filtering method
- Log warnings when memberships without roles are detected
- Update serializer docstring to document the invariant
- Fix test helper to assign default BasicMember role
- Make Role non-nullable in TypeScript types
- Remove defensive null checks from frontend components

This ensures:
- API responses always include valid role data
- Data inconsistencies are logged for investigation
- Frontend code is simpler without null checks
- Tests create valid memberships with roles

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings January 22, 2026 23:49
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Restructures the UserProjectMembership API response to return role details as a nested object, and updates the UI data model to consume the new shape.

Changes:

  • Backend serializers updated to return role as an object ({id, name, description}) instead of flattened role fields.
  • Membership view/query logic updated to filter memberships based on whether a role is assigned.
  • Frontend member/role types and the useMembers mapping updated to use the nested role object; tests updated accordingly.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
ui/src/data-services/models/role.ts Updates Role type to change description nullability.
ui/src/data-services/models/member.ts Defines a concrete ServerMember shape including nested role.
ui/src/data-services/hooks/team/useMembers.ts Simplifies server→client mapping by directly assigning record.role.
ami/users/tests/test_membership_management_api.py Updates API test assertions for nested role response fields.
ami/users/api/views.py Adds role-based filtering (with_role) to the membership queryset.
ami/users/api/serializers.py Removes flattened role fields and returns nested role object via get_role.
ami/main/models.py Introduces UserProjectMembershipQuerySet.with_role() and a custom manager; adds members_with_roles().

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

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

Comment on lines +549 to +555
queryset = self.annotate(
has_role=Exists(
Group.objects.filter(
user=OuterRef("user"),
name__startswith=models.functions.Concat(OuterRef("project_id"), models.Value("_")),
)
)
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!

Comment on lines +558 to +569
# 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}"
)
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 +158 to +161
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).
"""
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>

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@ami/main/models.py`:
- Around line 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.

Comment on lines +559 to +569
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}"
)
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.

@mihow
Copy link
Collaborator Author

mihow commented Jan 22, 2026

Code review

Found 2 issues:

  1. Type mismatch: Role.description should be string, not string | null

The TypeScript type declares description: string | null, but the Python Role class always defines description = "" (empty string, never None). The serializer returns role_cls.description directly without null handling. When a role exists, description is guaranteed to be a non-null string.

id: string
description: string | null
}

Compare with backend:

  1. Missing prefetch optimization in ViewSet queryset (CLAUDE.md: "Use bulk operations and prefetch patterns to minimize database queries")

The get_queryset() method calls .with_role(log_invalid=True) but doesn't prefetch user groups. The serializer's get_role() method calls Role.get_primary_role(), which triggers user.groups.filter(name=group_name).exists() queries for each role check. When listing N memberships, this generates N×M queries (N memberships × M roles checked).

def get_queryset(self):
project = self.get_active_project()
return UserProjectMembership.objects.filter(project=project).select_related("user").with_role(log_invalid=True)
def get_serializer_class(self):
if self.action == "list":

Consider adding .prefetch_related('user__groups') to the queryset to avoid N+1 queries.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

mihow and others added 2 commits January 23, 2026 23:05
Copy link
Contributor

Copilot AI commented Jan 24, 2026

@mihow I've opened a new pull request, #1101, to work on those changes. Once the pull request is ready, I'll request review from you.

@mihow
Copy link
Collaborator Author

mihow commented Jan 24, 2026

@annavik I updated the API (and other queries) to never return Members that have a null Role. So you the frontend can assume there is always a valid role now! Also description can be blank, but also never null (that's what I usually do for description or body fields).

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.

3 participants