# myapp/activity/activity_routes.py import datetime import logging from flask import request, jsonify, current_app, has_app_context # Flask utilities from bson.objectid import ObjectId, InvalidId # For MongoDB ObjectIds from functools import wraps # Import wraps for dummy decorator # --- Local Blueprint Import --- from . import bp # Import the 'bp' instance defined in the local __init__.py # --- Shared Extensions and Utilities Imports --- try: from ..extensions import mongo # Import the initialized PyMongo instance from ..utils import token_required # Import the authentication decorator except ImportError: # Fallback or error handling if imports fail print("Warning: Could not import mongo or token_required in activity/activity_routes.py.") mongo = None # Define a dummy decorator if token_required is missing def token_required(f): @wraps(f) # Use wraps for better introspection def wrapper(*args, **kwargs): print("ERROR: token_required decorator is not available!") return jsonify({"message": "Server configuration error: Missing authentication utility."}), 500 return wrapper # --- Schema Imports --- try: # Import the relevant schemas defined in schemas.py from ..schemas import ActivityCreateSchema, ActivitySchema from marshmallow import ValidationError except ImportError: print("Warning: Could not import Activity schemas or ValidationError in activity/activity_routes.py.") ActivityCreateSchema = None ActivitySchema = None ValidationError = None # Define ValidationError as None if import fails # --- Helper to get logger safely --- def _get_logger(): if has_app_context(): return current_app.logger return logging.getLogger(__name__) # Note: Routes use paths relative to the '/api/activity' prefix defined in __init__.py. @bp.route('/', methods=['POST']) # Path relative to blueprint prefix @token_required def create_activity(current_user): """ Create a new project activity log entry. Uses ActivityCreateSchema for input validation. Expects 'projectId', 'activityType', and optional 'message' in JSON payload. Verifies user has access to the project. """ logger = _get_logger() # Validate user object from token if not current_user or not current_user.get("_id"): logger.error("Invalid current_user object received in create_activity") return jsonify({"message": "Internal authorization error."}), 500 try: user_id = ObjectId(current_user["_id"]) except (InvalidId, TypeError) as e: logger.error(f"User ID conversion error in create_activity: {e}") return jsonify({"message": "Invalid user ID format in token."}), 400 # Check dependencies if not mongo: return jsonify({"message": "Database connection not available."}), 500 if not ActivityCreateSchema or not ValidationError: return jsonify({"message": "Server configuration error: Schema unavailable."}), 500 # Get and validate JSON data using the schema json_data = request.get_json() or {} schema = ActivityCreateSchema() try: validated_data = schema.load(json_data) except ValidationError as err: logger.warning(f"Create activity validation failed: {err.messages}") # Return validation errors from Marshmallow return jsonify(err.messages), 422 # 422 Unprocessable Entity is appropriate # Extract validated data project_id_str = validated_data['projectId'] # Already validated as ObjectId string by schema if validator is used activity_type = validated_data['activityType'] message = validated_data.get('message', "") # Get optional message try: # Convert projectId string to ObjectId (schema validator should ensure format) try: project_obj_id = ObjectId(project_id_str) except InvalidId: # This should ideally be caught by schema validation if using _validate_object_id logger.error(f"Schema validation passed but ObjectId conversion failed for: {project_id_str}") return jsonify({"message": "Invalid projectId format despite schema validation."}), 400 # --- Verify Project Access --- db = mongo.db project = db.projects.find_one({"_id": project_obj_id}, {"ownerId": 1, "collaborators": 1}) if not project: return jsonify({"message": "Project not found."}), 404 # 404 Not Found owner_id = project.get("ownerId") collaborators = project.get("collaborators", []) if owner_id != user_id and user_id not in collaborators: # 403 Forbidden - authenticated but not authorized for this project return jsonify({"message": "You do not have access to this project."}), 403 # --- Prepare and Insert Activity Log --- now = datetime.datetime.now(datetime.timezone.utc) # Use timezone-aware UTC time doc = { "projectId": project_obj_id, "userId": user_id, # Store the user who performed the activity "activityType": activity_type, "message": message, "createdAt": now # No updatedAt for activity logs usually } result = db.project_activity.insert_one(doc) # Return success response with the ID of the new log entry return jsonify({ "message": "Activity log created successfully.", "activity_id": str(result.inserted_id) # Convert ObjectId to string }), 201 # 201 Created status code except KeyError: # Should be caught by token_required or initial check logger.error(f"User ID (_id) not found in token payload for create_activity.") return jsonify({"message": "Authentication token is invalid or missing user ID."}), 401 except AttributeError: logger.error("PyMongo extension not initialized or attached correctly.") return jsonify({"message": "Database configuration error."}), 500 except Exception as e: logger.error(f"Error creating activity for user {current_user.get('_id', 'UNKNOWN')}: {e}", exc_info=True) return jsonify({"message": "An error occurred while creating the activity log."}), 500 @bp.route('/', methods=['GET']) # Path relative to blueprint prefix @token_required def list_activity_logs(current_user): """ List activity logs for a specific project. Uses ActivitySchema for output serialization. Requires 'projectId' as a query parameter. Supports 'limit' and 'offset' for pagination. Verifies user has access to the project. """ logger = _get_logger() # Validate user object from token if not current_user or not current_user.get("_id"): return jsonify({"message": "Internal authorization error."}), 500 try: user_id = ObjectId(current_user["_id"]) except (InvalidId, TypeError) as e: logger.error(f"User ID conversion error in list_activity_logs: {e}") return jsonify({"message": "Invalid user ID format in token."}), 400 # Check dependencies if not mongo: return jsonify({"message": "Database connection not available."}), 500 if not ActivitySchema: return jsonify({"message": "Server configuration error: Schema unavailable."}), 500 try: # Get query parameters project_id_str = request.args.get("projectId", "") limit_str = request.args.get("limit", "20") # Default limit 20 offset_str = request.args.get("offset", "0") # Default offset 0 # Validate and parse pagination parameters try: limit = max(int(limit_str), 1) # Ensure limit is at least 1 except ValueError: limit = 20 # Default on parsing error try: offset = max(int(offset_str), 0) # Ensure offset is non-negative except ValueError: offset = 0 # Default on parsing error # Project ID is required for listing logs if not project_id_str: return jsonify({"message": "Query parameter 'projectId' is required to list logs."}), 400 # Convert projectId string to ObjectId try: project_obj_id = ObjectId(project_id_str) except InvalidId: return jsonify({"message": "Invalid projectId format in query parameter."}), 400 # --- Verify Project Access --- db = mongo.db project = db.projects.find_one({"_id": project_obj_id}, {"ownerId": 1, "collaborators": 1}) if not project: return jsonify({"message": "Project not found."}), 404 owner_id = project.get("ownerId") collaborators = project.get("collaborators", []) if owner_id != user_id and user_id not in collaborators: return jsonify({"message": "You do not have access to this project's activity logs."}), 403 # --- Fetch Activity Logs --- cursor = db.project_activity.find( {"projectId": project_obj_id} ).sort("createdAt", -1).skip(offset).limit(limit) # Sort newest first # Convert cursor to list for serialization activity_docs = list(cursor) # --- Serialize results using the schema --- # Instantiate schema for multiple documents output_schema = ActivitySchema(many=True) # Use dump() to serialize the list of documents # Schema handles ObjectId and datetime conversion serialized_result = output_schema.dump(activity_docs) # Return the serialized list of activity logs return jsonify({"activity_logs": serialized_result}), 200 except KeyError: # Should be caught by token_required or initial check logger.error(f"User ID (_id) not found in token payload for list_activity_logs.") return jsonify({"message": "Authentication token is invalid or missing user ID."}), 401 except AttributeError: logger.error("PyMongo extension not initialized or attached correctly.") return jsonify({"message": "Database configuration error."}), 500 except Exception as e: logger.error(f"Error listing activity logs for user {current_user.get('_id', 'UNKNOWN')}: {e}", exc_info=True) return jsonify({"message": "An error occurred while listing activity logs."}), 500 @bp.route('/', methods=['DELETE']) # Path relative to blueprint prefix @token_required def delete_activity_log(current_user, activity_id): """ Delete a specific activity log entry by its ID. Requires the authenticated user to be the owner of the associated project. (No schema needed for input/output here) """ logger = _get_logger() # Validate user object from token if not current_user or not current_user.get("_id"): return jsonify({"message": "Internal authorization error."}), 500 try: user_id = ObjectId(current_user["_id"]) except (InvalidId, TypeError) as e: logger.error(f"User ID conversion error in delete_activity_log: {e}") return jsonify({"message": "Invalid user ID format in token."}), 400 if not mongo: return jsonify({"message": "Database connection not available."}), 500 try: # Validate activity_id format try: obj_activity_id = ObjectId(activity_id) except InvalidId: return jsonify({"message": "Invalid activity log ID format."}), 400 db = mongo.db # --- Find Log and Verify Ownership via Project --- # Fetch projectId to check ownership activity_doc = db.project_activity.find_one({"_id": obj_activity_id}, {"projectId": 1}) if not activity_doc: return jsonify({"message": "Activity log not found."}), 404 project_id = activity_doc.get("projectId") if not project_id or not isinstance(project_id, ObjectId): logger.error(f"Activity log {activity_id} is missing valid projectId.") return jsonify({"message": "Cannot verify ownership due to missing project reference."}), 500 project = db.projects.find_one({"_id": project_id}, {"ownerId": 1}) if not project: logger.warning(f"Project {project_id} associated with activity log {activity_id} not found.") # Even if project is gone, maybe allow deleting orphan log? Or deny? Deny for safety. return jsonify({"message": "Associated project not found."}), 404 # Verify ownership (only project owner can delete logs in this implementation) owner_id = project.get("ownerId") if owner_id != user_id: return jsonify({"message": "You do not have permission to delete this activity log (must be project owner)."}), 403 # --- Perform Deletion --- result = db.project_activity.delete_one({"_id": obj_activity_id}) # --- Return Response --- if result.deleted_count == 1: return jsonify({"message": "Activity log deleted successfully."}), 200 else: # Log was found but delete failed logger.warning(f"Activity log {activity_id} found but delete_one removed 0 documents.") return jsonify({"message": "Failed to delete activity log (already deleted?)."}), 404 except KeyError: # Should be caught by token_required or initial check logger.error(f"User ID (_id) not found in token payload for delete_activity_log.") return jsonify({"message": "Authentication token is invalid or missing user ID."}), 401 except AttributeError: logger.error("PyMongo extension not initialized or attached correctly.") return jsonify({"message": "Database configuration error."}), 500 except Exception as e: logger.error(f"Error deleting activity log {activity_id} for user {current_user.get('_id', 'UNKNOWN')}: {e}", exc_info=True) return jsonify({"message": "An error occurred while deleting the activity log."}), 500