Skip to content

Epic Multiple Profiles#382

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

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

Conversation

@gabrielalmeida-tsl
Copy link

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

Summary by CodeRabbit

  • New Features

    • Add full invitation workflow for profiles (send/accept/decline/cancel/resend) with email templates and deep-link support
    • Add profile member role management (create/update/delete) and store member emails; enhanced user search by email and profile name
    • Expose invitation metadata in GraphQL with permissioned visibility
  • Tests

    • Extensive tests covering invitations, invitation state transitions, email sending, permission paths, and profile-role GraphQL mutations and deletions

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

Adds invitation-driven profile user-role management: new GraphQL mutations and permission checks, invitation lifecycle (token, expiry) with email sending and templates, model fields and migrations for invitations, profile-member filtering by email/profile name, and extensive tests for invitations and mutations.

Changes

Cohort / File(s) Summary
User search filter & tests
baseapp_auth/graphql/filters.py, baseapp_auth/tests/graphql/test_queries_user.py
Renamed q filter method to filter_by_q; search now matches email__iexact and profile__name__search (when profiles app installed). Added GraphQL query and tests for email/profile-name filtering.
Profile invitation/email utilities & templates
baseapp_profiles/emails.py, baseapp_profiles/templates/profiles/emails/invitation-subject.txt.j2, baseapp_profiles/templates/profiles/emails/invitation-body.txt.j2, baseapp_profiles/templates/profiles/emails/invitation-body.html.j2, baseapp_profiles/constants.py
Added invitation creation and email-sending functions, deep-link fallback, email templates, and INVITATION_EXPIRATION_DAYS constant.
GraphQL mutations (profile user roles & invitations)
baseapp_profiles/graphql/mutations.py, baseapp_profiles/tests/test_graphql_mutations_update.py, baseapp_profiles/tests/test_graphql_mutations_delete.py
Introduced ProfileUserRoleCreate/Update/Delete and invitation mutations (send/accept/decline/cancel/resend). Updated tests and mutation constants to new shapes and names; added transactional flows and permission checks.
Model changes & migrations (baseapp_profiles & testproject profiles)
baseapp_profiles/models.py, baseapp_profiles/migrations/0007_*.py, baseapp_profiles/migrations/0008_*.py, baseapp_profiles/migrations/0009_*.py, baseapp_profiles/migrations/0010_*.py, testproject/profiles/migrations/0004_*.py, testproject/profiles/migrations/0005_*.py
Added invitation fields (invited_email, invitation_token, invited_at, invitation_expires_at, responded_at), email field, made user FK nullable/blank, adjusted status choices/defaults, and added unique constraint on (profile, invited_email). Multiple schema migrations added/updated.
GraphQL object types & permissions
baseapp_profiles/graphql/object_types.py, baseapp_profiles/permissions.py
Added can_view_profile_members helper, exposed invitation fields on ProfileUserRole GraphQL type with guarded resolvers, and included add_profileuserrole in permission checks.
Invitation tests
baseapp_profiles/tests/test_invitations.py
Large new test suite covering invitation creation, token management, state transitions (accept/decline/cancel/resend/expire), reuse rules, PII field permission visibility, and email-send fallback handling.
Test settings & factories
baseapp_core/tests/settings.py, baseapp_reports/tests/factories.py, testproject/settings.py
Added django.contrib.postgres to test INSTALLED_APPS; made ReportTypeFactory key deterministic; added frontend deep-link/accept URL toggles and FRONT_ACCEPT_INVITATION_URL for tests/runtime.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant Client
participant GraphQL as GraphQL Server
participant DB as Database
participant Email as Email Service
participant DeepLink as Deep Link Service

Client->>GraphQL: mutation profileSendInvitation(invite input)
GraphQL->>DB: lookup existing User by email
DB-->>GraphQL: user exists? / user record
GraphQL->>DB: create or update ProfileUserRole (invited_email, token, invited_at, expires_at, status=pending)
DB-->>GraphQL: saved invitation (token)
GraphQL->>DeepLink: request deep link for token
DeepLink-->>GraphQL: deep link URL OR error
GraphQL->>Email: send invitation email (accept_url)
Email-->>GraphQL: send result (ok/failure)
GraphQL-->>Client: mutation response (invitation data / errors)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • nossila
  • Hercilio1
  • vitorguima

Poem

🐰 I hopped in with a tiny token,
Sent invites while the code was woken,
Tokens, emails, links that gleam,
Profiles join the growing team,
Hooray — another feature, neatly woven!

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.35% 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 a non-descriptive term that does not convey meaningful information about the specific changeset. Replace the generic title with a specific, descriptive summary of the main changes, such as 'Add invitation system for profile user roles' or 'Implement profile member invitations and user role management'.
✅ 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 (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 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.

gabrielalmeida-tsl and others added 2 commits February 12, 2026 11:54
<!-- 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>
…ic (#391)

## Summary

- Handle email delivery failures gracefully in `SendInvitation` and
`ResendInvitation` mutations using try/except with logging, so the
invitation is still created/updated even if the email service fails
- Add `emailSent` boolean field to both mutation responses, allowing the
frontend to show appropriate feedback when email delivery fails
- Add tests covering email failure scenarios for both send and resend
flows

## Context

This addresses the concern raised in [PR
#368](#368 (comment))
comment about what happens when the email service fails to send an
invitation. Previously, if `send_invitation_email()` raised an
exception, the entire mutation would fail and the database transaction
would roll back — leaving no record of the invitation attempt. Now the
mutation succeeds regardless of email delivery, and the `emailSent`
response field lets the frontend inform the user and offer a resend
option.
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: 2

♻️ Duplicate comments (2)
baseapp_profiles/graphql/mutations.py (2)

300-330: ⚠️ Potential issue | 🟠 Major

Handle ProfileUserRole.DoesNotExist instead of if not obj.
objects.get(...) will raise before the if not obj check is reached.

🐛 Robust lookup
-        obj = ProfileUserRole.objects.get(user_id=user_pk, profile_id=profile_pk)
-
-        if not obj:
-            raise GraphQLError(str(_("User role not found")))
+        try:
+            obj = ProfileUserRole.objects.get(user_id=user_pk, profile_id=profile_pk)
+        except ProfileUserRole.DoesNotExist:
+            raise GraphQLError(str(_("User role not found"))) from None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baseapp_profiles/graphql/mutations.py` around lines 300 - 330, The code calls
ProfileUserRole.objects.get(...) and then checks if not obj, but get() raises
ProfileUserRole.DoesNotExist; update
ProfileUserRoleDelete.mutate_and_get_payload to wrap the lookup in a try/except
catching ProfileUserRole.DoesNotExist and raise a GraphQLError("User role not
found") (or localized equivalent) instead of the invalid if-not check, then
proceed with the permission check on obj.profile, set id_to_return =
obj.relay_id and delete obj as before.

209-262: ⚠️ Potential issue | 🟠 Major

Guard users_ids when it’s omitted to avoid TypeError.
input.get("users_ids") can be None, which makes the list comprehension fail.

🐛 Safe guard
-        profile_user_roles = [
+        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
Verify each finding against the current code and only fix it if needed.

In `@baseapp_profiles/graphql/mutations.py` around lines 209 - 262, The list
comprehension building profile_user_roles in
ProfileUserRoleCreate.mutate_and_get_payload assumes users_ids is iterable but
input.get("users_ids") can be None; guard it by normalizing users_ids to an
empty list (or validate and raise a GraphQLError) before the comprehension so
the loop over users_ids and the call to get_pk_from_relay_id(...) never receives
None; update the users_ids handling right after reading input to either
users_ids = users_ids or users_ids = [] (or raise) and then proceed to create
ProfileUserRole instances and call ProfileUserRole.objects.bulk_create(...)
🧹 Nitpick comments (7)
baseapp_profiles/migrations/0010_alter_profileuserrole_status.py (1)

6-27: This migration exists solely to correct the status default set in 0008 — can be dropped if 0008 is updated.

As noted in 0008, if default=3 is applied directly there, this migration becomes a no-op and the migration chain is cleaner. No independent issues otherwise.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baseapp_profiles/migrations/0010_alter_profileuserrole_status.py` around
lines 6 - 27, This migration (Migration class in
0010_alter_profileuserrole_status.py) only corrects the ProfileUserRole.status
default to 3; either remove this migration from the migration list or instead
set default=3 on the status field in the earlier migration
0008_profileuserrole...(the migration that introduced the status field) so that
0010 becomes a no-op and can be dropped—update the IntegerField choices/default
in the migration that created/altered ProfileUserRole.status (matching
model_name="profileuserrole", name="status") and then delete 0010 to keep the
migration chain clean.
baseapp_profiles/migrations/0008_profileuserrole_invitation_expires_at_and_more.py (1)

69-81: Unnecessary intermediate status default — consolidate migrations 0008 and 0010.

This migration sets status default to 2 (pending), but 0010 immediately overwrites it to 3 (inactive). In a production deployment where migrations run sequentially, any ProfileUserRole created between 0008 and 0010 (or if 0010 is missed) will carry the wrong default. The testproject/0005 migration goes directly to default=3, which is the correct final state. Since 0008 has not yet been applied to production (this is an open PR), consider setting default=3 here and dropping migration 0010 entirely.

♻️ Proposed fix (set correct default directly in 0008)
         field=models.IntegerField(
             choices=[
                 (1, "active"),
                 (2, "pending"),
                 (3, "inactive"),
                 (4, "declined"),
                 (5, "expired"),
             ],
-            default=2,
+            default=3,
         ),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@baseapp_profiles/migrations/0008_profileuserrole_invitation_expires_at_and_more.py`
around lines 69 - 81, The migration alters ProfileUserRole.status to default=2
but a later migration (0010) changes it to 3; change the default in this
migration's AlterField (model_name="profileuserrole", name="status") to
default=3 so the correct final default is applied immediately, and remove or
consolidate the subsequent migration 0010 to avoid an intermediate incorrect
default being applied in production.
baseapp_profiles/templates/profiles/emails/invitation-body.txt.j2 (1)

1-10: Consider adding a decline link, given the model supports an active declined status.

ProfileUserRole has a declined (status=4) state and a responded_at field, implying the system supports explicit decline. Currently the only CTA is accept_url; users who want to actively decline must find the flow elsewhere. A decline URL mirroring accept_url (e.g. decline_url) would close this gap.

Additionally, if expiration_date is passed as a raw datetime object from emails.py, the rendered format may be unreadable. Verify it is pre-formatted to a human-friendly string before being passed to the template context.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baseapp_profiles/templates/profiles/emails/invitation-body.txt.j2` around
lines 1 - 10, Add an explicit decline CTA to the invitation template and ensure
the expiration date is pre-formatted before rendering: update
invitation-body.txt.j2 to render a new decline_url alongside the existing
accept_url (reference symbols: invitation-body.txt.j2, accept_url, decline_url,
expiration_date) and extend the email generation in emails.py to build/pass a
human-friendly expiration_date string (e.g., strftime or a localization helper)
and a decline_url pointing to the endpoint that records ProfileUserRole
declined/status=4 and sets responded_at (reference symbols: emails.py,
ProfileUserRole, responded_at). Ensure the decline_url mirrors accept_url logic
and that emails.py populates both URLs and a formatted expiration_date in the
template context.
baseapp_profiles/graphql/object_types.py (1)

38-53: Ruff RUF012: prefer tuples for class Meta attributes.
Using tuples avoids mutable class-attribute warnings without changing behavior.

♻️ Proposed change
     class Meta:
         model = ProfileUserRole
-        interfaces = [RelayNode]
-        fields = [
+        interfaces = (RelayNode,)
+        fields = (
             "id",
             "pk",
             "user",
             "role",
             "status",
             "created",
             "modified",
             "invited_email",
             "invited_at",
             "invitation_expires_at",
             "responded_at",
-        ]
+        )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baseapp_profiles/graphql/object_types.py` around lines 38 - 53, The Meta
class for the GraphQL type uses mutable lists for class attributes; change
interfaces and fields in the Meta of the ProfileUserRole-based type to use
tuples instead of lists (e.g., replace interfaces = [RelayNode] and fields = [
"id", "pk", ... ] with interfaces = (RelayNode,) and fields = ("id", "pk", ...))
to satisfy Ruff RUF012 and avoid mutable class-attribute warnings.
baseapp_profiles/emails.py (1)

17-25: inviter is unused; either use it or silence ARG001 explicitly.
If you’re keeping the parameter for API compatibility, consider marking it as intentionally unused.

♻️ Minimal lint-safe tweak
-def create_invitation(profile, inviter, invited_email, role=ProfileUserRole.ProfileRoles.MANAGER):
+def create_invitation(profile, inviter, invited_email, role=ProfileUserRole.ProfileRoles.MANAGER):  # noqa: ARG001
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baseapp_profiles/emails.py` around lines 17 - 25, The parameter inviter in
create_invitation is declared but never used; either use it where needed or mark
it intentionally unused to satisfy linters—e.g., rename the parameter to
_inviter (or assign it to a short unused variable like _ = inviter) or add an
explicit linter suppression comment (e.g., "# noqa: ARG001" or the project’s
preferred suppression) on the function signature so create_invitation(profile,
inviter, ...) becomes lint-clean without changing behavior.
baseapp_profiles/models.py (1)

270-279: Ruff RUF012: use a tuple for Meta.constraints.
Prevents mutable class-attribute warnings with no functional change.

♻️ Proposed change
     class Meta:
         abstract = True
         unique_together = [("user", "profile")]
-        constraints = [
+        constraints = (
             models.UniqueConstraint(
                 fields=["profile", "invited_email"],
                 name="unique_profile_invited_email",
                 condition=models.Q(invited_email__isnull=False),
             ),
-        ]
+        )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baseapp_profiles/models.py` around lines 270 - 279, Meta in the abstract
model defines constraints as a list which triggers Ruff RUF012; change the
mutable list to an immutable tuple by replacing the list assigned to
Meta.constraints with a tuple containing the existing models.UniqueConstraint
instance (i.e., update the class Meta in the abstract model that currently has
unique_together and constraints to use constraints = (
models.UniqueConstraint(...), ) so the constraint remains the same but is an
immutable tuple).
baseapp_profiles/tests/test_invitations.py (1)

400-535: Use the patched mocks/fixtures (or assert calls) to avoid ARG002 noise.
A few tests patch send_invitation_email but never reference the mock, and some accept django_user_client without use. If Ruff is enforced, this will warn and the intent isn’t asserted. Consider asserting the call or dropping the unused fixture.

♻️ Suggested adjustments (apply similarly to other tests in these blocks)
 def test_send_invitation_succeeds_even_if_email_fails(
     self, mock_send_email, django_user_client, graphql_user_client
 ):
     ...
     response = graphql_user_client(mutation, variables=variables)
     content = response.json()
+    assert mock_send_email.called

-def test_accept_null_user_wrong_email_returns_wrong_user(
-    self, django_user_client, graphql_user_client
-):
+def test_accept_null_user_wrong_email_returns_wrong_user(self, graphql_user_client):
     ...

Also applies to: 619-823

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baseapp_profiles/tests/test_invitations.py` around lines 400 - 535, Several
tests patch baseapp_profiles.emails.send_invitation_email but never use the mock
or have unused fixtures (e.g., django_user_client) which triggers ARG002; in
each affected test (e.g., test_send_invitation_succeeds_even_if_email_fails and
test_resend_invitation_succeeds_even_if_email_fails) either remove unused
fixture params or add assertions on the mock (e.g.,
mock_send_email.assert_called() / assert_called_once() or
assert_called_with(...) using the invitation data) to demonstrate the patched
function was exercised; update any similar tests in the same block (and lines
619-823) the same way so the mock is referenced and unused fixtures are removed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@baseapp_profiles/graphql/mutations.py`:
- Around line 435-521: The code calls Profile.objects.get(pk=profile_pk) which
can raise Profile.DoesNotExist and cause a 500; wrap the lookup (after
get_pk_from_relay_id(profile_id)) in a try/except catching Profile.DoesNotExist
and raise a GraphQLError with a clear message (e.g. "Profile not found") and an
extensions code like "not_found" so the mutation (ProfileSendInvitation) returns
a controlled GraphQL error instead of crashing.

In `@testproject/settings.py`:
- Line 300: FRONT_URL is referenced (for FRONT_ACCEPT_INVITATION_URL) but pulled
via a star import which Ruff flags as F405; add an explicit import for FRONT_URL
from baseapp_core.tests.settings (e.g. add from baseapp_core.tests.settings
import FRONT_URL) and update the other similar constants that rely on FRONT_URL
(the block around lines 295–299) to use the explicitly imported symbol so Ruff
no longer reports F405.

---

Duplicate comments:
In `@baseapp_profiles/graphql/mutations.py`:
- Around line 300-330: The code calls ProfileUserRole.objects.get(...) and then
checks if not obj, but get() raises ProfileUserRole.DoesNotExist; update
ProfileUserRoleDelete.mutate_and_get_payload to wrap the lookup in a try/except
catching ProfileUserRole.DoesNotExist and raise a GraphQLError("User role not
found") (or localized equivalent) instead of the invalid if-not check, then
proceed with the permission check on obj.profile, set id_to_return =
obj.relay_id and delete obj as before.
- Around line 209-262: The list comprehension building profile_user_roles in
ProfileUserRoleCreate.mutate_and_get_payload assumes users_ids is iterable but
input.get("users_ids") can be None; guard it by normalizing users_ids to an
empty list (or validate and raise a GraphQLError) before the comprehension so
the loop over users_ids and the call to get_pk_from_relay_id(...) never receives
None; update the users_ids handling right after reading input to either
users_ids = users_ids or users_ids = [] (or raise) and then proceed to create
ProfileUserRole instances and call ProfileUserRole.objects.bulk_create(...)

---

Nitpick comments:
In `@baseapp_profiles/emails.py`:
- Around line 17-25: The parameter inviter in create_invitation is declared but
never used; either use it where needed or mark it intentionally unused to
satisfy linters—e.g., rename the parameter to _inviter (or assign it to a short
unused variable like _ = inviter) or add an explicit linter suppression comment
(e.g., "# noqa: ARG001" or the project’s preferred suppression) on the function
signature so create_invitation(profile, inviter, ...) becomes lint-clean without
changing behavior.

In `@baseapp_profiles/graphql/object_types.py`:
- Around line 38-53: The Meta class for the GraphQL type uses mutable lists for
class attributes; change interfaces and fields in the Meta of the
ProfileUserRole-based type to use tuples instead of lists (e.g., replace
interfaces = [RelayNode] and fields = [ "id", "pk", ... ] with interfaces =
(RelayNode,) and fields = ("id", "pk", ...)) to satisfy Ruff RUF012 and avoid
mutable class-attribute warnings.

In
`@baseapp_profiles/migrations/0008_profileuserrole_invitation_expires_at_and_more.py`:
- Around line 69-81: The migration alters ProfileUserRole.status to default=2
but a later migration (0010) changes it to 3; change the default in this
migration's AlterField (model_name="profileuserrole", name="status") to
default=3 so the correct final default is applied immediately, and remove or
consolidate the subsequent migration 0010 to avoid an intermediate incorrect
default being applied in production.

In `@baseapp_profiles/migrations/0010_alter_profileuserrole_status.py`:
- Around line 6-27: This migration (Migration class in
0010_alter_profileuserrole_status.py) only corrects the ProfileUserRole.status
default to 3; either remove this migration from the migration list or instead
set default=3 on the status field in the earlier migration
0008_profileuserrole...(the migration that introduced the status field) so that
0010 becomes a no-op and can be dropped—update the IntegerField choices/default
in the migration that created/altered ProfileUserRole.status (matching
model_name="profileuserrole", name="status") and then delete 0010 to keep the
migration chain clean.

In `@baseapp_profiles/models.py`:
- Around line 270-279: Meta in the abstract model defines constraints as a list
which triggers Ruff RUF012; change the mutable list to an immutable tuple by
replacing the list assigned to Meta.constraints with a tuple containing the
existing models.UniqueConstraint instance (i.e., update the class Meta in the
abstract model that currently has unique_together and constraints to use
constraints = ( models.UniqueConstraint(...), ) so the constraint remains the
same but is an immutable tuple).

In `@baseapp_profiles/templates/profiles/emails/invitation-body.txt.j2`:
- Around line 1-10: Add an explicit decline CTA to the invitation template and
ensure the expiration date is pre-formatted before rendering: update
invitation-body.txt.j2 to render a new decline_url alongside the existing
accept_url (reference symbols: invitation-body.txt.j2, accept_url, decline_url,
expiration_date) and extend the email generation in emails.py to build/pass a
human-friendly expiration_date string (e.g., strftime or a localization helper)
and a decline_url pointing to the endpoint that records ProfileUserRole
declined/status=4 and sets responded_at (reference symbols: emails.py,
ProfileUserRole, responded_at). Ensure the decline_url mirrors accept_url logic
and that emails.py populates both URLs and a formatted expiration_date in the
template context.

In `@baseapp_profiles/tests/test_invitations.py`:
- Around line 400-535: Several tests patch
baseapp_profiles.emails.send_invitation_email but never use the mock or have
unused fixtures (e.g., django_user_client) which triggers ARG002; in each
affected test (e.g., test_send_invitation_succeeds_even_if_email_fails and
test_resend_invitation_succeeds_even_if_email_fails) either remove unused
fixture params or add assertions on the mock (e.g.,
mock_send_email.assert_called() / assert_called_once() or
assert_called_with(...) using the invitation data) to demonstrate the patched
function was exercised; update any similar tests in the same block (and lines
619-823) the same way so the mock is referenced and unused fixtures are removed.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ebff44f and cfb8740.

📒 Files selected for processing (15)
  • baseapp_profiles/constants.py
  • baseapp_profiles/emails.py
  • baseapp_profiles/graphql/mutations.py
  • baseapp_profiles/graphql/object_types.py
  • baseapp_profiles/migrations/0008_profileuserrole_invitation_expires_at_and_more.py
  • baseapp_profiles/migrations/0009_profileuserrole_unique_profile_invited_email.py
  • baseapp_profiles/migrations/0010_alter_profileuserrole_status.py
  • baseapp_profiles/models.py
  • baseapp_profiles/templates/profiles/emails/invitation-body.html.j2
  • baseapp_profiles/templates/profiles/emails/invitation-body.txt.j2
  • baseapp_profiles/templates/profiles/emails/invitation-subject.txt.j2
  • baseapp_profiles/tests/test_invitations.py
  • baseapp_reports/tests/factories.py
  • testproject/profiles/migrations/0005_profileuserrole_invitation_expires_at_and_more.py
  • testproject/settings.py
✅ Files skipped from review due to trivial changes (1)
  • baseapp_profiles/templates/profiles/emails/invitation-body.html.j2

Comment on lines +435 to +521
profile_id = input.get("profile_id")
email = input.get("email")
role = input.get("role")

profile_pk = get_pk_from_relay_id(profile_id)
profile = Profile.objects.get(pk=profile_pk)

if not info.context.user.has_perm(
f"{profile_user_role_app_label}.add_profileuserrole", profile
):
raise GraphQLError(
str(_("You don't have permission to perform this action")),
extensions={"code": "permission_required"},
)

normalized_email = email.lower()
User = get_user_model()

try:
invited_user = User.objects.get(email__iexact=normalized_email)
except User.DoesNotExist:
invited_user = None

with transaction.atomic():
existing_role = (
ProfileUserRole.objects.select_for_update()
.filter(profile=profile, invited_email__iexact=normalized_email)
.first()
)

if existing_role:
if existing_role.status == ProfileUserRole.ProfileRoleStatus.ACTIVE:
raise GraphQLError(
str(_("This user is already a member of this profile")),
extensions={"code": "already_member"},
)

if existing_role.status == ProfileUserRole.ProfileRoleStatus.PENDING:
if not existing_role.is_invitation_expired():
raise GraphQLError(
str(_("An invitation has already been sent to this email")),
extensions={"code": "duplicate_invitation"},
)
existing_role.status = ProfileUserRole.ProfileRoleStatus.EXPIRED

if existing_role.status in [
ProfileUserRole.ProfileRoleStatus.DECLINED,
ProfileUserRole.ProfileRoleStatus.INACTIVE,
ProfileUserRole.ProfileRoleStatus.EXPIRED,
]:
_reset_invitation_for_send(existing_role, role=role, user=invited_user)
existing_role.save()
invitation = existing_role
else:
raise GraphQLError(
str(_("Cannot send invitation in current state")),
extensions={"code": "invalid_status"},
)
else:
try:
invitation = ProfileUserRole.objects.create(
profile=profile,
user=invited_user,
invited_email=normalized_email,
role=role,
status=ProfileUserRole.ProfileRoleStatus.PENDING,
invited_at=timezone.now(),
invitation_expires_at=(
timezone.now() + timedelta(days=INVITATION_EXPIRATION_DAYS)
),
)
invitation.generate_invitation_token()
invitation.save()
except IntegrityError:
raise GraphQLError(
str(_("An invitation has already been sent to this email")),
extensions={"code": "duplicate_invitation"},
)

email_sent = True
try:
send_invitation_email(invitation, info.context.user)
except Exception:
logger.exception("Failed to send invitation email to %s", normalized_email)
email_sent = False

return ProfileSendInvitation(profile_user_role=invitation, email_sent=email_sent)
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

Handle missing profiles in ProfileSendInvitation.
Profile.objects.get(...) can raise DoesNotExist and bubble up as a 500.

🐛 Suggested guard
-        profile = Profile.objects.get(pk=profile_pk)
+        try:
+            profile = Profile.objects.get(pk=profile_pk)
+        except Profile.DoesNotExist:
+            raise GraphQLError(
+                str(_("Profile not found")),
+                extensions={"code": "not_found"},
+            ) from None
🧰 Tools
🪛 Ruff (0.15.2)

[warning] 509-512: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baseapp_profiles/graphql/mutations.py` around lines 435 - 521, The code calls
Profile.objects.get(pk=profile_pk) which can raise Profile.DoesNotExist and
cause a 500; wrap the lookup (after get_pk_from_relay_id(profile_id)) in a
try/except catching Profile.DoesNotExist and raise a GraphQLError with a clear
message (e.g. "Profile not found") and an extensions code like "not_found" so
the mutation (ProfileSendInvitation) returns a controlled GraphQL error instead
of crashing.

FRONT_CHANGE_EMAIL_CONFIRM_URL = FRONT_URL + "/change-email/{id}/{token}"
FRONT_CHANGE_EMAIL_VERIFY_URL = FRONT_URL + "/change-email-verify/{id}/{token}"
FRONT_CHANGE_EXPIRED_PASSWORD_URL = FRONT_URL + "/change-expired-password/{token}"
FRONT_ACCEPT_INVITATION_URL = FRONT_URL + "/accept-invitation/{token}"
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

Ruff F405: FRONT_URL may be undefined due to star import.

FRONT_URL is brought in via from baseapp_core.tests.settings import * and is not explicitly imported, which Ruff flags as potentially undefined (F405). The same pattern exists on lines 295–299, so the cleanest fix is a single explicit import alongside the existing env fix suggested in a prior review.

🛠️ Suggested fix
-from baseapp_core.tests.settings import *  # noqa
+from baseapp_core.tests.settings import *  # noqa
+from baseapp_core.tests.settings import env, FRONT_URL  # noqa: F401
🧰 Tools
🪛 Ruff (0.15.2)

[error] 300-300: FRONT_URL may be undefined, or defined from star imports

(F405)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@testproject/settings.py` at line 300, FRONT_URL is referenced (for
FRONT_ACCEPT_INVITATION_URL) but pulled via a star import which Ruff flags as
F405; add an explicit import for FRONT_URL from baseapp_core.tests.settings
(e.g. add from baseapp_core.tests.settings import FRONT_URL) and update the
other similar constants that rely on FRONT_URL (the block around lines 295–299)
to use the explicitly imported symbol so Ruff no longer reports F405.

@@ -0,0 +1 @@
INVITATION_EXPIRATION_DAYS = 7
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 move this to a django settings var instead? make it get from django settings, if it is not present, then use 7 as default

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