@@ -66,58 +66,27 @@ def __init__(self, *args, **kwargs):
6666 @classmethod
6767 def from_db (cls , db , field_names , values ):
6868 """
69- Override from_db to capture original values when loading from database .
69+ Override from_db to store raw DB values for lazy capture .
7070
7171 Django calls this method instead of __init__ when loading models from the database.
72- We need to capture the original field values here for change detection.
72+
73+ PERFORMANCE: We store the raw field values here without processing them.
74+ This avoids accessing any ForeignKey fields (which would trigger queries).
75+ We only process these values into _original_values in save() when we actually
76+ need them for change detection.
7377 """
7478 instance = super ().from_db (db , field_names , values )
7579 # Initialize as empty dict for safe access
76- instance ._original_values = {}
77- # Capture original values immediately after loading from DB
78- # This ensures we have the baseline for change detection on the first save
79- instance ._capture_original_values ()
80- return instance
81-
82- def _capture_original_values (self ):
83- """
84- Capture current field values for change detection.
85-
86- This allows us to detect which fields changed during save operations,
87- which is crucial for determining appropriate FSM transitions.
88-
89- For ForeignKey fields, we store the PK instead of the object to avoid
90- circular references and recursion issues.
91-
92- Deferred fields (not yet loaded from DB) are skipped to prevent infinite
93- recursion when accessing them would trigger refresh_from_db().
94-
95- This is called after each save to refresh the baseline for the next save.
96- """
97- self ._original_values = {}
98-
99- # Get deferred fields to avoid triggering recursive database loads
100- # Deferred fields haven't been loaded yet, so they can't have changed
101- deferred_fields = self .get_deferred_fields ()
102-
103- for field in self ._meta .fields :
104- # Skip deferred fields to prevent recursion via refresh_from_db()
105- if field .attname in deferred_fields :
106- continue
80+ instance ._original_values = dict (zip (field_names , values ))
10781
108- value = getattr (self , field .name , None )
109- # For ForeignKey fields, store PK to avoid circular references
110- if field .is_relation and field .many_to_one and value is not None :
111- self ._original_values [field .name ] = value .pk if hasattr (value , 'pk' ) else value
112- else :
113- self ._original_values [field .name ] = value
82+ return instance
11483
11584 def __reduce_ex__ (self , protocol ):
11685 """
11786 Override serialization to exclude internal FSM tracking fields.
11887
11988 Django's serialization uses pickle which calls __reduce_ex__.
120- We exclude _original_values since it's only needed for runtime
89+ We exclude FSM tracking fields since they're only needed for runtime
12190 change detection, not for serialization/restoration.
12291 """
12392 # Get the default reduction
@@ -160,19 +129,16 @@ def _get_changed_fields(self) -> Dict[str, tuple]:
160129 # Only check fields that were captured in _original_values
161130 # Fields that were deferred during capture won't be in _original_values
162131 # and should be considered unchanged
163- if field .name not in self ._original_values :
132+ if field .attname not in self ._original_values :
133+ continue
134+ if field .is_relation and field .many_to_many :
164135 continue
165136
166- old_value = self ._original_values [field .name ]
167- new_value = getattr (self , field .name , None )
137+ old_value = self ._original_values [field .attname ]
138+ new_value = getattr (self , field .attname , None )
168139
169- # For ForeignKey fields, old_value is stored as PK, so compare PK to PK
170- if field .is_relation and field .many_to_one :
171- new_pk = new_value .pk if new_value and hasattr (new_value , 'pk' ) else new_value
172- if old_value != new_pk :
173- changed [field .name ] = (old_value , new_value )
174- elif old_value != new_value :
175- changed [field .name ] = (old_value , new_value )
140+ if old_value != new_value :
141+ changed [field .attname ] = (old_value , new_value )
176142 return changed
177143
178144 def _determine_fsm_transitions (self , is_creating : bool = None , changed_fields : dict = None ) -> list :
@@ -353,56 +319,26 @@ def _should_execute_fsm(self) -> bool:
353319 Check if FSM processing should be executed.
354320
355321 Returns False if:
356- - Feature flag is disabled
357- - User context is unavailable ( tests must set CurrentContext explicitly )
322+ - Feature flag is disabled (cached at request level)
323+ - Manually disabled via set_fsm_disabled() (for tests/bulk operations )
358324 - Explicitly skipped via instance attribute
359325
360326 Returns:
361327 True if FSM should execute, False otherwise
362328
363- Note:
364- CurrentContext is available in web requests and background jobs.
365- In tests, it must be set explicitly for the user/organization .
329+ PERFORMANCE: Uses cached FSM enabled state from CurrentContext that was set
330+ once per request when user was initialized. This is a simple boolean check
331+ instead of repeated feature flag lookups and user authentication checks .
366332 """
367333 # Check for instance-level skip flag
368334 if getattr (self , '_skip_fsm' , False ):
369335 return False
370336
371- # Use the centralized FSM enabled check from utils
372- # This handles feature flag and thread-local overrides
373- try :
374- from core .current_request import CurrentContext
375- from fsm .utils import is_fsm_enabled
337+ # Fast path: Check cached FSM enabled state
338+ # This was set once per request in CurrentContext.set_user()
339+ from core .current_request import CurrentContext
376340
377- # Get user from CurrentContext - don't fall back to AnonymousUser
378- # If no user in context (e.g., tests without explicit setup), return False
379- try :
380- user = CurrentContext .get_user ()
381- user_type = type (user ).__name__ if user else None
382- user_authenticated = getattr (user , 'is_authenticated' , None ) if user else None
383- logger .info (
384- f'FSM check for { self .__class__ .__name__ } (id={ getattr (self , "pk" , None )} ): '
385- f'user_type={ user_type } , authenticated={ user_authenticated } '
386- )
387- if user is None :
388- logger .info (f'FSM check: User is None, skipping FSM for { self .__class__ .__name__ } ' )
389- return False
390- # Check if user is authenticated (not AnonymousUser)
391- if not user .is_authenticated :
392- logger .info (
393- f'FSM check: User { user_type } not authenticated, skipping FSM for { self .__class__ .__name__ } '
394- )
395- return False
396- except Exception :
397- # CurrentContext not available or no user set
398- # This is expected in tests that don't set up context
399- logger .info (f'FSM check: Exception getting user, skipping FSM for { self .__class__ .__name__ } ' )
400- return False
401-
402- return is_fsm_enabled (user = user )
403- except Exception as e :
404- logger .debug (f'FSM check failed: { e } ' )
405- return False
341+ return CurrentContext .is_fsm_enabled ()
406342
407343 def save (self , * args , ** kwargs ):
408344 """
@@ -423,25 +359,29 @@ def save(self, *args, **kwargs):
423359 Returns:
424360 Whatever super().save() returns
425361 """
426- # Check for explicit FSM skip flag
427- skip_fsm = kwargs .pop ('skip_fsm' , False )
428-
429- # Also check CurrentContext for skip_fsm flag (for context manager usage)
430- if not skip_fsm :
431- from core .current_request import CurrentContext
362+ from core .current_request import CurrentContext
432363
433- skip_fsm = CurrentContext .get ('skip_fsm' , False )
364+ # Check for explicit FSM skip flag
365+ skip_fsm = kwargs .pop ('skip_fsm' , CurrentContext .is_fsm_disabled ())
434366
435367 # Check if this is a creation vs update
436368 is_creating = self ._state .adding
437369
438370 # Capture changed fields before save (only for updates)
439- # Note: _original_values should already be populated by from_db() or previous save()
440371 changed_fields = {} if is_creating else self ._get_changed_fields ()
441372
442373 # Perform the actual save
443374 result = super ().save (* args , ** kwargs )
444375
376+ # After successful save, update _original_values to current values
377+ # This ensures subsequent saves can detect changes correctly
378+ # Store attname values (raw PK for ForeignKey fields) to match from_db() format
379+ self ._original_values = {}
380+ for field in self ._meta .fields :
381+ if field .is_relation and field .many_to_many :
382+ continue
383+ self ._original_values [field .attname ] = getattr (self , field .attname , None )
384+
445385 # After successful save, trigger FSM transitions if enabled and not skipped
446386 should_execute = not skip_fsm and self ._should_execute_fsm ()
447387
@@ -500,9 +440,6 @@ def save(self, *args, **kwargs):
500440 exc_info = True ,
501441 )
502442
503- # Update original values after save for next time
504- self ._capture_original_values ()
505-
506443 return result
507444
508445 def _execute_fsm_transition (self , transition_name : str , is_creating : bool , changed_fields : Dict [str , tuple ]):
0 commit comments