Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve accessy syncing performance and improve code-reuse. #586

Merged
merged 3 commits into from
Dec 26, 2024

Conversation

HalfVoxel
Copy link
Contributor

@HalfVoxel HalfVoxel commented Dec 16, 2024

  • Reduced the number of calls we make to the AccessyAPI during syncs.
  • Re-use our existing code for getting membership summaries, instead of doing custom database calls.
  • Use the syncing code for when updating a single member as well, to ensure there are no differences in behaviour.
  • Handle accessy users with no phone number (msisdn). I think these are accounts that have been reset.
  • Members will now get access, if they have the appropriate spans, even if the labaccess agreement has not been signed. This makes accessy match how it looks in the rest of the UI. Note that spans are typically not created until the agreement has been signed. So this doesn't cause any practical changes in behaviour.
  • Updated method signatures for better clarity and maintainability.

Summary by CodeRabbit

  • New Features

    • Introduced a new method to add members to groups using member objects.
    • Enhanced functionality for retrieving and syncing member access based on optional member IDs.
  • Bug Fixes

    • Improved error handling and logging for specific status codes.
  • Documentation

    • Added comments to clarify logic regarding membership access.
  • Refactor

    • Simplified method signatures to accept member objects instead of phone numbers.
    • Streamlined the control flow in access-related functions.
  • Tests

    • Updated tests to reflect changes in member handling and assertions related to lab access agreements.

Copy link
Contributor

coderabbitai bot commented Dec 16, 2024

Walkthrough

The pull request introduces significant modifications to the multiaccessy module, focusing on enhancing the flexibility and object-oriented design of member and access management. Key changes include making the msisdn attribute optional in the AccessyUser class, refactoring methods to work directly with AccessyMember objects instead of phone numbers, and updating synchronization functions to support optional member-specific operations. Additionally, the error handling and logging mechanisms have been refined. These modifications improve type safety, reduce redundancy, and provide more granular control over member synchronization processes.

Changes

File Change Summary
api/src/multiaccessy/accessy.py - Made msisdn optional in AccessyUser
- Refactored remove_from_org and remove_from_group to use AccessyMember
- Added add_to_group method
api/src/multiaccessy/invite.py - Simplified ensure_accessy_labaccess to directly call sync(member_id)
api/src/multiaccessy/sync.py - Updated get_wanted_access and sync to support optional member_id
- Refined member synchronization logic
api/src/systest/api/accessy_sync_test.py - Added member parameter to create_span method
- Updated test assertions for member inclusion
api/src/membership/membership.py - Added comments clarifying logic for effective_labaccess_active in get_membership_summaries

Sequence Diagram

sequenceDiagram
    participant Sync as Sync Module
    participant AccessySession as Accessy Session
    participant Member as AccessyMember
    
    alt Sync Specific Member
        Sync->>Sync: Get member details
        Sync->>AccessySession: Remove/Add to groups
        AccessySession->>Member: Perform operations
    else Sync All Members
        Sync->>Sync: Fetch all members
        loop For Each Member
            Sync->>AccessySession: Sync member access
        end
    end
Loading

Poem

🐰 Hop, hop, through the code we go,
Refactoring with a rabbit's glow
Members dance, types now light
Optional fields, a coder's delight!
Sync and access, smooth as can be
CodeRabbit's magic, setting logic free! 🌈

Tip

CodeRabbit's docstrings feature is now available as part of our Early Access Program! Simply use the command @coderabbitai generate docstrings to have CodeRabbit automatically generate docstrings for your pull request. We would love to hear your feedback on Discord.


Thank you for using CodeRabbit. We offer it for free to the OSS community and would appreciate your support in helping us grow. If you find it useful, would you consider giving us a shout-out on your favorite social media?

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR. (Beta)
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (4)
api/src/multiaccessy/invite.py (1)

66-66: Add exception handling for the sync function call

The sync function may raise exceptions due to network issues or data inconsistencies. To enhance robustness, consider wrapping the sync(member_id) call in a try-except block to handle potential exceptions gracefully.

Apply this diff to add exception handling:

     if not summary.labaccess_active and not summary.special_labaccess_active:
         raise AccessyInvitePreconditionFailed("ingen aktiv labaccess")

+    try:
         sync(member_id=member_id)
+    except Exception as e:
+        logger.error(f"Failed to sync member {member_id}: {e}")
+        raise
api/src/multiaccessy/sync.py (2)

33-33: Raise more specific exceptions instead of generic Exception

In lines 33 and 120, raising a generic Exception lacks specificity and can make error handling less precise. Use a more specific exception like ValueError to provide clearer context about the error.

Apply this diff to improve exception handling:

 Line 33:
-            raise Exception("Member does not exist")
+            raise ValueError(f"Member with ID {member_id} does not exist")

 Line 120:
-            raise Exception("Member does not exist")
+            raise ValueError(f"Member with ID {member_id} does not exist")

Also applies to: 120-120


121-122: Add logging when skipping a member due to missing phone number

Currently, if a member's phone number is None, the function silently returns without logging any information. Adding a log entry will improve traceability and debugging.

Apply this diff to add a log message:

         if member.phone is None:
+            logger.info(f"Skipping sync for member {member_id} because phone number is missing")
             return
api/src/multiaccessy/accessy.py (1)

274-275: Ensure membership_id is populated before API calls

In remove_from_org and remove_from_group, member.membership_id is used. Ensure that membership_id is not None to prevent API errors. If necessary, add checks or raise exceptions when it's missing.

Consider adding an assertion:

     def remove_from_org(self, member: AccessyMember) -> None:
+        assert member.membership_id is not None, "membership_id is required"
         self.__delete(f"/org/admin/organization/{self.organization_id()}/user/{member.user_id}")

Also applies to: 277-278

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 34cb801 and 95ecd6e.

📒 Files selected for processing (4)
  • api/src/multiaccessy/accessy.py (4 hunks)
  • api/src/multiaccessy/invite.py (2 hunks)
  • api/src/multiaccessy/sync.py (3 hunks)
  • api/src/systest/api/accessy_sync_test.py (1 hunks)
🔇 Additional comments (5)
api/src/multiaccessy/invite.py (1)

16-17: Import statement is appropriate and maintains module dependencies

The import of sync from .sync ensures that the function is available for use in this module. There are no issues with circular dependencies or module resolution.

api/src/multiaccessy/sync.py (1)

107-164: Modifications enhance targeted synchronization and maintain correctness

The updates to sync function support syncing a specific member when member_id is provided, improving flexibility. The changes are well-integrated and maintain the overall functionality.

api/src/systest/api/accessy_sync_test.py (2)

98-107: Verify inclusion of members without lab access agreement

In the test, a member without a labaccess_agreement_at is included in the wanted access list. Please verify if this behavior aligns with the intended access policies, as it may allow access without signed agreements.


49-154: Updates to test cases reflect new member-span relationships

The test cases have been correctly updated to pass the member parameter to create_span, ensuring spans are associated with the correct members. Assertions have been adjusted appropriately.

api/src/multiaccessy/accessy.py (1)

184-184: Updates correctly handle optional msisdn values

Changing msisdn to be optional and adjusting the handling ensures that users without a phone number are managed appropriately, enhancing robustness.

Also applies to: 526-528

Comment on lines +29 to +49
def get_wanted_access(today: date, member_id: Optional[int] = None) -> dict[PHONE, AccessyMember]:
if member_id is not None:
member = db_session.query(Member).get(member_id)
if member is None:
raise Exception("Member does not exist")
members = [member]
summaries = [get_membership_summary(member_id, at_date=today)]
else:
members, summaries = get_members_and_membership(at_date=today)

return {
m.phone: AccessyMember(
phone=m.phone,
name=f"{m.firstname} {m.lastname}",
groups={GROUPS[span.type] for span in m.spans},
member_id=m.member_id,
member_number=m.member_number,
member.phone: AccessyMember(
phone=member.phone,
name=f"{member.firstname} {member.lastname}",
groups={
group
for group, enabled in {
ACCESSY_SPECIAL_LABACCESS_GROUP: membership.special_labaccess_active,
ACCESSY_LABACCESS_GROUP: membership.labaccess_active,
}.items()
if enabled
},
member_id=member.member_id,
member_number=member.member_number,
)
for m in query
for member, membership in zip(members, summaries)
if member.phone is not None and membership.effective_labaccess_active
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Critical issue: Missing check for lab access agreement

The get_wanted_access function does not verify if member.labaccess_agreement_at is set. Including members who haven't signed the lab access agreement might violate policy or introduce security risks.

Consider updating the filtering condition to ensure only members who have signed the lab access agreement are included:

         for member, membership in zip(members, summaries)
-        if member.phone is not None and membership.effective_labaccess_active
+        if (
+            member.phone is not None
+            and membership.effective_labaccess_active
+            and member.labaccess_agreement_at is not None
+        )
     }
📝 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 get_wanted_access(today: date, member_id: Optional[int] = None) -> dict[PHONE, AccessyMember]:
if member_id is not None:
member = db_session.query(Member).get(member_id)
if member is None:
raise Exception("Member does not exist")
members = [member]
summaries = [get_membership_summary(member_id, at_date=today)]
else:
members, summaries = get_members_and_membership(at_date=today)
return {
m.phone: AccessyMember(
phone=m.phone,
name=f"{m.firstname} {m.lastname}",
groups={GROUPS[span.type] for span in m.spans},
member_id=m.member_id,
member_number=m.member_number,
member.phone: AccessyMember(
phone=member.phone,
name=f"{member.firstname} {member.lastname}",
groups={
group
for group, enabled in {
ACCESSY_SPECIAL_LABACCESS_GROUP: membership.special_labaccess_active,
ACCESSY_LABACCESS_GROUP: membership.labaccess_active,
}.items()
if enabled
},
member_id=member.member_id,
member_number=member.member_number,
)
for m in query
for member, membership in zip(members, summaries)
if member.phone is not None and membership.effective_labaccess_active
def get_wanted_access(today: date, member_id: Optional[int] = None) -> dict[PHONE, AccessyMember]:
if member_id is not None:
member = db_session.query(Member).get(member_id)
if member is None:
raise Exception("Member does not exist")
members = [member]
summaries = [get_membership_summary(member_id, at_date=today)]
else:
members, summaries = get_members_and_membership(at_date=today)
return {
member.phone: AccessyMember(
phone=member.phone,
name=f"{member.firstname} {member.lastname}",
groups={
group
for group, enabled in {
ACCESSY_SPECIAL_LABACCESS_GROUP: membership.special_labaccess_active,
ACCESSY_LABACCESS_GROUP: membership.labaccess_active,
}.items()
if enabled
},
member_id=member.member_id,
member_number=member.member_number,
)
for member, membership in zip(members, summaries)
if (
member.phone is not None
and membership.effective_labaccess_active
and member.labaccess_agreement_at is not None
)

@HalfVoxel HalfVoxel force-pushed the aron/improve_accessy branch from aa830a4 to 4c66716 Compare December 16, 2024 21:00
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
api/src/multiaccessy/sync.py (1)

23-49: ⚠️ Potential issue

Critical: Lab access agreement verification still missing

The function allows access for members even without a signed lab access agreement, which was flagged in a previous review.

Consider adding the lab access agreement check:

    return {
        member.phone: AccessyMember(
            phone=member.phone,
            name=f"{member.firstname} {member.lastname}",
            groups={
                group
                for group, enabled in {
                    ACCESSY_SPECIAL_LABACCESS_GROUP: membership.special_labaccess_active,
                    ACCESSY_LABACCESS_GROUP: membership.labaccess_active,
                }.items()
                if enabled
            },
            member_id=member.member_id,
            member_number=member.member_number,
        )
        for member, membership in zip(members, summaries)
-       if member.phone is not None and membership.effective_labaccess_active
+       if member.phone is not None and membership.effective_labaccess_active and member.labaccess_agreement_at is not None
    }
🧹 Nitpick comments (2)
api/src/systest/api/accessy_sync_test.py (1)

98-107: Add test cases for edge cases

The test for unsigned lab access agreement should include more scenarios, such as expired agreements or pending agreements.

Consider adding these test cases:

# Test expired agreement
expired_agreement_member = self.db.create_member(
    phone=random_phone_number(),
    labaccess_agreement_at=self.date(-30),  # 30 days ago
)
self.db.create_span(
    startdate=self.date(0),
    enddate=self.date(0),
    type=Span.LABACCESS,
    member=expired_agreement_member,
)

# Test pending agreement
pending_agreement_member = self.db.create_member(
    phone=random_phone_number(),
    labaccess_agreement_at=self.date(1),  # Future date
)
self.db.create_span(
    startdate=self.date(0),
    enddate=self.date(0),
    type=Span.LABACCESS,
    member=pending_agreement_member,
)
api/src/membership/membership.py (1)

Line range hint 1-240: Consider optimizing database queries for better performance.

Since this PR focuses on improving syncing performance, consider these optimizations:

  1. The similar SQL queries for labaccess_active, membership_active, and special_labaccess_active could be combined into a single query using a UNION or conditional aggregation.
  2. Add database indices for frequently queried columns (member_id, type, startdate, enddate).
  3. Consider caching membership summaries for frequently accessed members.

Would you like me to provide a specific implementation for any of these optimizations?

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 95ecd6e and 4dee06a.

📒 Files selected for processing (5)
  • api/src/membership/membership.py (1 hunks)
  • api/src/multiaccessy/accessy.py (4 hunks)
  • api/src/multiaccessy/invite.py (2 hunks)
  • api/src/multiaccessy/sync.py (3 hunks)
  • api/src/systest/api/accessy_sync_test.py (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • api/src/multiaccessy/accessy.py
🔇 Additional comments (4)
api/src/multiaccessy/invite.py (2)

16-16: LGTM: Good modularization of sync functionality

The import of the sync function aligns well with the PR's objective of improving code reuse.


66-66: Verify edge cases in the sync function

While the refactoring improves code reuse, we should verify that all edge cases previously handled in this function are properly covered by the sync function.

✅ Verification successful

Edge cases are properly handled in the sync function

The verification shows that the sync function adequately handles all relevant edge cases:

  • Member existence check: Both functions verify if the member exists and raise appropriate exceptions
  • Phone number validation: Both handle cases where phone numbers are missing
  • Access validation: The invite.py's precondition check for active lab access is maintained as it runs before calling sync()
  • Configuration checks: The sync function includes proper validation for Accessy configuration (ACCESSY_LABACCESS_GROUP, ACCESSY_SPECIAL_LABACCESS_GROUP, accessy_session)
  • Pending invites handling: The sync function includes additional logic to prevent duplicate invites
🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Description: Check for edge case handling in the old vs new implementation

# Search for error handling and edge cases in the old implementation
git log -p HEAD^..HEAD -- api/src/multiaccessy/invite.py | grep -A 5 "raise\|except\|if\|else"

# Search for corresponding error handling in the new sync implementation
rg "raise\|except\|if\|else" api/src/multiaccessy/sync.py

Length of output: 59


Script:

#!/bin/bash
# Let's try a different approach to analyze the changes and edge cases

# First, let's see the actual changes in invite.py
rg -A 5 -B 5 "sync\(member_id=" api/src/multiaccessy/invite.py

# Then check the sync function implementation
fd sync.py$ --exec cat {}

# Look for error handling patterns in both files
rg "try|except|raise|if\s+not|if\s+\w+\s+is\s+None" api/src/multiaccessy/{invite,sync}.py

Length of output: 7591

api/src/systest/api/accessy_sync_test.py (1)

135-146: LGTM: Good null safety checks

The added assertions for phone number presence improve the robustness of the tests.

api/src/membership/membership.py (1)

148-152: Documentation clearly explains the access control logic.

The added comments effectively document that lab access is determined solely by the presence of spans, independent of membership agreement status. This aligns with the PR objectives.

However, let's verify this security-relevant change:

✅ Verification successful

Based on the evidence from the shell scripts, I can now generate the final response:

The documented behavior of granting lab access based on spans is correct and verified by tests.

The codebase confirms this behavior through:

  • Test cases in api/src/systest/api/accessy_sync_test.py explicitly verify that lab access is granted based on spans, including a test case included_even_if_labaccess_agreement_not_signed
  • The implementation in api/src/membership/membership.py shows that effective_labaccess_active is determined solely by the presence of either regular lab access spans or special lab access spans
  • The comment accurately reflects that while the agreement is typically required for adding spans (through order actions), the access control itself only depends on the spans

This is an intentional security model where:

  1. Spans are the source of truth for access control
  2. The membership agreement acts as a prerequisite for obtaining spans through normal processes
  3. Special access can be granted via spans even without an agreement when needed
🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Description: Verify the intentional decoupling of lab access from membership agreement
# by checking for any related security discussions or documentation.

# Check for related security discussions in issues/PRs
gh search issues "membership agreement lab access spans" --repo makerspace/makeradmin

# Look for related security documentation
rg -g "!{node_modules,dist,build}/*" -A 5 "lab.*access.*agreement|membership.*agreement.*access" 

# Check for any security-related tests
rg -g "*test*.py" -A 5 "test.*lab.*access.*agreement|test.*membership.*agreement"

Length of output: 18130


Script:

#!/bin/bash
# Let's verify the behavior by checking the implementation and tests

# Check the effective_labaccess_active implementation
ast-grep --pattern 'def effective_labaccess_active($$$)'

# Check for test cases specifically about lab access spans
rg -g "*test*.py" -A 10 "test.*labaccess.*span|test.*effective_labaccess"

# Check for any Accessy-related security checks
rg -g "!{node_modules,dist,build}/*" -A 5 "effective_labaccess.*active|labaccess.*span"

Length of output: 9255

Comment on lines +101 to +158
def sync(today: Optional[date] = None, member_id: Optional[int] = None) -> None:
if accessy_session is None:
logger.info(f"accessy sync skipped, accessy not configured.")
return

if not today:
today = date.today()

actual_members = accessy_session.get_all_members()
# If a specific member is given, sync only that member,
# otherwise sync all members
if member_id is not None:
member = db_session.query(Member).get(member_id)
if member is None:
raise Exception("Member does not exist")
if member.phone is None:
return
accessy_member = accessy_session._get_org_user_from_phone(member.phone)
actual_members = [accessy_member] if accessy_member is not None else []
else:
actual_members = accessy_session.get_all_members()

pending_invites = accessy_session.get_pending_invitations(after_date=today - timedelta(days=7))
wanted_members = get_wanted_access(today)
wanted_members = get_wanted_access(today, member_id=member_id)

actual_members_by_phone = {}
members_to_discard: List[AccessyMember] = []
for m in actual_members:
if m.phone:
actual_members_by_phone[m.phone] = m
else:
logger.warning(
f"accessy sync got member %s from accessy without phone number, skipping in calculation, will probably cause extra invite or delayed org remove",
m,
)
# Members with no phone numbers are probably accounts that have gotten reset
members_to_discard.append(m)

diff = calculate_diff(actual_members_by_phone, wanted_members)

for member in diff.invites:
if member.phone in pending_invites:
logger.info(f"accessy sync skipping, invite already pending: {member}")
for accessy_member in diff.invites:
assert accessy_member.phone is not None
if accessy_member.phone in pending_invites:
logger.info(f"accessy sync skipping, invite already pending: {accessy_member}")
continue
logger.info(f"accessy sync inviting: {member}")
accessy_session.invite_phone_to_org_and_groups([member.phone], member.groups)
logger.info(f"accessy sync inviting: {accessy_member}")
accessy_session.invite_phone_to_org_and_groups([accessy_member.phone], accessy_member.groups)

for op in diff.group_adds:
logger.info(f"accessy sync adding to group: {op}")
accessy_session.add_to_group(op.member.phone, op.group)
accessy_session.add_to_group(op.member, op.group)

for op in diff.group_removes:
logger.info(f"accessy sync removing from group: {op}")
accessy_session.remove_from_group(op.member.phone, op.group)
accessy_session.remove_from_group(op.member, op.group)

for accessy_member in diff.org_removes:
logger.info(f"accessy sync removing from org: {accessy_member}")
accessy_session.remove_from_org(accessy_member)

for member in diff.org_removes:
logger.info(f"accessy sync removing from org: {member}")
accessy_session.remove_from_org(member.phone)
for accessy_member in members_to_discard:
logger.info(f"accessy sync removing from org: {accessy_member}, because phone number is missing")
accessy_session.remove_from_org(accessy_member)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Improve error handling for API operations

The sync function performs multiple API operations without proper error handling. Network operations should be wrapped in try-except blocks.

Consider adding error handling:

    for accessy_member in diff.invites:
        assert accessy_member.phone is not None
        if accessy_member.phone in pending_invites:
            logger.info(f"accessy sync skipping, invite already pending: {accessy_member}")
            continue
-       logger.info(f"accessy sync inviting: {accessy_member}")
-       accessy_session.invite_phone_to_org_and_groups([accessy_member.phone], accessy_member.groups)
+       try:
+           logger.info(f"accessy sync inviting: {accessy_member}")
+           accessy_session.invite_phone_to_org_and_groups([accessy_member.phone], accessy_member.groups)
+       except Exception as e:
+           logger.error(f"Failed to invite member {accessy_member}: {e}")

Similar error handling should be added for other API operations.

Committable suggestion skipped: line range outside the PR's diff.

Copy link
Member

@BerglundDaniel BerglundDaniel left a comment

Choose a reason for hiding this comment

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

Nice!

- Reduced the number of calls we make to the AccessyAPI during syncs.
- Re-use our existing code for getting membership summaries, instead of doing custom database calls.
- Use the syncing code for when updating a single member as well, to ensure there are no differences in behaviour.
@HalfVoxel HalfVoxel force-pushed the aron/improve_accessy branch from 4dee06a to b0c3110 Compare December 26, 2024 11:09
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
api/src/multiaccessy/sync.py (1)

23-49: Improve type safety in get_wanted_access function

The function handles optional member_id well, but could benefit from explicit type hints for the return dictionary values.

Consider this improvement:

-def get_wanted_access(today: date, member_id: Optional[int] = None) -> dict[PHONE, AccessyMember]:
+def get_wanted_access(today: date, member_id: Optional[int] = None) -> Dict[PHONE, AccessyMember]:

Also, consider adding error handling for the database query:

     if member_id is not None:
-        member = db_session.query(Member).get(member_id)
+        member = db_session.query(Member).get(member_id)
+        if member is None:
+            logger.error(f"Member {member_id} not found")
+            raise ValueError(f"Member {member_id} not found")
api/src/systest/api/accessy_sync_test.py (1)

98-107: Add test case for member-specific sync

While the tests cover the general sync cases well, we should add a test for the new member-specific sync functionality.

Consider adding this test case:

def test_get_wanted_access_single_member(self) -> None:
    # Create a member with spans
    member = self.db.create_member(
        phone=random_phone_number(),
        labaccess_agreement_at=date.today(),
    )
    self.db.create_span(
        startdate=self.date(0),
        enddate=self.date(0),
        type=Span.LABACCESS,
        member=member,
    )
    
    # Test member-specific sync
    wanted = get_wanted_access(self.date(), member_id=member.member_id)
    self.assertEqual(len(wanted), 1)
    self.assertIn(member.phone, wanted)
    
    # Test with non-existent member
    with self.assertRaises(ValueError):
        get_wanted_access(self.date(), member_id=99999)

Also applies to: 153-154

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4dee06a and b0c3110.

📒 Files selected for processing (5)
  • api/src/membership/membership.py (1 hunks)
  • api/src/multiaccessy/accessy.py (4 hunks)
  • api/src/multiaccessy/invite.py (2 hunks)
  • api/src/multiaccessy/sync.py (3 hunks)
  • api/src/systest/api/accessy_sync_test.py (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • api/src/membership/membership.py
  • api/src/multiaccessy/accessy.py
🔇 Additional comments (5)
api/src/multiaccessy/invite.py (2)

16-16: LGTM: Import aligns with architectural changes

The new import of the sync function supports the refactoring to improve code reuse.


66-66: Verify error propagation from sync function

The function now delegates to sync(member_id) after precondition checks. While this improves code reuse, we should ensure error handling is consistent.

Run this script to analyze error handling:

✅ Verification successful

Let me gather more information about the sync function and its error handling.


Let me try a different approach to find the sync function.


Let me check the full implementation of the sync function to understand its error handling.


Let me check the invite function to understand how it handles errors from sync.


Let me check the error handling in the base HTTP methods of the Accessy client.


Let me check the request function implementation to complete the error handling analysis.


Based on the analysis of the codebase, I can now provide the final response regarding error propagation.

Error handling is properly propagated through the call chain

The sync function and its dependencies have consistent error handling:

  • The sync function raises explicit exceptions for invalid member IDs: raise Exception("Member does not exist")
  • All HTTP operations are handled through the Accessy client which raises AccessyError for API failures
  • The underlying HTTP operations (__post, __put, __delete) properly propagate errors through the request function
  • Errors from the Accessy API client (like "too many requests", invalid API key, etc.) are properly raised as AccessyError

The error handling chain is complete from the sync function through to the HTTP layer, ensuring that any failures will be properly propagated to the caller.

🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Check error handling patterns in sync and invite functions
rg -A 5 "raise\s+\w+Error" api/src/multiaccessy/

Length of output: 2296


Script:

#!/bin/bash
# Check sync function implementation and error handling
ast-grep --pattern 'def sync($$$) { $$$ }'

Length of output: 44


Script:

#!/bin/bash
# Find sync function definition and its context
rg -B 2 -A 10 "^def sync" api/src/multiaccessy/

Length of output: 798


Script:

#!/bin/bash
# Get the full content of sync.py to analyze error handling
cat api/src/multiaccessy/sync.py

Length of output: 5865


Script:

#!/bin/bash
# Find the invite function implementation
rg -B 5 -A 10 "def invite" api/src/multiaccessy/

Length of output: 1331


Script:

#!/bin/bash
# Find the HTTP methods implementation in accessy.py
rg -B 2 -A 10 "def __(?:get|post|put|delete)" api/src/multiaccessy/accessy.py

Length of output: 1553


Script:

#!/bin/bash
# Find the request function implementation
rg -B 5 -A 15 "def request" api/src/multiaccessy/accessy.py

Length of output: 744

api/src/multiaccessy/sync.py (1)

126-132: LGTM: Improved handling of members without phone numbers

Good addition of handling for members with missing phone numbers, likely due to account resets.

api/src/systest/api/accessy_sync_test.py (2)

49-51: LGTM: Improved test structure with explicit member associations

The test setup is now more explicit with member associations in span creation.

Also applies to: 57-62, 68-79


135-140: LGTM: Robust phone number assertions

Good addition of explicit phone number assertions to catch potential null pointer issues early.

Also applies to: 145-148

Comment on lines +109 to +119
# If a specific member is given, sync only that member,
# otherwise sync all members
if member_id is not None:
member = db_session.query(Member).get(member_id)
if member is None:
raise Exception("Member does not exist")
if member.phone is None:
return
accessy_member = accessy_session._get_org_user_from_phone(member.phone)
actual_members = [accessy_member] if accessy_member is not None else []
else:
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Enhance error handling for member-specific sync

The member-specific sync path needs more robust error handling and logging.

Consider these improvements:

     if member_id is not None:
         member = db_session.query(Member).get(member_id)
         if member is None:
-            raise Exception("Member does not exist")
+            logger.error(f"Cannot sync non-existent member {member_id}")
+            raise ValueError(f"Member {member_id} does not exist")
         if member.phone is None:
+            logger.info(f"Skipping sync for member {member_id} without phone number")
             return
         accessy_member = accessy_session._get_org_user_from_phone(member.phone)
+        if accessy_member is None:
+            logger.info(f"Member {member_id} not found in Accessy")
         actual_members = [accessy_member] if accessy_member is not None else []
📝 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
# If a specific member is given, sync only that member,
# otherwise sync all members
if member_id is not None:
member = db_session.query(Member).get(member_id)
if member is None:
raise Exception("Member does not exist")
if member.phone is None:
return
accessy_member = accessy_session._get_org_user_from_phone(member.phone)
actual_members = [accessy_member] if accessy_member is not None else []
else:
# If a specific member is given, sync only that member,
# otherwise sync all members
if member_id is not None:
member = db_session.query(Member).get(member_id)
if member is None:
logger.error(f"Cannot sync non-existent member {member_id}")
raise ValueError(f"Member {member_id} does not exist")
if member.phone is None:
logger.info(f"Skipping sync for member {member_id} without phone number")
return
accessy_member = accessy_session._get_org_user_from_phone(member.phone)
if accessy_member is None:
logger.info(f"Member {member_id} not found in Accessy")
actual_members = [accessy_member] if accessy_member is not None else []
else:

Comment on lines +136 to +142
for accessy_member in diff.invites:
assert accessy_member.phone is not None
if accessy_member.phone in pending_invites:
logger.info(f"accessy sync skipping, invite already pending: {accessy_member}")
continue
logger.info(f"accessy sync inviting: {member}")
accessy_session.invite_phone_to_org_and_groups([member.phone], member.groups)
logger.info(f"accessy sync inviting: {accessy_member}")
accessy_session.invite_phone_to_org_and_groups([accessy_member.phone], accessy_member.groups)
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Add retry mechanism for API operations

The invite operation could fail due to network issues or API rate limits.

Consider adding a retry mechanism:

     for accessy_member in diff.invites:
         assert accessy_member.phone is not None
         if accessy_member.phone in pending_invites:
             logger.info(f"accessy sync skipping, invite already pending: {accessy_member}")
             continue
-        logger.info(f"accessy sync inviting: {accessy_member}")
-        accessy_session.invite_phone_to_org_and_groups([accessy_member.phone], accessy_member.groups)
+        max_retries = 3
+        for attempt in range(max_retries):
+            try:
+                logger.info(f"accessy sync inviting: {accessy_member} (attempt {attempt + 1}/{max_retries})")
+                accessy_session.invite_phone_to_org_and_groups([accessy_member.phone], accessy_member.groups)
+                break
+            except Exception as e:
+                if attempt == max_retries - 1:
+                    logger.error(f"Failed to invite {accessy_member} after {max_retries} attempts: {e}")
+                    raise
+                logger.warning(f"Failed to invite {accessy_member} (attempt {attempt + 1}/{max_retries}): {e}")
📝 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
for accessy_member in diff.invites:
assert accessy_member.phone is not None
if accessy_member.phone in pending_invites:
logger.info(f"accessy sync skipping, invite already pending: {accessy_member}")
continue
logger.info(f"accessy sync inviting: {member}")
accessy_session.invite_phone_to_org_and_groups([member.phone], member.groups)
logger.info(f"accessy sync inviting: {accessy_member}")
accessy_session.invite_phone_to_org_and_groups([accessy_member.phone], accessy_member.groups)
for accessy_member in diff.invites:
assert accessy_member.phone is not None
if accessy_member.phone in pending_invites:
logger.info(f"accessy sync skipping, invite already pending: {accessy_member}")
continue
max_retries = 3
for attempt in range(max_retries):
try:
logger.info(f"accessy sync inviting: {accessy_member} (attempt {attempt + 1}/{max_retries})")
accessy_session.invite_phone_to_org_and_groups([accessy_member.phone], accessy_member.groups)
break
except Exception as e:
if attempt == max_retries - 1:
logger.error(f"Failed to invite {accessy_member} after {max_retries} attempts: {e}")
raise
logger.warning(f"Failed to invite {accessy_member} (attempt {attempt + 1}/{max_retries}): {e}")

@HalfVoxel HalfVoxel merged commit af0ef8d into main Dec 26, 2024
11 checks passed
@HalfVoxel HalfVoxel deleted the aron/improve_accessy branch December 26, 2024 11:13
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.

2 participants