diff --git a/backend/blueprints/chat/__init__.py b/backend/blueprints/chat/__init__.py index d9ea1b7..864dd4d 100644 --- a/backend/blueprints/chat/__init__.py +++ b/backend/blueprints/chat/__init__.py @@ -1,122 +1,49 @@ +import uuid + from flask import Blueprint, request, jsonify,g from uuid import UUID -from db.model import db, User, Course, Enrollment,Chat +from ...db.model import db, User, Course, Enrollment,Chat from utils.auth import auth_required import requests -from sqlalchemy import desc +from config import SPAM_SCORE_THRESHOLD, AI_SPAM_SERVICES_MICROSERVICE +from sqlalchemy import desc, select, and_ chat = Blueprint('chat', __name__) -SPAM_DETECTION_URL = "http://localhost:5000/test-spam" - -@chat.route('/course//users', methods=['POST']) -def get_users_assigned_to_course(course_id: UUID): - """ - Fetch all users assigned to a specific course. - :param course_id: ID of the course to fetch users for (UUID). - :return: JSON response with users assigned to the course. - """ - try: - # Query the course to ensure it exists - course = Course.query.get(course_id) - if not course: - return jsonify({"error": "Course not found."}), 404 - - # Get the list of users assigned to the course - users = User.query.filter(User.enrollments.any(course_id=course_id)).all() - - # Prepare the response data - user_data = [ - { - "id": user.id, - "email": user.email, - "username": user.username, - "firstName": user.firstName, - "lastName": user.lastName, - } - for user in users - ] - - return jsonify({"course": course.name, "users": user_data}), 200 - except Exception as e: - return jsonify({"error": f"An error occurred: {str(e)}"}), 500 - - -@chat.route('/course//chat', methods=['POST']) -def join_chat(course_id: UUID): - """ - Allow users to join the chat only if they are enrolled in the course. - :param course_id: ID of the course (UUID). - :return: JSON response indicating whether the user can join or not. - """ - try: - # Get the user from the request (assume user_id is passed in the request body) - user_id = request.json.get('user_id') - if not user_id: - return jsonify({"error": "User ID is required."}), 400 - - # Query the user and ensure they exist - user = User.query.get(user_id) - if not user: - return jsonify({"error": "User not found."}), 404 - - # Check if the user is enrolled in the course - enrollment = Enrollment.query.filter_by(userID=user_id, courseID=course_id).first() - if not enrollment: - return jsonify({"error": "User is not enrolled in this course."}), 403 - - # If enrolled, allow the user to join the chat - return jsonify({"message": f"User {user.username} is enrolled in the course and can join the chat."}), 200 - except Exception as e: - return jsonify({"error": f"An error occurred: {str(e)}"}), 500 - - -@chat.route("/send-message", methods=["POST"]) +@chat.route("/send", methods=["POST"]) @auth_required() def create_chat(): - """ - Create a chat message for a specific course. - """ - current_user = g.current_user # Fetch the logged-in user - data = request.get_json() - - # Validate the payload - course_id = data.get("course_id") - message = data.get("message") - - if not course_id or not message: - return jsonify({"error": "Course ID and message are required."}), 400 - + current_user: User = g.current_user # Fetch the logged-in user + data = request.args + if not data.get("course_id"): + return jsonify({"error": "Unknown Course"}), 400 + if not data.get("message", "").strip(): + return jsonify({'message': 'Silently rejected blank message'}), 200 try: - # Call the spam detection service - spam_response = requests.post(SPAM_DETECTION_URL, json={"test_message": message}) + # Check if the user is enrolled in the course + enrollment_record: Enrollment = db.session.execute( + select(Enrollment).where(and_( + Enrollment.courseID == data.get("course_id"), + Enrollment.userID == current_user.id) + ) + ) + if not enrollment_record: + return jsonify({"error": "You are not enrolled in this course."}), 403 + if enrollment_record.currentPage < enrollment_record.course.pageForCommunity: + return jsonify({ + 'message': 'You have not met the requirements to enter the chat.' + }), 403 + # Create and save the chat message + spam_response = requests.post(AI_SPAM_SERVICES_MICROSERVICE, json={"test_message": data.get("message").strip()}) if spam_response.status_code != 200: return jsonify({"error": "Failed to check message for spam."}), 500 - spam_score = int(spam_response.json().get("spam_score", 0)) - - if spam_score > 6: + if spam_score > SPAM_SCORE_THRESHOLD: return jsonify({"error": "This message contains suspicious links or is vulnerable."}), 400 - - # Verify the course exists - course = db.session.query(Course).filter_by(id=course_id).first() - if not course: - return jsonify({"error": "Invalid course ID."}), 404 - - # Check if the user is enrolled in the course - is_enrolled = db.session.query(Course).join(Enrollment).filter( - Enrollment.userID == current_user.id, - Enrollment.courseID == course_id - ).first() - - if not is_enrolled: - return jsonify({"error": "You are not enrolled in this course."}), 403 - - # Create and save the chat message new_chat = Chat( - textContent=message, + textContent=data.get("message").strip(), userID=current_user.id, - courseID=course_id, + courseID=data.get("course_id"), ) db.session.add(new_chat) db.session.commit() @@ -128,56 +55,71 @@ def create_chat(): @chat.route("/get", methods=["GET"]) @auth_required() -def get_chat_history(): - """ - Fetch chat history for a course. - """ - current_user = g.current_user # Logged-in user - course_id = request.args.get("course_id") - limit = int(request.args.get("limit", 20)) # Default to 20 messages - offset = int(request.args.get("offset", 0)) # Default to no offset (latest chats) - - if not course_id: - return jsonify({"error": "Course ID is required."}), 400 - +def get_messages(): try: - # Verify the course exists - course = db.session.query(Course).filter_by(id=course_id).first() - if not course: - return jsonify({"error": "Invalid course ID."}), 404 - - # Check if the user is enrolled in the course - is_enrolled = db.session.query(Course).join(Enrollment).filter( - Enrollment.userID == current_user.id, - Enrollment.courseID == course_id - ).first() - - if not is_enrolled: + course_id: uuid.UUID = uuid.UUID(request.args.get('course_id')) + current_user: User = g.current_user + limit = int(request.args.get('limit', 10)) + before_id = request.args.get('before') + after_id = request.args.get('after') + # Verify user's enrollment + enrollment = db.session.execute( + select(Enrollment).where( + and_(Enrollment.courseID == course_id, Enrollment.userID == current_user.id) + ) + ).scalar() + if not enrollment: return jsonify({"error": "You are not enrolled in this course."}), 403 - - # Fetch the latest chat messages with limit and offset - chats = ( - db.session.query(Chat) - .filter_by(courseID=course_id) - .order_by(desc(Chat.chatDate)) - .offset(offset) - .limit(limit) - .all() - ) - - # Format the chat messages - chat_history = [ - { - "id": str(chat.id), - "textContent": chat.textContent, - "userID": str(chat.userID), - "chatDate": chat.chatDate.isoformat(), - "is_mine": chat.userID == current_user.id, - } - for chat in chats - ] - - return jsonify({"chats": chat_history}), 200 + query = select(Chat).where(Chat.courseID == course_id) + if before_id: + try: + reference_message: Chat = db.session.execute( + select(Chat).where(Chat.id == uuid.UUID(before_id)) + ).scalar() + if not reference_message: + return jsonify({'message': 'Reference message not found'}), 404 + query = query.order_by(Chat.chatDate.desc()).where(Chat.chatDate < reference_message.chatDate) + except ValueError: + return jsonify({'message': 'Invalid message ID format'}), 400 + elif after_id: + try: + reference_message = db.session.execute( + select(Chat).where(Chat.id == uuid.UUID(after_id)) + ).scalar() + + if not reference_message: + return jsonify({'message': 'Reference message not found'}), 404 + query = query.order_by(Chat.chatDate.asc()).where(Chat.chatDate > reference_message.chatDate) + # For after queries, reverse the order later + query = query.order_by(Chat.chatDate) + except ValueError: + return jsonify({'message': 'Invalid message ID format'}), 400 + else: + # Default ordering + query = query.order_by(desc(Chat.chatDate)) + + # Apply limit and execute query + query = query.limit(limit) + messages = list(db.session.execute(query).scalars()) + + # Reverse the order for 'after' queries to maintain consistency + if after_id: + messages.reverse() + + # Format messages + chat_messages = [{ + 'id': str(msg.id), + 'text': msg.textContent, + 'userId': str(msg.userID), + 'username': msg.user.username, + 'timestamp': msg.chatDate.isoformat() + } for msg in messages] + + return jsonify({ + 'messages': chat_messages, + 'count': len(chat_messages), + 'hasMore': len(chat_messages) == limit + }), 200 except Exception as e: - return jsonify({"error": f"An error occurred: {str(e)}"}), 500 \ No newline at end of file + return jsonify({'message': f'An error occurred: {str(e)}'}), 500 \ No newline at end of file diff --git a/backend/blueprints/course/__init__.py b/backend/blueprints/course/__init__.py index 94f549b..a344ddb 100644 --- a/backend/blueprints/course/__init__.py +++ b/backend/blueprints/course/__init__.py @@ -110,9 +110,6 @@ def enroll_user(): return jsonify({'message': 'Already enrolled to this course'}) return jsonify({'message': 'Enrollment successful'}), 200 - - - @course.route('/createCourse', methods=['POST']) @auth_required() def create_course(): @@ -134,7 +131,7 @@ def create_course(): return jsonify({'message': 'Course name cannot be empty'}), 401 course_description: str = form_data.get('course_description', '') category_id: uuid.UUID = uuid.UUID(form_data['category_uuid']) - pages_required_for_community: int = int(form_data['community_unlock_at_pages']) # TODO: Add this field to model + page_for_community: int = int(form_data.get('page_for_community', 1)) # TODO: Add this field to model new_course: Course = Course( name=course_name, @@ -142,6 +139,7 @@ def create_course(): authorID=g.current_user.id, description=course_description, isActive=True, + pageForCommunity=page_for_community, publishedStatus=int(published_status), coverImage=cover_file_name, serverFilename =pdf_file_name, diff --git a/backend/blueprints/profile/__init__.py b/backend/blueprints/profile/__init__.py index 7e7373d..bb2eb83 100644 --- a/backend/blueprints/profile/__init__.py +++ b/backend/blueprints/profile/__init__.py @@ -15,7 +15,6 @@ from sqlalchemy.exc import IntegrityError # from flask import url_for profile = Blueprint('profile', __name__) -# Function to check allowed file extensions def allowed_file(filename): """Check if the uploaded file has an allowed extension.""" return '.' in filename and filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS'] @@ -110,13 +109,8 @@ def manage_profile(): Handle GET and PUT requests for the logged-in user's profile. """ current_user: User = g.current_user - if request.method == 'GET': - try: - profile_picture = url_for('send_file', filename=current_user.pfpFilename, _external=True) - except: - profile_picture = "" - + profile_picture = url_for('send_file', filename=current_user.pfpFilename, _external=True) try: # Construct the user profile data profile_data = { @@ -133,24 +127,17 @@ def manage_profile(): "pfp_filename": current_user.pfpFilename, "profile_picture": profile_picture, } - return jsonify({"profile": profile_data}), 200 except Exception as e: return jsonify({"error": f"Failed to fetch profile. Error: {str(e)}"}), 500 - elif request.method == 'PUT': # Update the user's profile using form data try: - # email = request.form.get('email') first_name = request.form.get('firstName') last_name = request.form.get('lastName') username = request.form.get('username') dob = request.form.get('dob') bio = request.form.get('bio') - - # Update fields if provided - # if email: - # current_user.email = email if first_name: current_user.firstName = first_name if last_name: @@ -161,10 +148,7 @@ def manage_profile(): current_user.dob = dob # Ensure the date format is validated if bio: current_user.bio = bio - - # Commit changes to the database db.session.commit() - return jsonify({"message": "Profile updated successfully."}), 200 except IntegrityError: db.session.rollback() @@ -174,7 +158,6 @@ def manage_profile(): return jsonify({"error": f"Failed to update profile. Error: {str(e)}"}), 500 - @profile.route('/update-profile-picture', methods=['PATCH']) @auth_required() def update_profile_picture(): @@ -194,22 +177,18 @@ def update_profile_picture(): return jsonify({"error": "No selected file"}), 400 if not allowed_file(file.filename): return jsonify({"error": "Invalid file type"}), 400 - # Secure the filename and save the new file filename = secure_filename(f"user_{user.id}_{file.filename}") new_filepath = os.path.join(USER_UPLOADS_DIR, filename) file.save(new_filepath) - # Delete the old profile picture (if it's not the default) if user.pfpFilename != DEFAULT_PROFILE_FILE: old_filepath = os.path.join(USER_UPLOADS_DIR, user.pfpFilename) if os.path.exists(old_filepath): os.remove(old_filepath) - # Update the user's profile picture user.pfpFilename = filename db.session.commit() - # Generate the new profile URL profile_url = url_for('send_file',filename=user.pfpFilename,_external=True) @@ -232,37 +211,25 @@ def change_password(): """ user = g.current_user data = request.form - # Validate input data current_password = data.get('current_password') new_password = data.get('new_password') confirm_password = data.get('confirm_password') - if not current_password or not new_password or not confirm_password: return jsonify({"error": "All fields (current_password, new_password, confirm_password) are required"}), 400 - # Check if current password matches the user's existing password if not check_password_hash(user.hash_password, current_password): return jsonify({"error": "Current password is incorrect"}), 400 - # Check if new password and confirmation match if new_password != confirm_password: return jsonify({"error": "New password and confirm password do not match"}), 400 - # Check for password complexity (optional) # Validate password try: password_check_sanity(new_password) except InsecurePasswordException as e: return jsonify({"error": str(e)}), 400 - # Update the user's password user.hash_password = generate_password_hash(new_password) db.session.commit() - - return jsonify({"message": "Password updated successfully"}), 200 - - -# @profile.route('/hello') -# @auth_required() -# @requires_role([UserRole.ADMIN]) \ No newline at end of file + return jsonify({"message": "Password updated successfully"}), 200 \ No newline at end of file diff --git a/backend/config.py b/backend/config.py index 1ab2f0c..064e851 100644 --- a/backend/config.py +++ b/backend/config.py @@ -20,6 +20,9 @@ DISABLE_PASSWORD_SANITY_CHECKS: bool = False PROJECT_ROOT: os.path = os.path.dirname(os.path.abspath(__file__)) USER_UPLOADS_DIR: str = os.path.abspath(os.path.join(PROJECT_ROOT, "uploads")) +SPAM_SCORE_THRESHOLD: int = 6 +AI_SPAM_SERVICES_MICROSERVICE: str ='http://localhost:5000/test-spam' + DB_URI: str = f"{DB_ENGINE}://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" ACTIVATE_ACCOUNTS_ON_SIGNUP: bool = True diff --git a/backend/db/model.py b/backend/db/model.py index 936e6ac..82852e6 100644 --- a/backend/db/model.py +++ b/backend/db/model.py @@ -73,6 +73,8 @@ class Course(db.Model): authorID: Mapped[uuid.UUID] = mapped_column(ForeignKey("user.id")) author: Mapped["User"] = relationship(back_populates="publications") description: Mapped[str] = mapped_column(String(1024), nullable=False, default='') + pageForCommunity: Mapped[[int]] = mapped_column(Integer, nullable=False, default=1) + totalPages: Mapped[[int]] = mapped_column(Integer, nullable=False, default=1) totalEnrolled: Mapped[int] = mapped_column(Integer, nullable=False, default='') isActive: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) publishedStatus: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=PublishedStatus.DRAFT)