SurfSmart/backend_flask/myapp/activity/activity_routes.py
2025-06-09 17:53:19 +08:00

298 lines
14 KiB
Python

# 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('/<string:activity_id>', 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