Skip to content

Comments

Epic Multiple Profiles#382

Open
gabrielalmeida-tsl wants to merge 5 commits intomasterfrom
epic/multiples-profiles
Open

Epic Multiple Profiles#382
gabrielalmeida-tsl wants to merge 5 commits intomasterfrom
epic/multiples-profiles

Conversation

@gabrielalmeida-tsl
Copy link

@gabrielalmeida-tsl gabrielalmeida-tsl commented Feb 2, 2026

Summary by CodeRabbit

Release Notes

  • New Features

    • Added profile member role management with create, update, and delete operations
    • Enhanced user search to filter by email and profile names
    • Profile members can now store email addresses
  • Tests

    • Added comprehensive test coverage for profile member role operations
    • Added tests for enhanced search functionality

lbjunq and others added 2 commits October 1, 2025 09:50
✅ Multiple Profiles - Members Suggestion Dropdown

Description

As a user, on the BaseApp Profile,I would like to I want to add new
members to my organization, In order to have thse members help in the
organizations activites.

 

Original Story:  
Acceptance Criteria 
Context
This story is about creating the Add members button and the search
functionality to find users in the platform to invite them as members.
The suggestions dropdown should be generic so that in other projects the
queries could be changed. So that we can suggest other stuff that is not
users.
We are going to use the user query just for the sake of using an
example.

    Sidebar with AA

Business Rules - Members Suggestion Dropdown
On this story we will work on the search functionality to add members,
but not the actual logic of the invitation.

Given a user wants to add a new member to the organization, when they
click the Add Member button, a modal should appear where they can input
the name or the user name.

The input box should suggest a list of users, based on what the user has
written in it.

        The filtering criteria for this search will be name of the user.

We will show only 4 options max and then use an infinite scrolling
behavior to expand the initial results.

Given the I have found the corresponding user, when I click on the user
option, it will be added to the list of users to invite after
confirmation.

Given the user has clicked on Invite Button, the invited users should
now appear in the Members list page with the Label invited.

Demo: https://www.loom.com/share/d991c36180c847d2b34dd82cf116a22d

---------

Co-authored-by: Lúcio BJ <lb@tsl.io>
Co-authored-by: Alisson Patricio <eu@alisson.net>
Co-authored-by: Patrick Sluth <ps@tsl.io>
Co-authored-by: Vitor <vitor@M4cb00k.local>
Co-authored-by: Vitor Guimarães <80125714+vitorguima@users.noreply.github.com>
Co-authored-by: Alisson Patricio <eu@alisson.net>
@coderabbitai
Copy link

coderabbitai bot commented Feb 2, 2026

Walkthrough

This PR extends the user search filtering to support email and profile name lookups, and introduces three new GraphQL mutations for managing profile user roles (create, update, delete). It also updates the ProfileUserRole model to support email-based invitations and new status handling, with corresponding migrations and test coverage.

Changes

Cohort / File(s) Summary
User Search Filtering
baseapp_auth/graphql/filters.py, baseapp_auth/tests/graphql/test_queries_user.py
Renamed filter method from filtesearchr_q to filter_by_q. Updated search logic to filter by email and profile name (if baseapp_profiles installed) instead of first/last name. Added test cases validating email and profile name filtering.
Profile User Role Mutations
baseapp_profiles/graphql/mutations.py, baseapp_profiles/permissions.py
Added three new mutation classes: ProfileUserRoleCreate (bulk creation in transaction), ProfileUserRoleUpdate, and ProfileUserRoleDelete. Replaced legacy mutation field mappings. Added add_profileuserrole permission check.
Profile User Role Model & Migrations
baseapp_profiles/models.py, baseapp_profiles/migrations/0007_profileuserrole_email_alter_profileuserrole_status_and_more.py, testproject/profiles/migrations/0004_add_email_to_profileuserrole.py
Added email field to AbstractProfileUserRole; made user ForeignKey nullable; changed status default to INACTIVE. Corresponding migrations propagated to both baseapp and test project.
Profile User Role Test Coverage
baseapp_profiles/tests/test_graphql_mutations_delete.py, baseapp_profiles/tests/test_graphql_mutations_update.py
Added comprehensive tests for new ProfileUserRole mutations covering permission scenarios (owner, permitted user, non-permitted user). Replaced old mutation references with new ProfileUserRole mutation constants and response assertions.
Configuration
baseapp_core/tests/settings.py
Added django.contrib.postgres to INSTALLED_APPS for test environment.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Possibly related PRs

Suggested reviewers

  • Hercilio1
  • vitorguima
  • anicioalexandre
  • luciobj

Poem

🐰 With roles and emails now in hand,
We search and filter through the land,
Profile members join by invite,
GraphQL mutations burning bright,
The baseapp grows with wings so grand! ✨

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Epic Multiple Profiles' is vague and generic, using non-descriptive terminology that does not convey meaningful information about the specific changes made in the changeset. Use a more descriptive title that captures the main technical change, such as 'Add ProfileUserRole mutations and email-based filtering' or 'Implement profile member management with email invitations'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch epic/multiples-profiles

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.

Copy link

@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: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
baseapp_profiles/models.py (1)

235-240: ⚠️ Potential issue | 🟠 Major

unique_together constraint may not work as expected with nullable user.

The unique_together = [("user", "profile")] constraint combined with user being nullable could allow multiple ProfileUserRole records with user=None and the same profile, since NULL values are typically not considered equal in database unique constraints. This may be problematic for the email invitation flow when it's fully implemented.

Additionally, the __str__ method on line 240 will display confusing output if user is None (e.g., "None as Manager in ProfileName" in admin/logs).

Since the email invitation feature is currently marked as TODO (line 174 in mutations.py), consider implementing email-based uniqueness validation (either at the database level with a partial unique constraint or via application-level validation) when completing the emails_to_invite functionality. Also consider adding a fallback in __str__ to display the email when user is None.

baseapp_auth/admin.py (1)

75-98: ⚠️ Potential issue | 🟠 Major

Guard add_is_password_expired() when expiration is disabled.
With interval <= 0, add_is_password_expired() still annotates is_password_expired as True for most users, and obj.password_expired will then return the annotation, making the admin show expired even when expiration is off.

✅ Suggested fix
 def get_queryset(self, request):
     qs = super().get_queryset(request)
-    return qs.add_is_password_expired()
+    if config.USER_PASSWORD_EXPIRATION_INTERVAL <= 0:
+        return qs
+    return qs.add_is_password_expired()
🤖 Fix all issues with AI agents
In `@baseapp_api_key/graphql/__init__.py`:
- Line 1: The file imports APIKeyAuthentication from .middleware and currently
uses a blanket noqa which RUF100 flags; remove the unused blanket `# noqa` and
either delete it entirely if no lint suppression is required or replace it with
a targeted code comment (e.g., a specific ruff/flake rule like `# noqa: F401`
only if the import must be kept for exports) so only the unnecessary blanket
suppression is removed while keeping the import of APIKeyAuthentication intact.

In `@baseapp_api_key/graphql/consumers.py`:
- Around line 39-54: The code unconditionally accesses api_key.user after
fetching api_key, which can be None or expired; update the logic in the block
handling unencrypted_api_key (where you call self.APIKeyModel.objects.encrypt
and await database_sync_to_async(...).first) to first check that api_key is not
None and that not api_key.is_expired before assigning user = api_key.user; if
either check fails set user = None (or return/continue) so you never dereference
a None api_key; ensure the checks occur immediately after fetching api_key and
remove the unconditional final assignment to api_key.user.
- Around line 61-63: The permission check in the conditional using
database_sync_to_async(user.has_perm)(...) is missing an await, so the coroutine
is not executed and the condition always truthy; update the conditional in the
block that references profile and database_sync_to_async(user.has_perm) to await
the coroutine (i.e., await
database_sync_to_async(user.has_perm)(f"{profile._meta.app_label}.use_profile",
profile)) so the permission is actually evaluated before allowing setting
current_profile.

In `@baseapp_api_key/graphql/middleware.py`:
- Around line 6-17: The class-level authenticated flag on APIKeyAuthentication
causes shared state across requests; change the logic in
APIKeyAuthentication.resolve to avoid a class attribute by using a
per-request/local marker (e.g., check/set a flag on info.context such as
info.context._api_key_authenticated or simply run authentication each call)
instead of the class attribute authenticated; ensure you still call
self.authenticate(info.context), assign info.context.user when
user.is_authenticated, and remove or stop modifying the class-level
authenticated property so middleware instances remain stateless across requests.

In `@baseapp_api_key/management/commands/api_key.py`:
- Around line 54-67: The code only processes the first tuple from
options.get("rotate_encryption_key") because it uses _args[0]; update the rotate
handling in the management command so all provided pairs are handled: either
iterate over the list returned by options.get("rotate_encryption_key") and call
APIKeyModel.objects.rotate_encryption_key(...) for each (keeping the same
masking and stdout output using _mask), or change the argparse setup to use
nargs=2 (remove action="append") if only a single rotation pair should be
supported; adjust references to _args and the processing block accordingly to
reflect the chosen approach.

In `@baseapp_api_key/README.md`:
- Around line 124-136: Update the README examples to replace the literal
token-like placeholder "BA-your-64-character-api-key-here" with a non-secret
environment-variable placeholder (e.g., use an env var name like $API_KEY or
{{API_KEY}}) in both the curl example and the Python requests headers; change
the curl command to read the header value from an env variable and change the
Python snippet to show reading the value from an environment variable (e.g., via
os.environ) so the examples in README.md no longer contain token-like secrets
and avoid secret-scanner false positives.

In `@baseapp_api_key/rest_framework/permissions.py`:
- Around line 15-29: The has_permission method on HasAPIKey is incorrectly
marked with `@abc.abstractmethod` despite providing a concrete implementation,
which prevents instantiation; remove the `@abc.abstractmethod` decorator from
has_permission in the HasAPIKey class so DRF can instantiate the permission
class, keeping the existing logic that reads settings.BA_API_KEY_REQUEST_HEADER
from request.META, uses APIKeyModel.objects.encrypt(unencrypted_value=...) and
then queries APIKeyModel.objects.filter(is_expired=False,
encrypted_api_key=...). Ensure no other abstract decorators remain on
has_permission.

In `@baseapp_auth/graphql/filters.py`:
- Around line 21-23: The method filter_by_q has an unused parameter name; rename
that parameter to _name to follow django-filters' required signature (self,
_name, value) and Python convention for unused variables. Update the method
definition for filter_by_q accordingly so linters stop flagging the unused
parameter while preserving the expected django-filters interface (no other logic
changes needed in filter_by_q).

In `@baseapp_core/tests/settings.py`:
- Line 217: Add the missing app entry to INSTALLED_APPS so the APIKey model and
authentication backend are available: in the settings where
DEFAULT_AUTHENTICATION_CLASSES includes
"baseapp_api_key.rest_framework.authentication.APIKeyAuthentication", add
"baseapp_api_key" to INSTALLED_APPS (ensure the string exactly matches the app
name used elsewhere) so imports and model lookups for APIKey and the
authentication class succeed during tests.

In `@baseapp_profiles/graphql/mutations.py`:
- Around line 178-183: The list comprehension building profile_user_roles can
raise a TypeError when users_ids is None; update the code that creates
profile_user_roles (the comprehension using ProfileUserRole and
get_pk_from_relay_id) to handle a missing users_ids by treating it as an empty
iterable (e.g., defaulting users_ids to [] or guarding with a conditional) so
the loop is safe when input.get("users_ids") returns None.
- Around line 244-247: ProfileUserRole.objects.get(...) will raise
ProfileUserRole.DoesNotExist before the current if check, so catch that
exception and convert it into the intended GraphQLError; specifically wrap the
call to ProfileUserRole.objects.get(user_id=user_pk, profile_id=profile_pk) in a
try/except catching ProfileUserRole.DoesNotExist and in the except raise
GraphQLError(str(_("User role not found"))), leaving successful retrieval to
assign obj as before.

In `@baseapp_profiles/tests/test_graphql_mutations_update.py`:
- Around line 340-361: The test function
test_user_with_permission_can_add_profile_user_role uses the wrong profile in
the GraphQL input: replace the incorrect user.profile.relay_id with the
profile.relay_id created by ProfileFactory (reference symbols:
test_user_with_permission_can_add_profile_user_role, ProfileFactory,
user.profile.relay_id, profile.relay_id) and ensure the subsequent
profile.refresh_from_db() refers to that same profile; alternatively, if the
intent was to test adding a role to the user's own profile, adjust the
ProfileFactory setup or the test name to reflect that intent and/or grant
explicit permission on profile before calling the mutation.

In `@testproject/settings.py`:
- Around line 171-173: Ruff flags env as undefined because it was brought in via
a star import; explicitly import the env symbol instead of relying on wildcard
imports so BA_API_KEY_REQUEST_HEADER and BA_API_KEY_ENCRYPTION_KEY can use it.
Add a direct import for env (the same provider used elsewhere in the project) at
the top of settings.py (so env is defined before BA_API_KEY_REQUEST_HEADER and
BA_API_KEY_ENCRYPTION_KEY are assigned).
🧹 Nitpick comments (19)
baseapp_profiles/graphql/mutations.py (2)

157-160: Use exception chaining for clearer tracebacks.

Per static analysis (B904), when re-raising exceptions within an except clause, use raise ... from err or raise ... from None to distinguish them from errors in exception handling.

♻️ Proposed fix for exception chaining
         try:
             profile = Profile.objects.get(pk=profile_pk)
         except Profile.DoesNotExist:
-            raise GraphQLError(str(_("Profile not found")))
+            raise GraphQLError(str(_("Profile not found"))) from None
         try:
             obj = ProfileUserRole.objects.get(user_id=user_pk, profile_id=profile_pk)
         except ProfileUserRole.DoesNotExist:
-            raise GraphQLError(str(_("Role not found")))
+            raise GraphQLError(str(_("Role not found"))) from None

Also applies to: 211-214


169-172: Redundant condition check for role_type.

The elif role_type and role_type not in ... condition is redundant because the elif already implies role_type is truthy (since the if not role_type: branch wasn't taken).

♻️ Simplify the condition
         if not role_type:
             role_type = ProfileUserRole.ProfileRoles.MANAGER
-        elif role_type and role_type not in ProfileUserRole.ProfileRoles.values:
+        elif role_type not in ProfileUserRole.ProfileRoles.values:
             raise GraphQLError(str(_("Invalid role type")))
baseapp_profiles/tests/test_graphql_mutations_delete.py (1)

105-117: Unnecessary permission grant for owner test.

The test is named test_user_profile_owner_can_remove_profile_member, implying it tests that the profile owner can delete members. However, lines 106-113 grant the delete_profileuserrole permission to the user. Since the owner should already have this permission by virtue of being the owner (as per ProfilesPermissionsBackend), the explicit permission grant is unnecessary and may obscure what the test is actually verifying.

♻️ Proposed fix to remove unnecessary permission grant
 def test_user_profile_owner_can_remove_profile_member(django_user_client, graphql_user_client):
-    perm = Permission.objects.get(
-        content_type__app_label=ProfileUserRole._meta.app_label, codename="delete_profileuserrole"
-    )
-
     user = django_user_client.user
     user_2 = UserFactory()
 
-    user.user_permissions.add(perm)
     profile = ProfileFactory(owner=user)
baseapp_auth/tasks.py (1)

12-15: Use timezone.now().date() to align with Django's __date lookup expectations.

Django's __date lookup is documented to accept a date value. While passing timezone.now() (a datetime) will work because Django implicitly converts it via DateField.to_python(), this conversion happens after timezone normalization, which can mask subtle timezone edge cases. Passing the date explicitly—as shown below—is the documented recommended approach and keeps the intent clear.

Suggested change
-        .filter(password_expiry_date__date=timezone.now())
+        .filter(password_expiry_date__date=timezone.now().date())
baseapp_api_key/tests/mixins.py (1)

1-1: Consider removing the unused noqa directive for F401.

Static analysis indicates that F401 is not enabled in your linter configuration, making the noqa: F401 directive unnecessary. You can simplify this to just # noqa: F403.

🧹 Suggested cleanup
-from baseapp_core.tests.mixins import *  # noqa: F403, F401
+from baseapp_core.tests.mixins import *  # noqa: F403
setup.cfg (1)

22-22: Exact version pins may limit flexibility.

django-pgtrigger == 4.14 and django-pghistory == 3.5.5 are pinned to exact versions. This ensures stability but prevents patch updates. If there are specific compatibility reasons, this is fine; otherwise, consider using compatible release specifiers (e.g., ~= 4.14 or >= 4.14, < 5.0) to allow patch updates.

Also applies to: 36-36

baseapp_auth/querysets.py (1)

1-35: Verify typing.Self compatibility with supported Python versions.

Without from __future__ import annotations, typing.Self only exists at runtime on Python 3.11+. If older versions are supported, this will raise on import. Consider a forward reference or typing_extensions.Self, or confirm the minimum Python version.

🔧 Safer runtime-compatible annotation
-import typing
@@
-    def add_is_expired(self) -> typing.Self:
+    def add_is_expired(self) -> "BaseAPIKeyQuerySet":
baseapp_api_key/tests/conftest.py (1)

5-7: Prefer sys.path.insert(0, ...) to ensure local modules win.
This avoids accidentally importing an installed package version ahead of the repo copy.

♻️ Suggested change
-# add the test root dir to python path
-root_dir = d(d(abspath(__file__)))
-sys.path.append(root_dir)
+# add the test root dir to python path
+root_dir = d(d(abspath(__file__)))
+sys.path.insert(0, root_dir)
baseapp_api_key/tests/helpers.py (2)

5-5: Remove unused noqa directive.

Per static analysis, the F401 suppression is not needed here since the wildcard import re-exports symbols (covered by F403).

🧹 Proposed fix
-from baseapp_core.tests.helpers import *  # noqa: F403, F401
+from baseapp_core.tests.helpers import *  # noqa: F403

8-14: Consider adding error handling for missing factory configuration.

If the configured BASEAPP_AUTH_USER_FACTORY path is invalid, this will raise a cryptic ModuleNotFoundError or AttributeError. A clearer error message would improve debuggability.

💡 Proposed improvement
 def get_user_factory():
     factory_class_path = getattr(
         settings, "BASEAPP_AUTH_USER_FACTORY", "apps.users.tests.factories.UserFactory"
     )
     module_path = ".".join(factory_class_path.split(".")[:-1])
-    module = importlib.import_module(module_path)
-    return getattr(module, factory_class_path.split(".")[-1])
+    class_name = factory_class_path.split(".")[-1]
+    try:
+        module = importlib.import_module(module_path)
+        return getattr(module, class_name)
+    except (ImportError, AttributeError) as e:
+        raise ImportError(
+            f"Could not load user factory '{factory_class_path}'. "
+            f"Check BASEAPP_AUTH_USER_FACTORY setting. Original error: {e}"
+        ) from e
baseapp_api_key/rest_framework/permissions.py (2)

6-7: Minor: Incorrect type hints.

  • HttpRequest should be Request (DRF's request class) since this is DRF context
  • view: typing.Type[APIView] should be view: APIView (instance, not class)
📝 Proposed fix
-from rest_framework.request import HttpRequest
+from rest_framework.request import Request
 from rest_framework.views import APIView

 from baseapp_api_key.models import APIKey, BaseAPIKey


 class BaseHasAPIKey(permissions.BasePermission):
     APIKeyModel: typing.Type[BaseAPIKey]

-    def has_permission(self, request: HttpRequest, view: typing.Type[APIView]):
+    def has_permission(self, request: Request, view: APIView) -> bool:

1-1: Unused import: abc module.

After removing @abc.abstractmethod, the abc import becomes unused.

🧹 Proposed fix
-import abc
 import typing
baseapp_auth/tests/graphql/test_queries_user.py (2)

173-181: Unused queries variable.

The queries variable is unpacked but never used. Since this test doesn't verify query count (unlike test_anon_can_query_users_list_with_optimized_query), consider using _ to indicate it's intentionally ignored.

Proposed fix
-    response, queries = graphql_client_with_queries(
+    response, _ = graphql_client_with_queries(
         QUERY_USERS_LIST_WITH_FILTERS, variables={"q": "test@test.com"}
     )

184-197: Same unused queries variable pattern.

Same issue as above - the queries variable is unpacked but unused.

Proposed fix
-    response, queries = graphql_client_with_queries(
+    response, _ = graphql_client_with_queries(
         QUERY_USERS_LIST_WITH_FILTERS, variables={"q": "test"}
     )
baseapp_api_key/tests/factories.py (1)

3-6: Import ordering: move uuid4 import to top.

The from uuid import uuid4 import on line 6 appears after code execution on line 5. While this works, it violates PEP 8 import ordering conventions.

Proposed fix
 import factory
+from uuid import uuid4

 import baseapp_auth.tests.helpers as h

 UserFactory = h.get_user_factory()
-from uuid import uuid4
baseapp_api_key/models.py (1)

14-14: default=None with null=False may cause validation issues.

Setting default=None on a BinaryField with null=False creates a potentially confusing situation. While this pattern can enforce that the field must be explicitly set (as done in admin.py and factories), attempting to save a model instance without setting encrypted_api_key will raise an IntegrityError.

Consider using a sentinel value or removing the default to make the requirement explicit:

Option 1: Remove default entirely (recommended)
-    encrypted_api_key = models.BinaryField(null=False, blank=False, default=None)
+    encrypted_api_key = models.BinaryField(null=False, blank=False)
Option 2: Use empty bytes as default if a default is truly needed
-    encrypted_api_key = models.BinaryField(null=False, blank=False, default=None)
+    encrypted_api_key = models.BinaryField(null=False, blank=False, default=b"")
baseapp_api_key/admin.py (1)

19-19: Consider removing encrypted_api_key from list_display.

Displaying encrypted_api_key (binary/hex data) in the admin list view provides no useful information to administrators and clutters the interface. Consider showing a truncated version or a simple "Set" / "Not Set" indicator instead.

Proposed fix - add a display method
 class BaseAPIKeyAdmin(ModelAdmin):
-    list_display = ("id", "user", "name", "encrypted_api_key", "is_expired")
+    list_display = ("id", "user", "name", "has_api_key", "is_expired")

     def is_expired(self, obj):
         return obj.is_expired

+    def has_api_key(self, obj):
+        return bool(obj.encrypted_api_key)
+    has_api_key.boolean = True
+    has_api_key.short_description = "API Key Set"
baseapp_api_key/management/commands/api_key.py (1)

44-48: Use Exception instead of BaseException and bare raise.

  1. Catching BaseException is overly broad and includes SystemExit, GeneratorExit, etc. Use Exception for general error handling.
  2. Use bare raise to preserve the original traceback.
Proposed fix
-        except BaseException as e:
+        except Exception as e:
             self.stdout.write("\r\n")
             if isinstance(e, KeyboardInterrupt):
                 return
-            raise e
+            raise

Note: With Exception, you won't catch KeyboardInterrupt (which inherits from BaseException), so you may want to handle it separately:

Alternative with explicit KeyboardInterrupt handling
+        except KeyboardInterrupt:
+            self.stdout.write("\r\n")
+            return
-        except BaseException as e:
+        except Exception:
             self.stdout.write("\r\n")
-            if isinstance(e, KeyboardInterrupt):
-                return
-            raise e
+            raise
baseapp_api_key/managers.py (1)

11-11: Import the API-key queryset from the API-key app to avoid cross-app coupling.
This looks like a copy/paste from baseapp_auth; prefer using the queryset in baseapp_api_key.querysets.

♻️ Suggested fix
-from baseapp_auth.querysets import BaseAPIKeyQuerySet
+from baseapp_api_key.querysets import BaseAPIKeyQuerySet

Comment on lines +178 to +183
profile_user_roles = [
ProfileUserRole(
user_id=get_pk_from_relay_id(user_id), profile_id=profile_pk, role=role_type
)
for user_id in users_ids
]
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Potential TypeError when users_ids is None.

If users_ids is not provided in the input, input.get("users_ids") returns None, and iterating over None in the list comprehension will raise a TypeError.

🐛 Proposed fix to handle missing users_ids
         profile_user_roles = [
             ProfileUserRole(
                 user_id=get_pk_from_relay_id(user_id), profile_id=profile_pk, role=role_type
             )
-            for user_id in users_ids
+            for user_id in (users_ids or [])
         ]
🤖 Prompt for AI Agents
In `@baseapp_profiles/graphql/mutations.py` around lines 178 - 183, The list
comprehension building profile_user_roles can raise a TypeError when users_ids
is None; update the code that creates profile_user_roles (the comprehension
using ProfileUserRole and get_pk_from_relay_id) to handle a missing users_ids by
treating it as an empty iterable (e.g., defaulting users_ids to [] or guarding
with a conditional) so the loop is safe when input.get("users_ids") returns
None.

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Patrick Sluth <ps@tsl.io>
Co-authored-by: Vitor <vitor@M4cb00k.local>
Co-authored-by: Vitor Guimarães <80125714+vitorguima@users.noreply.github.com>
Co-authored-by: Alisson Patricio <eu@alisson.net>
Co-authored-by: Mathieu Bouhelier <62220320+mathieubouhelier@users.noreply.github.com>
Co-authored-by: Hercilio Martins Ortiz <herciliomartins@gmail.com>
Co-authored-by: Hercilio Ortiz <ho@tsl.io>
Co-authored-by: Ronan <62265281+Ronan-Fernandes@users.noreply.github.com>
Co-authored-by: Rodean Fraser <rf2@tsl.io>
Co-authored-by: davidbermudez-tsl <89879138+davidbermudez-tsl@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Filipe de Castro <fc@tsl.io>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Lucas Caribé <lucasmoc47@gmail.com>
Co-authored-by: Lúcio BJ <lbjunq@hotmail.com>
Co-authored-by: Lúcio BJ <lb@tsl.io>
Co-authored-by: Gabriel Leandro <gs@tsl.io>
Co-authored-by: Gabriel Almeida  <ga@tsl.io>
@nossila
Copy link
Member

nossila commented Feb 10, 2026

Hold this PR until #368 gets merged

Copy link

@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: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
baseapp_profiles/tests/test_graphql_mutations_update.py (1)

321-369: 🛠️ Refactor suggestion | 🟠 Major

Delete tests are duplicated — these also exist in test_graphql_mutations_delete.py.

test_user_profile_owner_can_remove_profile_member and test_user_with_permission_can_remove_profile_member are identical to their counterparts in the delete test file. Remove them from here to avoid maintenance burden and test confusion.

🤖 Fix all issues with AI agents
In `@baseapp_auth/tests/graphql/test_queries_user.py`:
- Around line 200-213: The test function
test_anon_can_query_users_list_with_filter_by_profile_name should reflect that
the filtering uses Django's __icontains substring lookup (so "test" matching
"testing" is correct and not full-text based); update the test
comment/assumption to remove any full-text/stemming concern and ensure the call
to graphql_client_with_queries(QUERY_USERS_LIST_WITH_FILTERS, variables={"q":
"test"}) remains as-is, and rename the unused variable queries returned from
graphql_client_with_queries to _queries to silence the linter while leaving
response handling and the final assertion unchanged.

In `@baseapp_profiles/tests/test_graphql_mutations_delete.py`:
- Around line 105-155: Two test functions are duplicated; remove the duplicate
definitions of test_user_profile_owner_can_remove_profile_member and
test_user_with_permission_can_remove_profile_member from the file that tests
updates (keep the versions in the delete tests file), ensuring only the deletion
tests remain; search for the function names to locate and delete the duplicate
blocks so the deletion-focused tests exist in one place.

In `@baseapp_profiles/tests/test_graphql_mutations_update.py`:
- Around line 372-396: Rename the misleading test function
test_user_without_permission_cant_remove_profile_member to reflect that it
asserts blocking creation of a profile role; update the function name to
something like test_user_without_permission_cant_create_profile_user_role (or
test_user_without_permission_cant_add_profile_member) and keep the existing test
body that uses PROFILE_USER_ROLE_CREATE_GRAPHQL and the create-style input
(usersIds, roleType) so the name matches the behavior being tested.
🧹 Nitpick comments (2)
baseapp_profiles/tests/test_graphql_mutations_delete.py (1)

27-33: Duplicate mutation constant: PROFILE_USER_ROLE_DELETE_GRAPHQL is defined in both this file and test_graphql_mutations_update.py.

This constant is identical in both files. Consider defining it once (e.g., in a shared test constants module or in this file) and importing it where needed.

baseapp_auth/tests/graphql/test_queries_user.py (1)

189-197: Unused queries variable — use _ prefix or a plain graphql_client.

The queries binding from graphql_client_with_queries is never used. Either rename it to _queries or use a simpler client fixture if you don't need query-count assertions.

♻️ Suggested fix
 def test_anon_can_query_users_list_with_filter_by_email(graphql_client_with_queries):
     UserFactory.create_batch(2)
     UserFactory(email="test@test.com")
-    response, queries = graphql_client_with_queries(
+    response, _queries = graphql_client_with_queries(
         QUERY_USERS_LIST_WITH_FILTERS, variables={"q": "test@test.com"}
     )

Comment on lines +200 to +213
def test_anon_can_query_users_list_with_filter_by_profile_name(graphql_client_with_queries):
UserFactory.create_batch(2)
user_1 = UserFactory()
user_1.profile.name = "test"
user_1.profile.save()
user_2 = UserFactory()
user_2.profile.name = "testing"
user_2.profile.save()
response, queries = graphql_client_with_queries(
QUERY_USERS_LIST_WITH_FILTERS, variables={"q": "test"}
)
content = response.json()

assert len(content["data"]["users"]["edges"]) == 2
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Remove the full-text search concern; the filter uses __icontains for substring matching, not PostgreSQL full-text search. Rename unused variable queries to _queries.

The search for "test" matching "testing" is not fragile—the filter uses Django's __icontains lookup (case-insensitive substring matching), so this behavior is deterministic. The substring "test" is guaranteed to match "testing" regardless of configuration. The PostgreSQL full-text search stemming concern mentioned in the original comment does not apply here.

The queries variable is genuinely unused and should be renamed to _queries to suppress the linter warning.

♻️ Suggested fix for unused variable
-    response, queries = graphql_client_with_queries(
+    response, _queries = graphql_client_with_queries(
         QUERY_USERS_LIST_WITH_FILTERS, variables={"q": "test"}
     )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_anon_can_query_users_list_with_filter_by_profile_name(graphql_client_with_queries):
UserFactory.create_batch(2)
user_1 = UserFactory()
user_1.profile.name = "test"
user_1.profile.save()
user_2 = UserFactory()
user_2.profile.name = "testing"
user_2.profile.save()
response, queries = graphql_client_with_queries(
QUERY_USERS_LIST_WITH_FILTERS, variables={"q": "test"}
)
content = response.json()
assert len(content["data"]["users"]["edges"]) == 2
def test_anon_can_query_users_list_with_filter_by_profile_name(graphql_client_with_queries):
UserFactory.create_batch(2)
user_1 = UserFactory()
user_1.profile.name = "test"
user_1.profile.save()
user_2 = UserFactory()
user_2.profile.name = "testing"
user_2.profile.save()
response, _queries = graphql_client_with_queries(
QUERY_USERS_LIST_WITH_FILTERS, variables={"q": "test"}
)
content = response.json()
assert len(content["data"]["users"]["edges"]) == 2
🧰 Tools
🪛 Ruff (0.14.14)

[warning] 208-208: Unpacked variable queries is never used

Prefix it with an underscore or any other dummy variable pattern

(RUF059)

🤖 Prompt for AI Agents
In `@baseapp_auth/tests/graphql/test_queries_user.py` around lines 200 - 213, The
test function test_anon_can_query_users_list_with_filter_by_profile_name should
reflect that the filtering uses Django's __icontains substring lookup (so "test"
matching "testing" is correct and not full-text based); update the test
comment/assumption to remove any full-text/stemming concern and ensure the call
to graphql_client_with_queries(QUERY_USERS_LIST_WITH_FILTERS, variables={"q":
"test"}) remains as-is, and rename the unused variable queries returned from
graphql_client_with_queries to _queries to silence the linter while leaving
response handling and the final assertion unchanged.

Comment on lines +105 to +155
def test_user_profile_owner_can_remove_profile_member(django_user_client, graphql_user_client):
perm = Permission.objects.get(
content_type__app_label=ProfileUserRole._meta.app_label, codename="delete_profileuserrole"
)

user = django_user_client.user
user_2 = UserFactory()

user.user_permissions.add(perm)
profile = ProfileFactory(owner=user)
profile_user_role = ProfileUserRoleFactory(
profile=profile, user=user_2, role=ProfileUserRole.ProfileRoles.MANAGER
)

expected_relay_id = profile_user_role.relay_id

response = graphql_user_client(
PROFILE_USER_ROLE_DELETE_GRAPHQL,
variables={"input": {"userId": user_2.relay_id, "profileId": profile.relay_id}},
)
content = response.json()

assert content["data"]["profileUserRoleDelete"]["deletedId"] == expected_relay_id
assert not ProfileUserRole.objects.filter(id=profile_user_role.id).exists()


def test_user_with_permission_can_remove_profile_member(django_user_client, graphql_user_client):
perm = Permission.objects.get(
content_type__app_label=ProfileUserRole._meta.app_label, codename="delete_profileuserrole"
)

user = django_user_client.user
user.user_permissions.add(perm)
user_2 = UserFactory()
user_3 = UserFactory()

profile = ProfileFactory(owner=user_2)
ProfileUserRoleFactory(profile=profile, user=user, role=ProfileUserRole.ProfileRoles.ADMIN)
profile_user_role = ProfileUserRoleFactory(
profile=profile, user=user_3, role=ProfileUserRole.ProfileRoles.MANAGER
)

expected_relay_id = profile_user_role.relay_id

response = graphql_user_client(
PROFILE_USER_ROLE_DELETE_GRAPHQL,
variables={"input": {"userId": user_3.relay_id, "profileId": profile.relay_id}},
)
content = response.json()
assert content["data"]["profileUserRoleDelete"]["deletedId"] == expected_relay_id
assert not ProfileUserRole.objects.filter(id=profile_user_role.id).exists()
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's verify the test functions exist in both files
echo "=== Finding test functions in delete file ==="
rg -n "def test_user.*remove_profile_member" baseapp_profiles/tests/test_graphql_mutations_delete.py -A 2

echo ""
echo "=== Finding test functions in update file ==="
rg -n "def test_user.*remove_profile_member" baseapp_profiles/tests/test_graphql_mutations_update.py -A 2

Repository: silverlogic/baseapp-backend

Length of output: 1387


🏁 Script executed:

# Now let's extract and compare the full test content
echo "=== Full content of tests in delete file (lines 105-155) ==="
sed -n '105,155p' baseapp_profiles/tests/test_graphql_mutations_delete.py

echo ""
echo "=== Full content of tests in update file (lines 321-369) ==="
sed -n '321,369p' baseapp_profiles/tests/test_graphql_mutations_update.py

Repository: silverlogic/baseapp-backend

Length of output: 4219


🏁 Script executed:

# Let's also check the exact line numbers where these tests start and end
echo "=== Checking exact test boundaries in delete file ==="
grep -n "^def test_user.*remove_profile_member" baseapp_profiles/tests/test_graphql_mutations_delete.py

echo ""
echo "=== Checking exact test boundaries in update file ==="
grep -n "^def test_user.*remove_profile_member" baseapp_profiles/tests/test_graphql_mutations_update.py

Repository: silverlogic/baseapp-backend

Length of output: 713


Remove duplicate tests from test_graphql_mutations_update.py.

test_user_profile_owner_can_remove_profile_member and test_user_with_permission_can_remove_profile_member are functionally identical in both files (delete file lines 105–155 match update file lines 321–369). These tests belong only in test_graphql_mutations_delete.py since they test deletion behavior. Delete the duplicate versions from the update file.

🤖 Prompt for AI Agents
In `@baseapp_profiles/tests/test_graphql_mutations_delete.py` around lines 105 -
155, Two test functions are duplicated; remove the duplicate definitions of
test_user_profile_owner_can_remove_profile_member and
test_user_with_permission_can_remove_profile_member from the file that tests
updates (keep the versions in the delete tests file), ensuring only the deletion
tests remain; search for the function names to locate and delete the duplicate
blocks so the deletion-focused tests exist in one place.

Comment on lines 372 to +396
def test_user_without_permission_cant_remove_profile_member(
django_user_client, graphql_user_client
):
perm = Permission.objects.get(
content_type__app_label=ProfileUserRole._meta.app_label, codename="add_profileuserrole"
)

user = django_user_client.user
user.user_permissions.add(perm)
user_2 = UserFactory()
user_3 = UserFactory()

profile = ProfileFactory(owner=user_2)
ProfileUserRoleFactory(profile=profile, user=user, role=ProfileUserRole.ProfileRoles.MANAGER)
profile_user_role = ProfileUserRoleFactory(
profile=profile, user=user_3, role=ProfileUserRole.ProfileRoles.MANAGER

response = graphql_user_client(
PROFILE_USER_ROLE_CREATE_GRAPHQL,
variables={
"input": {
"usersIds": [user_2.relay_id],
"profileId": profile.relay_id,
"roleType": "ADMIN",
}
},
)
content = response.json()
assert content["errors"][0]["message"] == "You don't have permission to perform this action"
profile.refresh_from_db()
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Test name is misleading — it tests profile user role creation, not removal.

The function is named test_user_without_permission_cant_remove_profile_member but it uses PROFILE_USER_ROLE_CREATE_GRAPHQL with create-style input (usersIds, roleType). This appears to be testing that a non-owner can't create a role on someone else's profile, not that they can't remove a member.

🐛 Proposed rename
-def test_user_without_permission_cant_remove_profile_member(
+def test_user_without_permission_cant_add_profile_user_role(
     django_user_client, graphql_user_client
 ):
🤖 Prompt for AI Agents
In `@baseapp_profiles/tests/test_graphql_mutations_update.py` around lines 372 -
396, Rename the misleading test function
test_user_without_permission_cant_remove_profile_member to reflect that it
asserts blocking creation of a profile role; update the function name to
something like test_user_without_permission_cant_create_profile_user_role (or
test_user_without_permission_cant_add_profile_member) and keep the existing test
body that uses PROFILE_USER_ROLE_CREATE_GRAPHQL and the create-style input
(usersIds, roleType) so the name matches the behavior being tested.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Profile invitation workflow: send, accept, decline, cancel, and resend
invitations via GraphQL with token-based expiry, status transitions, and
mobile deep-linking with web fallback.
* Email invitations with HTML/text templates and configurable acceptance
URLs; invitees matched to existing accounts or invited by email with
role assignment.

* **Tests**
* Extensive test coverage for invitations: creation, tokens, expiry,
state transitions, permissions, and email sending.

* **Chores**
* DB schema migrations and new expiration constant for invitation
handling; deterministic test factory keys.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Gabriel Almeida <ga@tsl.io>
Co-authored-by: Alisson Patricio <eu@alisson.net>
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