Skip to content

Commit f352ee0

Browse files
authored
[Comp-754] Fix table latency for staff_users and sync with My toggle (#653)
1 parent 4b0cd3e commit f352ee0

File tree

15 files changed

+1642
-1132
lines changed

15 files changed

+1642
-1132
lines changed

compliance-api/src/compliance_api/models/staff_user.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from typing import Optional
99

1010
from sqlalchemy import Boolean, Column, ForeignKey, Index, Integer, String
11-
from sqlalchemy.orm import relationship
11+
from sqlalchemy.orm import joinedload, relationship
1212

1313
from .base_model import BaseModelVersioned
1414
from .utils import with_session
@@ -52,10 +52,16 @@ class StaffUser(BaseModelVersioned):
5252
)
5353
position = relationship("Position", foreign_keys=[position_id], lazy="joined")
5454
deputy_director = relationship(
55-
"StaffUser", remote_side=[id], foreign_keys=[deputy_director_id]
55+
"StaffUser",
56+
remote_side=[id],
57+
foreign_keys=[deputy_director_id],
58+
lazy="selectin"
5659
)
5760
supervisor = relationship(
58-
"StaffUser", remote_side=[id], foreign_keys=[supervisor_id]
61+
"StaffUser",
62+
remote_side=[id],
63+
foreign_keys=[supervisor_id],
64+
lazy="selectin"
5965
)
6066
is_deleted = Column(Boolean, default=False, server_default="f", nullable=False)
6167
__table_args__ = (
@@ -107,3 +113,31 @@ def delete_staff_user(cls, staff_user_id, session=None):
107113
user.is_active = False
108114
session.flush()
109115
return user
116+
117+
@classmethod
118+
def get_all_with_relationships(cls, default_filters=True, sort_by=None):
119+
"""
120+
Fetch all staff users with eager loading of relationships.
121+
122+
This prevents DetachedInstanceError when accessing relationships
123+
after objects are cached or detached from the session.
124+
"""
125+
query = cls.query
126+
127+
# Apply filters
128+
if default_filters:
129+
query = query.filter_by(is_active=True)
130+
query = query.filter_by(is_deleted=False)
131+
132+
# Eager load all relationships to prevent lazy loading issues
133+
query = query.options(
134+
joinedload(cls.position),
135+
joinedload(cls.deputy_director),
136+
joinedload(cls.supervisor)
137+
)
138+
139+
# Apply sorting
140+
if sort_by and hasattr(cls, sort_by):
141+
query = query.order_by(getattr(cls, sort_by))
142+
143+
return query.all()

compliance-api/src/compliance_api/resources/staff_user.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from compliance_api.auth import auth
2121
from compliance_api.exceptions import ResourceNotFoundError
2222
from compliance_api.schemas import KeyValueSchema, StaffUserCreateSchema, StaffUserSchema, StaffUserUpdateSchema
23+
from compliance_api.services.cached_staff_user import CachedStaffUserService
2324
from compliance_api.services import StaffUserService
2425
from compliance_api.utils.enum import PermissionEnum
2526
from compliance_api.utils.util import cors_preflight
@@ -54,9 +55,8 @@ class StaffUsers(Resource):
5455
@auth.require
5556
def get():
5657
"""Fetch all users."""
57-
users = StaffUserService.get_all_staff_users()
58-
user_list_schema = StaffUserSchema(many=True)
59-
return user_list_schema.dump(users), HTTPStatus.OK
58+
users = CachedStaffUserService.get_all_staff_users_with_auth()
59+
return users, HTTPStatus.OK
6060

6161
@staticmethod
6262
@auth.require

compliance-api/src/compliance_api/services/authorize_service/auth_service.py

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,54 +4,115 @@
44
from flask import current_app, g
55

66
from compliance_api.exceptions import BusinessError
7+
from compliance_api.utils.cache import cache
78
from compliance_api.utils.constant import AUTH_APP
89
from compliance_api.utils.enum import HttpMethod
910

1011
from .constant import API_REQUEST_TIMEOUT
1112

1213

1314
class AuthService:
14-
"""Handle service request for epic.authorize."""
15+
"""Handle service request for epic.authorize with integrated cache management."""
1516

1617
@staticmethod
1718
def get_epic_user_by_guid(auth_user_guid: str):
18-
"""Return the user representation from epic.authorize."""
19+
"""
20+
Return the user representation from epic.authorize (cached).
21+
22+
This is called frequently for individual user lookups,
23+
so we cache it with the user's token hash for security.
24+
"""
25+
from compliance_api.services.cached_staff_user import CachedStaffUserService
26+
27+
# Include token hash in cache key for security
28+
token_hash = CachedStaffUserService._get_token_hash()
29+
cache_key = f"auth_user:{auth_user_guid}:{token_hash}"
30+
31+
cached_result = cache.get(cache_key)
32+
if cached_result is not None:
33+
current_app.logger.debug(f"Cache hit for auth user {auth_user_guid}")
34+
return cached_result
35+
36+
current_app.logger.debug(f"Cache miss for auth user {auth_user_guid}")
37+
1938
auth_user_response = _request_auth_service(f"users/{auth_user_guid}")
2039
if auth_user_response.status_code != 200:
2140
raise BusinessError(
2241
f"Error finding user with ID {auth_user_guid} from auth server"
2342
)
24-
return auth_user_response.json()
43+
44+
result = auth_user_response.json()
45+
cache.set(cache_key, result, timeout=180) # 3 minutes
46+
return result
2547

2648
@staticmethod
2749
def get_epic_users_by_app():
28-
"""Return the users belong to COMPLIANCE app in identity server."""
50+
"""
51+
Return all users belonging to COMPLIANCE app with caching.
52+
53+
This caches per-user to prevent data leakage.
54+
"""
55+
from compliance_api.services.cached_staff_user import CachedStaffUserService
56+
from compliance_api.utils.constant import AUTH_APP
57+
from compliance_api.exceptions import BusinessError
58+
59+
# Include token hash in cache key for security
60+
token_hash = CachedStaffUserService._get_token_hash()
61+
cache_key = f"auth_users_app:{AUTH_APP}:{token_hash}"
62+
63+
cached_result = cache.get(cache_key)
64+
if cached_result is not None:
65+
current_app.logger.debug("Cache hit for auth users by app")
66+
return cached_result
67+
68+
current_app.logger.debug("Cache miss for auth users by app")
69+
70+
# Fetch from auth service
2971
auth_users_response = _request_auth_service(f"users?app_name={AUTH_APP}")
3072
if auth_users_response.status_code != 200:
3173
raise BusinessError(f"Error fetching users for the app {AUTH_APP}")
32-
return auth_users_response.json()
74+
75+
result = auth_users_response.json()
76+
cache.set(cache_key, result, timeout=180) # 3 minutes
77+
return result
3378

3479
@staticmethod
3580
def update_user_group(auth_user_guid: str, payload: dict):
36-
"""Update the group of the user in the identity server."""
81+
"""Update user group and invalidate all related caches."""
82+
from compliance_api.services.cached_staff_user import CachedStaffUserService
83+
from compliance_api.utils.enum import HttpMethod
84+
from compliance_api.exceptions import BusinessError
85+
3786
update_group_response = _request_auth_service(
3887
f"users/{auth_user_guid}/groups", HttpMethod.PUT, payload
3988
)
4089
if update_group_response.status_code != 204:
4190
raise BusinessError(
4291
f"Update group in the auth server failed for user : {auth_user_guid}"
4392
)
93+
94+
# Invalidate ALL staff caches since permissions changed
95+
CachedStaffUserService.invalidate_staff_cache(auth_user_guid)
96+
4497
return update_group_response
4598

4699
@staticmethod
47100
def delete_user_group(auth_user_guid: str, group: str, del_sub_group_mappings=True):
48-
"""Delete the user-group mapping in identity server."""
101+
"""Delete user group and invalidate all related caches."""
102+
from compliance_api.services.cached_staff_user import CachedStaffUserService
103+
from compliance_api.utils.enum import HttpMethod
104+
from compliance_api.exceptions import BusinessError
105+
49106
delete_response = _request_auth_service(
50107
f"users/{auth_user_guid}/groups/{group}?del_sub_group_mappings={del_sub_group_mappings}",
51108
HttpMethod.DELETE,
52109
)
53110
if delete_response.status_code != 204:
54111
raise BusinessError("Delete group mapping failed")
112+
113+
# Invalidate ALL staff caches since permissions changed
114+
CachedStaffUserService.invalidate_staff_cache(auth_user_guid)
115+
55116
return delete_response
56117

57118

compliance-api/src/compliance_api/services/cached_staff_user.py

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
"""Cached service for staff user validation during token authentication."""
1+
"""Integrated caching strategy for staff users."""
22

3-
from flask import current_app
3+
import hashlib
4+
from flask import current_app, g
45

56
from compliance_api.models.staff_user import StaffUser as StaffUserModel
7+
from compliance_api.schemas.staff_user import StaffUserSchema
8+
from compliance_api.services.authorize_service.auth_service import AuthService
69
from compliance_api.utils.cache import cache
710

811

@@ -12,6 +15,7 @@ class CachedStaffUserService:
1215
CACHE_TIMEOUT = 3600 # 1 hour
1316
STAFF_CACHE_KEY_PREFIX = "staff_user:"
1417
ALL_STAFF_CACHE_KEY = "all_staff_users"
18+
ALL_STAFF_WITH_AUTH_PREFIX = "all_staff_with_auth:"
1519

1620
@classmethod
1721
def exists_staff_by_auth_guid(cls, auth_guid: str) -> bool:
@@ -47,21 +51,84 @@ def exists_staff_by_auth_guid(cls, auth_guid: str) -> bool:
4751

4852
return cache_value
4953

50-
@classmethod
51-
def invalidate_staff_cache(cls, auth_guid: str = None):
54+
@staticmethod
55+
def get_all_staff_users_with_auth():
56+
"""
57+
Get all staff users with merged auth data (cached as serialized data).
58+
59+
Returns:
60+
List of serialized staff user dictionaries
61+
"""
62+
# pylint: disable=import-outside-toplevel
63+
from compliance_api.services.staff_user import _set_permission_level_in_compliance_user_obj
64+
65+
# Create cache key that includes token hash for security
66+
token_hash = CachedStaffUserService._get_token_hash()
67+
68+
version = cache.get("all_staff_with_auth_version")
69+
if version is None:
70+
version = 0
71+
72+
cache_key = (
73+
f"{CachedStaffUserService.ALL_STAFF_WITH_AUTH_PREFIX}"
74+
f"{token_hash}:v{version}"
75+
)
76+
77+
cached_result = cache.get(cache_key)
78+
if cached_result is not None:
79+
current_app.logger.debug("Cache hit for all staff users with auth data")
80+
return cached_result
81+
82+
current_app.logger.debug("Cache miss for all staff users with auth data")
83+
84+
# Get users from compliance database with eager loading
85+
users = StaffUserModel.get_all_with_relationships(default_filters=False)
86+
87+
# Get compliance users from epic system
88+
auth_users = AuthService.get_epic_users_by_app()
89+
90+
# Merge the two sets of users to set the permission in the result
91+
index_auth_users = {user["username"]: user for user in auth_users}
92+
for user in users:
93+
auth_user = index_auth_users.get(user.auth_user_guid, None)
94+
user = _set_permission_level_in_compliance_user_obj(user, auth_user)
95+
96+
# Serialize the data BEFORE caching to avoid detached instance issues
97+
user_schema = StaffUserSchema(many=True)
98+
serialized_users = user_schema.dump(users)
99+
100+
# Cache the serialized data
101+
cache.set(cache_key, serialized_users, timeout=300) # 5 minutes
102+
103+
return serialized_users
104+
105+
@staticmethod
106+
def invalidate_staff_cache(auth_guid: str = None):
52107
"""
53108
Invalidate cached staff user data.
54109
55110
Args:
56111
auth_guid: Specific auth_guid to invalidate, or None to clear all staff cache
57112
"""
58113
if auth_guid:
59-
cache_key = f"{cls.STAFF_CACHE_KEY_PREFIX}{auth_guid}"
114+
cache_key = f"{CachedStaffUserService.STAFF_CACHE_KEY_PREFIX}{auth_guid}"
60115
cache.delete(cache_key)
61116
current_app.logger.info(f"Invalidated cache for staff user: {auth_guid}")
62-
else:
63-
cls._clear_all_staff_cache()
64-
current_app.logger.info("Invalidated all staff user cache")
117+
118+
# Always invalidate the aggregate cache when any staff user changes
119+
CachedStaffUserService._invalidate_all_staff_with_auth_cache()
120+
121+
@staticmethod
122+
def _invalidate_all_staff_with_auth_cache():
123+
try:
124+
current_version = cache.get("all_staff_with_auth_version")
125+
if current_version is None:
126+
current_version = 0
127+
128+
cache.set("all_staff_with_auth_version", current_version + 1)
129+
current_app.logger.info("Bumped staff+auth cache version")
130+
except (AttributeError, RuntimeError) as e:
131+
current_app.logger.error(f"Error invalidating staff+auth cache: {e}")
65132

66133
@classmethod
67134
def _clear_all_staff_cache(cls):
@@ -71,3 +138,19 @@ def _clear_all_staff_cache(cls):
71138
current_app.logger.info("Cleared all cache (simple cache type)")
72139
except (AttributeError, RuntimeError) as e:
73140
current_app.logger.error(f"Error clearing cache: {str(e)}")
141+
142+
@staticmethod
143+
def _get_token_hash():
144+
"""
145+
Generate a cache key suffix based on the current user's token.
146+
147+
This ensures different users get different cached data while
148+
not exposing the actual token in cache keys.
149+
"""
150+
token = getattr(g, "access_token", None)
151+
if not token:
152+
return "no_token"
153+
154+
# Use first 16 chars of SHA256 hash
155+
token_hash = hashlib.sha256(token.encode()).hexdigest()[:16]
156+
return token_hash

0 commit comments

Comments
 (0)