fix(fsm): Fix StateLog.by capture and cycle validation; add photographer field to photos

## FSM State Machine Fixes

### StateLog.by Field Capture
- Modified TransitionMethodFactory to pass 'user' as 'by' kwarg to enable
  django-fsm-log's @fsm_log_by decorator to correctly capture the user who
  performed the transition
- Applied fix to both escalate_transition and create_transition_method
- Uses exec() to dynamically create transition functions with correct __name__
  before decorators are applied, ensuring django-fsm's method registration works

### Cycle Validation Behavior
- Changed validate_no_cycles() to return ValidationWarning instead of ValidationError
- Cycles are now treated as warnings, not blocking errors, since cycles are often
  intentional in operational status FSMs (e.g., reopening after temporary closure)

### Ride Status Transitions
- Added TEMPORARY_CLOSURE -> OPERATING transition (reopen after temporary closure)
- Added SBNO -> OPERATING transition (revival - ride returns to operation)

## Field Parity

### Photo Models
- Added 'photographer' field to RidePhoto and ParkPhoto models
- Maps to frontend 'photographer_credit' field for full schema parity
- Includes corresponding migrations for both apps

### Serializers
- Added 'photographer' to RidePhotoSerializer and ParkPhotoSerializer read_only_fields
This commit is contained in:
pacnpal
2026-01-09 08:04:44 -05:00
parent fe960e8b62
commit cf54df0416
9 changed files with 157 additions and 32 deletions

View File

@@ -53,6 +53,11 @@ def with_callbacks(
def wrapper(instance, *args, **kwargs):
# Extract user from kwargs
user = kwargs.get("user")
# Pass user as 'by' for django-fsm-log's @fsm_log_by decorator
# This must be set before calling the inner func so the decorator can capture it
if user is not None and 'by' not in kwargs:
kwargs['by'] = user
# Get source state before transition
source_state = getattr(instance, field_name, None)
@@ -329,6 +334,9 @@ class TransitionMethodFactory:
)
def approve(instance, user=None, comment: str = "", **kwargs):
"""Approve and transition to approved state."""
# Pass user as 'by' for django-fsm-log's @fsm_log_by decorator
if user is not None:
kwargs['by'] = user
if hasattr(instance, "approved_by_id"):
instance.approved_by = user
if hasattr(instance, "approval_comment"):
@@ -382,6 +390,9 @@ class TransitionMethodFactory:
)
def reject(instance, user=None, reason: str = "", **kwargs):
"""Reject and transition to rejected state."""
# Pass user as 'by' for django-fsm-log's @fsm_log_by decorator
if user is not None:
kwargs['by'] = user
if hasattr(instance, "rejected_by_id"):
instance.rejected_by = user
if hasattr(instance, "rejection_reason"):
@@ -435,6 +446,9 @@ class TransitionMethodFactory:
)
def escalate(instance, user=None, reason: str = "", **kwargs):
"""Escalate to higher authority."""
# Pass user as 'by' for django-fsm-log's @fsm_log_by decorator
if user is not None:
kwargs['by'] = user
if hasattr(instance, "escalated_by_id"):
instance.escalated_by = user
if hasattr(instance, "escalation_reason"):
@@ -483,31 +497,45 @@ class TransitionMethodFactory:
# Get field name for callback wrapper
field_name = field.name if hasattr(field, 'name') else 'status'
@fsm_log_by
@transition(
# Create the transition function with the correct name from the start
# by using exec to define it dynamically. This ensures __name__ is correct
# before decorators are applied, which is critical for django-fsm's
# method registration.
doc = docstring if docstring else f"Transition from {source} to {target}"
# Define the function dynamically with the correct name
# IMPORTANT: We set kwargs['by'] = user so that @fsm_log_by can capture
# who performed the transition. The decorator looks for 'by' in kwargs.
func_code = f'''
def {method_name}(instance, user=None, **kwargs):
"""{doc}"""
# Pass user as 'by' for django-fsm-log's @fsm_log_by decorator
if user is not None:
kwargs['by'] = user
pass
'''
local_namespace: dict = {}
exec(func_code, {}, local_namespace)
inner_func = local_namespace[method_name]
# Apply decorators in correct order (innermost first)
# @fsm_log_by -> @transition -> inner_func
decorated = transition(
field=field,
source=source,
target=target,
permission=permission_guard,
)
def generic_transition(instance, user=None, **kwargs):
"""Execute state transition."""
pass
generic_transition.__name__ = method_name
if docstring:
generic_transition.__doc__ = docstring
else:
generic_transition.__doc__ = f"Transition from {source} to {target}"
)(inner_func)
decorated = fsm_log_by(decorated)
# Apply callback wrapper if enabled
if enable_callbacks:
generic_transition = with_callbacks(
decorated = with_callbacks(
field_name=field_name,
emit_signals=emit_signals,
)(generic_transition)
)(decorated)
return generic_transition
return decorated
def with_transition_logging(transition_method: Callable) -> Callable:

View File

@@ -83,7 +83,7 @@ class MetadataValidator:
result.errors.extend(self.validate_transitions())
result.errors.extend(self.validate_terminal_states())
result.errors.extend(self.validate_permission_consistency())
result.errors.extend(self.validate_no_cycles())
result.warnings.extend(self.validate_no_cycles()) # Cycles are warnings, not errors
result.errors.extend(self.validate_reachability())
# Set validity based on errors
@@ -197,23 +197,20 @@ class MetadataValidator:
return errors
def validate_no_cycles(self) -> list[ValidationError]:
def validate_no_cycles(self) -> list[ValidationWarning]:
"""
Detect invalid state cycles (excluding self-loops).
Detect state cycles (excluding self-loops).
Note: Cycles are allowed in many FSMs (e.g., status transitions that allow
reopening or revival). This method returns warnings, not errors, since
cycles are often intentional in operational status FSMs.
Returns:
List of validation errors
List of validation warnings
"""
errors = []
warnings = []
graph = self.builder.build_transition_graph()
# Check for self-loops (state transitioning to itself)
for state, targets in graph.items():
if state in targets:
# Self-loops are warnings, not errors
# but we can flag them
pass
# Detect cycles using DFS
visited: set[str] = set()
rec_stack: set[str] = set()
@@ -240,16 +237,16 @@ class MetadataValidator:
if state not in visited:
cycle = has_cycle(state, [])
if cycle:
errors.append(
ValidationError(
code="STATE_CYCLE_DETECTED",
message=(f"Cycle detected: {' -> '.join(cycle)}"),
warnings.append(
ValidationWarning(
code="STATE_CYCLE_EXISTS",
message=(f"Cycle exists (may be intentional): {' -> '.join(cycle)}"),
state=cycle[0],
)
)
break # Report first cycle only
return errors
return warnings
def validate_reachability(self) -> list[ValidationError]:
"""