From bd836fe0eb919407cd1e686e782a911148181658 Mon Sep 17 00:00:00 2001 From: lifee77 Date: Fri, 4 Jul 2025 21:40:06 -0500 Subject: [PATCH 01/19] upload resume fix --- Dockerfile | 7 +- azure-deploy/web.config | 9 +++ backend/app.py | 41 +++++++++- backend/config.py | 14 ++++ backend/routes/profile.py | 69 +++++++++++++++- react-frontend/src/pages/Profile.jsx | 114 ++++++++++++++++++++++++--- react-frontend/src/services/api.js | 9 ++- 7 files changed, 243 insertions(+), 20 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5194b0f8..a1223c69 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,8 +55,11 @@ RUN playwright install-deps chromium COPY app.py . COPY backend/ backend/ -# Create uploads directory if it doesn't exist -RUN mkdir -p backend/uploads +# Create uploads directory if it doesn't exist with proper permissions +RUN mkdir -p backend/uploads && \ + chmod 777 backend/uploads && \ + mkdir -p uploads && \ + chmod 777 uploads # Create instance directory for SQLite database and ensure it's writable RUN mkdir -p instance && \ diff --git a/azure-deploy/web.config b/azure-deploy/web.config index 688e3ccd..f0214fa9 100644 --- a/azure-deploy/web.config +++ b/azure-deploy/web.config @@ -12,8 +12,17 @@ + + + + + + + + + diff --git a/backend/app.py b/backend/app.py index 522f608b..74875130 100644 --- a/backend/app.py +++ b/backend/app.py @@ -156,6 +156,18 @@ def load_user(user_id): # Initialize our custom email service init_email_service(app) + # Ensure upload directories exist in production + upload_folder = app.config.get('UPLOAD_FOLDER', 'uploads') + if not os.path.isabs(upload_folder): + upload_folder = os.path.join(app.root_path, upload_folder) + + try: + os.makedirs(upload_folder, exist_ok=True) + os.chmod(upload_folder, 0o777) # Ensure proper permissions + logging.info(f"Upload folder ensured: {upload_folder}") + except Exception as e: + logging.warning(f"Could not create upload folder: {e}") + # Create database tables and bind models to app context with app.app_context(): try: @@ -227,7 +239,8 @@ def health_check(): "timestamp": datetime.utcnow().isoformat(), "components": { "app": "healthy", - "database": "unknown" + "database": "unknown", + "upload_folder": "unknown" } } @@ -244,11 +257,35 @@ def health_check(): health_status["status"] = "unhealthy" health_status["database_error"] = str(e) + # Check upload folder + try: + upload_folder = app.config.get('UPLOAD_FOLDER', 'uploads') + if not os.path.isabs(upload_folder): + upload_folder = os.path.join(app.root_path, upload_folder) + + health_status["upload_path"] = upload_folder + + # Test if folder exists and is writable + if not os.path.exists(upload_folder): + os.makedirs(upload_folder, exist_ok=True) + + test_file = os.path.join(upload_folder, 'health_check.tmp') + with open(test_file, 'w') as f: + f.write('test') + os.remove(test_file) + + health_status["components"]["upload_folder"] = "healthy" + except Exception as e: + app.logger.error(f"Upload folder health check failed: {str(e)}") + health_status["components"]["upload_folder"] = "unhealthy" + health_status["status"] = "unhealthy" + health_status["upload_error"] = str(e) + # Set response status code based on health status_code = 200 if health_status["status"] == "healthy" else 503 # Log health check result - app.logger.info(f"Health check status: {health_status['status']}, Database: {health_status['components']['database']}") + app.logger.info(f"Health check status: {health_status['status']}, Database: {health_status['components']['database']}, Upload: {health_status['components']['upload_folder']}") return health_status, status_code diff --git a/backend/config.py b/backend/config.py index 6cb719ba..e60ec439 100644 --- a/backend/config.py +++ b/backend/config.py @@ -93,6 +93,8 @@ def SQLALCHEMY_ENGINE_OPTIONS(self): SESSION_COOKIE_SECURE = True SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = 'Lax' + # Add session cookie name for consistency + SESSION_COOKIE_NAME = 'instantapply_session' # Security settings WTF_CSRF_ENABLED = True @@ -302,6 +304,18 @@ def SQLALCHEMY_ENGINE_OPTIONS(self): SESSION_COOKIE_SECURE = True SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = 'Lax' + SESSION_COOKIE_NAME = 'instantapply_session' + # Set session cookie domain for production + SESSION_COOKIE_DOMAIN = '.instantapply.tech' + + # Production file upload settings - ensure larger limits for Azure + MAX_CONTENT_LENGTH = 32 * 1024 * 1024 # 32MB for production + UPLOAD_FOLDER = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'uploads') + # Ensure upload folder exists in production + try: + os.makedirs(UPLOAD_FOLDER, exist_ok=True) + except Exception: + pass # Will be handled by the upload endpoint # Configuration dictionary config = { diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 0b23dff6..2f3ebc36 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -248,6 +248,26 @@ def api_upload_resume(): if not user: current_app.logger.error(f"Could not find user with ID {current_user.id}") return jsonify({'success': False, 'error': 'User not found'}), 404 + + # Ensure upload folder exists with proper permissions + upload_folder = current_app.config.get('UPLOAD_FOLDER', 'uploads') + if not os.path.isabs(upload_folder): + upload_folder = os.path.join(current_app.root_path, upload_folder) + + try: + os.makedirs(upload_folder, exist_ok=True) + # Test write permissions + test_file = os.path.join(upload_folder, 'test_write.tmp') + with open(test_file, 'w') as f: + f.write('test') + os.remove(test_file) + current_app.logger.info(f"Upload folder verified: {upload_folder}") + except Exception as e: + current_app.logger.error(f"Upload folder creation/permission error: {str(e)}") + # Fallback to a temporary directory + import tempfile + upload_folder = tempfile.mkdtemp() + current_app.logger.info(f"Using temporary upload folder: {upload_folder}") # Check if we have JSON data with a base64 encoded file if request.is_json: @@ -277,6 +297,7 @@ def api_upload_resume(): }), 200 except Exception as e: current_app.logger.error(f"Error processing base64 resume: {str(e)}") + current_app.logger.error(traceback.format_exc()) db.session.rollback() return jsonify({'success': False, 'error': f'Error processing resume: {str(e)}'}), 400 else: @@ -288,6 +309,23 @@ def api_upload_resume(): file = request.files['resume_file'] if file and file.filename != '': try: + # Validate file type + allowed_extensions = current_app.config.get('ALLOWED_EXTENSIONS', {'pdf', 'doc', 'docx', 'txt'}) + file_ext = file.filename.rsplit('.', 1)[1].lower() if '.' in file.filename else '' + if file_ext not in allowed_extensions: + return jsonify({ + 'success': False, + 'error': f'File type not allowed. Please use: {", ".join(allowed_extensions)}' + }), 400 + + # Check file size (Flask's MAX_CONTENT_LENGTH should handle this, but double-check) + max_size = current_app.config.get('MAX_CONTENT_LENGTH', 16 * 1024 * 1024) # 16MB default + if hasattr(file, 'content_length') and file.content_length and file.content_length > max_size: + return jsonify({ + 'success': False, + 'error': f'File too large. Maximum size: {max_size // (1024*1024)}MB' + }), 400 + # Process the resume file file_path, filename, resume_text = process_resume_file(file) @@ -833,11 +871,36 @@ def process_resume_file(file): filename = secure_filename(file.filename) timestamp = dt.now().strftime("%Y%m%d_%H%M%S") unique_filename = f"{timestamp}_{filename}" - # Ensure upload folder exists + + # Ensure upload folder exists with proper error handling upload_folder = current_app.config.get('UPLOAD_FOLDER', 'uploads') - os.makedirs(upload_folder, exist_ok=True) + if not os.path.isabs(upload_folder): + upload_folder = os.path.join(current_app.root_path, upload_folder) + + try: + os.makedirs(upload_folder, exist_ok=True) + # Test write permissions + test_file = os.path.join(upload_folder, 'test_write.tmp') + with open(test_file, 'w') as f: + f.write('test') + os.remove(test_file) + current_app.logger.info(f"Upload folder verified: {upload_folder}") + except Exception as e: + current_app.logger.error(f"Upload folder creation/permission error: {str(e)}") + # Fallback to a temporary directory + import tempfile + upload_folder = tempfile.mkdtemp() + current_app.logger.info(f"Using temporary upload folder: {upload_folder}") + file_path = os.path.join(upload_folder, unique_filename) - file.save(file_path) + + try: + file.save(file_path) + current_app.logger.info(f"File saved successfully: {file_path}") + except Exception as e: + current_app.logger.error(f"Error saving file: {str(e)}") + raise e + # Extract text from the file resume_text = extract_text_from_resume(file_path) diff --git a/react-frontend/src/pages/Profile.jsx b/react-frontend/src/pages/Profile.jsx index 07828c83..c95cada5 100644 --- a/react-frontend/src/pages/Profile.jsx +++ b/react-frontend/src/pages/Profile.jsx @@ -317,6 +317,45 @@ const LoadingText = styled.p` font-size: 1.1rem; `; +const LoadingSubText = styled.p` + color: var(--color-text-secondary); + font-size: 0.9rem; + margin-top: 0.5rem; + text-align: center; + opacity: 0.8; +`; + +const UploadBenefits = styled.div` + display: flex; + justify-content: space-around; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border-light); +`; + +const BenefitItem = styled.div` + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + font-size: 0.8rem; + color: var(--color-text-secondary); + + i { + font-size: 1.2rem; + margin-bottom: 0.5rem; + color: var(--color-primary); + } + + @media (max-width: 768px) { + font-size: 0.7rem; + + i { + font-size: 1rem; + } + } +`; + const FileNameDisplay = styled.div` font-size: 0.9rem; margin-top: 0.5rem; @@ -1170,11 +1209,21 @@ const Profile = () => { const handleFileUpload = async (file) => { if (!file) return; - const maxSizeMB = 10; + // Enhanced file validation + const maxSizeMB = 16; const maxSizeBytes = maxSizeMB * 1024 * 1024; + const allowedTypes = ['pdf', 'doc', 'docx', 'txt']; + const fileExtension = file.name.split('.').pop().toLowerCase(); + + // Validate file type + if (!allowedTypes.includes(fileExtension)) { + addFlashMessage('danger', `File type not supported. Please use: ${allowedTypes.join(', ').toUpperCase()}`); + return; + } + // Validate file size if (file.size > maxSizeBytes) { - addFlashMessage('danger', `File is too large. Maximum size is ${maxSizeMB}MB.`); + addFlashMessage('danger', `File is too large. Maximum size is ${maxSizeMB}MB. Your file is ${(file.size / (1024 * 1024)).toFixed(1)}MB.`); return; } @@ -1188,6 +1237,9 @@ const Profile = () => { // Set the filename immediately so the UI can be updated setUploadedFileName(file.name); + // Show immediate feedback + addFlashMessage('info', 'Uploading and processing your resume...'); + const response = await profileAPI.uploadResume(formData); if (response.data && response.data.success) { @@ -1216,25 +1268,41 @@ const Profile = () => { await fetchProfileData(true); refreshTimeoutRef.current = null; addFlashMessage('success', 'Resume uploaded and parsed successfully! Your profile has been updated with your skills, experience, and projects.'); - }, 2000); // 2 seconds - much more reasonable + }, 2000); } else { // If no parsed data, just refresh the profile data from the server console.log('No parsed data received, refreshing profile'); setTimeout(async () => { await fetchProfileData(true); - }, 2000); // 2 seconds + }, 2000); addFlashMessage('success', 'Resume uploaded successfully!'); } } else { // If upload failed, clear the filename setUploadedFileName(''); - addFlashMessage('danger', response.data?.message || 'Failed to parse resume. Please try again.'); + const errorMessage = response.data?.error || response.data?.message || 'Failed to parse resume. Please try again.'; + addFlashMessage('danger', errorMessage); } } catch (error) { // If there was an error, clear the filename setUploadedFileName(''); console.error('Resume upload error:', error); - addFlashMessage('danger', error.response?.data?.message || 'Failed to upload resume.'); + + // Provide more specific error messages + let errorMessage = 'Failed to upload resume. Please try again.'; + if (error.response?.status === 401) { + errorMessage = 'Session expired. Please log in again.'; + } else if (error.response?.status === 413) { + errorMessage = 'File is too large. Please use a smaller file.'; + } else if (error.response?.data?.error) { + errorMessage = error.response.data.error; + } else if (error.response?.data?.message) { + errorMessage = error.response.data.message; + } else if (error.message) { + errorMessage = error.message; + } + + addFlashMessage('danger', errorMessage); } finally { setIsLoading(false); setIsParsingResume(false); @@ -1665,22 +1733,23 @@ const Profile = () => { onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} - onClick={() => fileInputRef.current.click()} - style={{ position: 'relative' }} + onClick={() => !isParsingResume && fileInputRef.current.click()} + style={{ position: 'relative', cursor: isParsingResume ? 'not-allowed' : 'pointer' }} > {isParsingResume && ( Processing resume... + This may take a few moments )} - + {uploadedFileName - ? 'Your resume has been uploaded and processed. Click to upload a new one.' - : 'Your resume will be automatically parsed to fill in your profile information.' + ? 'Resume uploaded successfully! Click to upload a new one.' + : 'Drag & drop your resume or click to browse' } @@ -1688,6 +1757,7 @@ const Profile = () => { {uploadedFileName} + )} @@ -1698,8 +1768,28 @@ const Profile = () => { style={{ display: 'none' }} ref={fileInputRef} onChange={handleFileSelect} + disabled={isParsingResume} /> - Supported formats: PDF, DOCX, DOC, TXT + + Supported formats: PDF, DOCX, DOC, TXT • Maximum size: 16MB + + + {!uploadedFileName && ( + + + + Auto-fill profile fields + + + + Extract skills & experience + + + + Save time on data entry + + + )}
diff --git a/react-frontend/src/services/api.js b/react-frontend/src/services/api.js index 8f59f56d..8122c211 100644 --- a/react-frontend/src/services/api.js +++ b/react-frontend/src/services/api.js @@ -246,7 +246,14 @@ export const profileAPI = { ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {}) }, - withCredentials: true + withCredentials: true, + timeout: 60000, // 60 second timeout for file uploads + maxContentLength: 32 * 1024 * 1024, // 32MB + maxBodyLength: 32 * 1024 * 1024, // 32MB + // Add retry mechanism for production + validateStatus: function (status) { + return status < 500; // Don't reject on 4xx errors, handle them in the component + } }); }, From c60cf7072eb7bc3c63c0f68c80a85d56b34c1006 Mon Sep 17 00:00:00 2001 From: lifee77 Date: Fri, 4 Jul 2025 21:51:48 -0500 Subject: [PATCH 02/19] modularized profile routes --- backend/app.py | 8 +- backend/routes/profile.py | 1511 +------------------- backend/routes/profile/__init__.py | 32 + backend/routes/profile/keywords.py | 162 +++ backend/routes/profile/main.py | 197 +++ backend/routes/profile/resume.py | 320 +++++ backend/routes/profile_backup.py | 1503 +++++++++++++++++++ backend/utils/job_recommenders/__init__.py | 8 +- backend/utils/job_recommenders/advanced.py | 8 +- backend/utils/job_recommenders/simple.py | 12 +- backend/utils/profile_utils.py | 211 +++ backend/utils/resume_utils.py | 215 +++ 12 files changed, 2667 insertions(+), 1520 deletions(-) create mode 100644 backend/routes/profile/__init__.py create mode 100644 backend/routes/profile/keywords.py create mode 100644 backend/routes/profile/main.py create mode 100644 backend/routes/profile/resume.py create mode 100644 backend/routes/profile_backup.py create mode 100644 backend/utils/profile_utils.py create mode 100644 backend/utils/resume_utils.py diff --git a/backend/app.py b/backend/app.py index 74875130..f3725941 100644 --- a/backend/app.py +++ b/backend/app.py @@ -190,7 +190,7 @@ def load_user(user_id): # Register blueprints from routes.api import api_bp from routes.auth import auth_bp - from routes.profile import profile_bp + from routes.profile import register_profile_routes from routes.jobs import jobs_bp from routes.admin import admin_bp from routes.moderator import moderator_bp @@ -213,8 +213,10 @@ def load_user(user_id): app.register_blueprint(auth_bp, url_prefix='/api/auth', name='api_auth') app.register_blueprint(simple_signup_bp, url_prefix='/api/auth') app.register_blueprint(simple_auth_bp, url_prefix='/api/auth') - app.register_blueprint(profile_bp, url_prefix='/profile') - app.register_blueprint(profile_bp, url_prefix='/api/profile', name='api_profile') + + # Register profile routes using new modular structure + register_profile_routes(app) + app.register_blueprint(content_preview_bp, url_prefix='/content') app.register_blueprint(session_bp, url_prefix='/api/session', name='api_session') diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 2f3ebc36..cea72ca4 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -1,1503 +1,8 @@ -from flask import Blueprint, render_template, redirect, url_for, flash, request, current_app, jsonify, session, abort, send_from_directory -from flask_login import login_required, current_user -import os -import json -from werkzeug.utils import secure_filename -import PyPDF2 -import docx2txt -from datetime import datetime as dt -import logging -import time -import traceback -import re -from dateutil import parser as date_parser -from sqlalchemy.orm import RelationshipProperty -from sqlalchemy import text -from models.db import db -from models.all_models import User, Experience, Project, PortfolioLink, ApplicantValue, Skill, Language, Certification, DesiredJobTitle -from forms.profile import ProfileForm -from utils.document_parser import parse_pdf -from sqlalchemy.orm.exc import DetachedInstanceError -from models import JobKeyword -from services.resume_keyword_service import ResumeKeywordService -from utils.document_parser import parse_pdf -from utils.auth import admin_required, moderator_required - -# Helper function to parse various date formats -def parse_date(date_string): - """ - Parse various date formats into a date object - """ - if not date_string: - return None - - # First try to use dateutil parser which handles most common formats - try: - return date_parser.parse(date_string).date() - except: - # Handle common date formats manually - patterns = [ - r'(\w+)\s+(\d{4})', # Month YYYY (e.g., "October 2024", "May 2024") - r'(\d{1,2})[/\.-](\d{1,2})[/\.-](\d{2,4})', # MM/DD/YYYY or DD/MM/YYYY - r'(\d{4})' # Just year - ] - - for pattern in patterns: - match = re.search(pattern, date_string) - if match: - groups = match.groups() - try: - if len(groups) == 2 and groups[0].isalpha(): - # Month YYYY format (e.g., "October 2024") - month_map = { - 'january': 1, 'february': 2, 'march': 3, 'april': 4, 'may': 5, 'june': 6, - 'july': 7, 'august': 8, 'september': 9, 'october': 10, 'november': 11, 'december': 12, - 'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'may': 5, 'jun': 6, - 'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12 - } - month_text = groups[0].lower() - month = month_map.get(month_text, 1) - year = int(groups[1]) - return dt.date(year, month, 1) - elif len(groups) == 3: - # MM/DD/YYYY or DD/MM/YYYY - month = int(groups[0]) - day = int(groups[1]) - year = int(groups[2]) - if year < 100: - year += 2000 if year < 50 else 1900 - return dt.date(year, month, day) - elif len(groups) == 1: - # Just year - year = int(groups[0]) - return dt.date(year, 1, 1) - except: - continue - - # If nothing worked, try to extract just a year - year_match = re.search(r'(\d{4})', date_string) - if year_match: - try: - year = int(year_match.group(1)) - return dt.date(year, 1, 1) - except: - pass - - # If all attempts failed, return None - return None - -profile_bp = Blueprint('profile', __name__) -resume_service = ResumeKeywordService() -document_parser = parse_pdf - -@profile_bp.route('', methods=['GET', 'POST', 'OPTIONS']) -@profile_bp.route('/', methods=['GET', 'POST', 'OPTIONS']) -def profile(): - """Handle profile requests - serve React frontend for browser, API for AJAX""" - # Handle OPTIONS request for CORS preflight - if request.method == 'OPTIONS': - response = jsonify({'status': 'ok'}) - response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') - response.headers['Access-Control-Allow-Headers'] = 'Content-Type,Authorization' - response.headers['Access-Control-Allow-Methods'] = 'GET,POST,OPTIONS' - response.headers['Access-Control-Allow-Credentials'] = 'true' - return response - - # For GET requests, check if it's an AJAX request (API call) or a browser request (HTML) - if request.method == 'GET': - # Check if this is an API request (AJAX call from React frontend) - is_ajax = ( - request.headers.get('Content-Type', '').startswith('application/json') or - request.headers.get('Accept', '').find('application/json') != -1 or - request.headers.get('X-Requested-With') == 'XMLHttpRequest' or - request.args.get('format') == 'json' - ) - - if is_ajax: - # Return JSON data for API requests - call the API function directly - return api_get_profile() - else: - # For direct browser access, serve the React frontend - # This will be handled by the main app's catch-all route - # We need to not handle this route for browser requests - abort(404) # Let the main app's catch-all route handle this - - # For POST requests, call the API update function directly - if request.method == 'POST': - return api_update_profile() - - return jsonify({'error': 'Method not allowed'}), 405 - - -@profile_bp.route('/upload-resume', methods=['GET', 'POST']) -@login_required -def upload_resume(): - """Handle resume upload via API for React frontend.""" - form = ProfileForm() - - # For POST requests with file uploads - if request.method == 'POST': - if 'resume_file' in request.files: - file = request.files['resume_file'] - if file and file.filename != '': - try: - # Process the resume file - file_path, filename, resume_text = process_resume_file(file) - - if file_path: - # Update user's resume information - current_user.resume_filename = filename - current_user.resume_file_path = file_path - if resume_text: - current_user.resume = resume_text - - # Save changes to database - db.session.commit() - - # Extract keywords from resume and save to keyword database - current_app.logger.debug(f"[KEYWORD EXTRACTION] Raw resume text (first 300 chars): {resume_text[:300] if resume_text else 'None'}") - extraction_result = resume_service.extract_keywords_from_resume( - user_id=current_user.id, - resume_text=resume_text - ) - current_app.logger.debug(f"[KEYWORD EXTRACTION] Extracted {extraction_result['keywords_extracted']} keywords: {extraction_result['keywords']}") - try: - current_app.logger.info(f"[KEYWORD EXTRACTION] Extracted {extraction_result['keywords_extracted']} keywords from resume for user {current_user.id}") - current_app.logger.info(f"[KEYWORD EXTRACTION] Keywords: {extraction_result['keywords']}") - except Exception as e: - current_app.logger.error(f"[KEYWORD EXTRACTION ERROR] {str(e)}") - # Continue despite keyword extraction errors - - # Return success JSON response - return jsonify({ - 'success': True, - 'message': 'Resume uploaded and parsed successfully. Please review your profile information.', - 'fileInfo': { - 'filename': filename, - 'parsedData': parse_pdf(file_path) # Include parsed data in the response - } - }), 200 - except Exception as e: - current_app.logger.error(f"Error in resume upload: {str(e)}") - return jsonify({ - 'success': False, - 'message': f'Error processing resume: {str(e)}' - }), 400 - else: - return jsonify({ - 'success': False, - 'message': 'No resume file selected.' - }), 400 - else: - return jsonify({ - 'success': False, - 'message': 'No resume file provided in request' - }), 400 - - # Skip the resume upload if requested - if request.args.get('skip') == 'true': - session['skip_resume_upload'] = True - return jsonify({ - 'success': True, - 'redirect': url_for('profile.profile') - }), 200 - - # For GET requests, return form config instead of rendering a template - form_config = { - 'csrf_token': form.csrf_token.data, - 'resume_field': { - 'label': 'Upload Resume', - 'description': 'Upload your resume (PDF, DOCX, or TXT)', - 'required': True, - 'accepted_types': '.pdf,.docx,.doc,.txt' - } - } - - return jsonify({ - 'success': True, - 'form_config': form_config - }), 200 - - -@profile_bp.route('/upload-resume', methods=['GET']) -@login_required -def upload_resume_react(): - """Handler for the React-based resume upload page""" - try: - # Redirect to the correct URL for resume uploads - return redirect(url_for('profile.upload_resume')) - except Exception as e: - current_app.logger.error(f"Error redirecting to resume upload: {str(e)}") - flash(f"Error loading resume upload: {str(e)}", "danger") - return redirect(url_for('profile.profile')) - - -@profile_bp.route('/resume', methods=['POST']) -@login_required -def api_upload_resume(): - """API endpoint for resume uploads from the React frontend""" - try: - current_app.logger.info(f"Resume upload attempt for user {current_user.id}") - current_app.logger.info(f"Request content type: {request.content_type}") - current_app.logger.info(f"Request files: {list(request.files.keys())}") - current_app.logger.info(f"Request form: {list(request.form.keys())}") - current_app.logger.info(f"Is JSON: {request.is_json}") - - # Re-fetch the current user from the database to ensure it's attached to the session - user = User.query.get(current_user.id) - if not user: - current_app.logger.error(f"Could not find user with ID {current_user.id}") - return jsonify({'success': False, 'error': 'User not found'}), 404 - - # Ensure upload folder exists with proper permissions - upload_folder = current_app.config.get('UPLOAD_FOLDER', 'uploads') - if not os.path.isabs(upload_folder): - upload_folder = os.path.join(current_app.root_path, upload_folder) - - try: - os.makedirs(upload_folder, exist_ok=True) - # Test write permissions - test_file = os.path.join(upload_folder, 'test_write.tmp') - with open(test_file, 'w') as f: - f.write('test') - os.remove(test_file) - current_app.logger.info(f"Upload folder verified: {upload_folder}") - except Exception as e: - current_app.logger.error(f"Upload folder creation/permission error: {str(e)}") - # Fallback to a temporary directory - import tempfile - upload_folder = tempfile.mkdtemp() - current_app.logger.info(f"Using temporary upload folder: {upload_folder}") - - # Check if we have JSON data with a base64 encoded file - if request.is_json: - current_app.logger.info("Processing JSON request with base64 file") - data = request.json - if 'resume_file' in data and data['resume_file'].startswith('data:'): - try: - from utils.document_parser import parse_and_save_resume - current_app.logger.info("Parsing resume from base64 data") - parsed_text, file_path, filename, mime_type = parse_and_save_resume( - data['resume_file'], user.id) - user.resume = parsed_text - user.resume_file_path = file_path - user.resume_filename = filename - user.resume_mime_type = mime_type - - db.session.commit() - current_app.logger.info(f"Successfully saved base64 resume for user {user.id}") - - return jsonify({ - 'success': True, - 'message': 'Resume uploaded successfully from base64 data', - 'fileInfo': { - 'filename': filename, - 'mimeType': mime_type - } - }), 200 - except Exception as e: - current_app.logger.error(f"Error processing base64 resume: {str(e)}") - current_app.logger.error(traceback.format_exc()) - db.session.rollback() - return jsonify({'success': False, 'error': f'Error processing resume: {str(e)}'}), 400 - else: - return jsonify({'success': False, 'error': 'No valid base64 resume file found in JSON data'}), 400 - - # Handle multipart form data (file upload) - elif 'resume_file' in request.files: - current_app.logger.info("Processing multipart form file upload") - file = request.files['resume_file'] - if file and file.filename != '': - try: - # Validate file type - allowed_extensions = current_app.config.get('ALLOWED_EXTENSIONS', {'pdf', 'doc', 'docx', 'txt'}) - file_ext = file.filename.rsplit('.', 1)[1].lower() if '.' in file.filename else '' - if file_ext not in allowed_extensions: - return jsonify({ - 'success': False, - 'error': f'File type not allowed. Please use: {", ".join(allowed_extensions)}' - }), 400 - - # Check file size (Flask's MAX_CONTENT_LENGTH should handle this, but double-check) - max_size = current_app.config.get('MAX_CONTENT_LENGTH', 16 * 1024 * 1024) # 16MB default - if hasattr(file, 'content_length') and file.content_length and file.content_length > max_size: - return jsonify({ - 'success': False, - 'error': f'File too large. Maximum size: {max_size // (1024*1024)}MB' - }), 400 - - # Process the resume file - file_path, filename, resume_text = process_resume_file(file) - - if file_path: - # Update user's resume information - user.resume_filename = filename - user.resume_file_path = file_path - if resume_text: - user.resume = resume_text - - # Save changes to database - db.session.commit() - current_app.logger.info(f"Successfully processed and saved resume file for user {user.id}") - - # Try to extract keywords (but don't fail if this fails) - try: - from services.resume_keyword_service import ResumeKeywordService - resume_service = ResumeKeywordService() - extraction_result = resume_service.extract_keywords_from_resume( - user_id=user.id, - resume_text=resume_text - ) - current_app.logger.info(f"Extracted {extraction_result['keywords_extracted']} keywords") - except Exception as e: - current_app.logger.warning(f"Keyword extraction failed but continuing: {str(e)}") - - return jsonify({ - 'success': True, - 'message': 'Resume uploaded and processed successfully', - 'fileInfo': { - 'filename': filename - } - }), 200 - else: - return jsonify({'success': False, 'error': 'Failed to process resume file'}), 400 - except Exception as e: - current_app.logger.error(f"Error processing resume file: {str(e)}") - current_app.logger.error(traceback.format_exc()) - db.session.rollback() - return jsonify({'success': False, 'error': f'Error processing resume: {str(e)}'}), 400 - else: - return jsonify({'success': False, 'error': 'No file selected or file is empty'}), 400 - else: - current_app.logger.warning(f"No resume file provided. Request files: {request.files.keys()}, Form: {request.form.keys()}") - return jsonify({'success': False, 'error': 'No resume file provided in request'}), 400 - - except Exception as e: - current_app.logger.error(f"Unexpected error in API resume upload: {str(e)}") - current_app.logger.error(traceback.format_exc()) - db.session.rollback() - return jsonify({'success': False, 'error': f'Unexpected error: {str(e)}'}), 500 - - -def update_user_relationships(user, data, relationship_type): - """Helper function to update user relationships (skills, certifications, languages, job titles)""" - if relationship_type == 'skills': - # Clear existing skills - user.skills = [] - - items_list = data.get('skills', []) - if isinstance(items_list, str): - try: - items_list = json.loads(items_list) - except json.JSONDecodeError: - items_list = [s.strip() for s in items_list.split(',') if s.strip()] - - for item in items_list: - # Handle both string and object formats - if isinstance(item, dict): - item_name = item.get('name') or item.get('skill') - else: - item_name = str(item) - - if item_name and item_name.strip(): - skill = Skill.query.filter_by(name=item_name.strip()).first() - if not skill: - skill = Skill(name=item_name.strip()) - db.session.add(skill) - user.skills.append(skill) - - elif relationship_type == 'certifications': - # Clear existing certifications - user.certifications = [] - - items_list = data.get('certifications', []) - if isinstance(items_list, str): - try: - items_list = json.loads(items_list) - except json.JSONDecodeError: - items_list = [] - - for item_data in items_list: - if isinstance(item_data, dict): - item_name = item_data.get('name') - else: - item_name = str(item_data) - - if item_name and item_name.strip(): - cert = Certification.query.filter_by(name=item_name.strip()).first() - if not cert: - cert = Certification(name=item_name.strip()) - db.session.add(cert) - user.certifications.append(cert) - - elif relationship_type == 'languages': - # Clear existing languages - user.languages = [] - - items_list = data.get('languages', []) - if isinstance(items_list, str): - try: - items_list = json.loads(items_list) - except json.JSONDecodeError: - items_list = [] - - for item_data in items_list: - if isinstance(item_data, dict): - item_name = item_data.get('language') - else: - item_name = str(item_data) - - if item_name and item_name.strip(): - lang = Language.query.filter_by(name=item_name.strip()).first() - if not lang: - lang = Language(name=item_name.strip()) - db.session.add(lang) - user.languages.append(lang) - - elif relationship_type == 'job_titles': - # Clear existing job title entries for this user - DesiredJobTitle.query.filter_by(user_id=user.id).delete() - - items_list = data.get('desired_job_titles', []) - if isinstance(items_list, str): - try: - items_list = json.loads(items_list) - except json.JSONDecodeError: - items_list = [t.strip() for t in items_list.split(',') if t.strip()] - - # Add new job title entries - for title in items_list: - if title and str(title).strip(): # Only add non-empty titles - job_title = DesiredJobTitle(user_id=user.id, title=str(title).strip()) - db.session.add(job_title) - -def is_relationship_field(user, field_name): - """Check if the given field is a SQLAlchemy relationship field""" - if not hasattr(user.__class__, field_name): - return False - attr = getattr(user.__class__, field_name) - if not hasattr(attr, 'property'): - return False - return isinstance(attr.property, RelationshipProperty) - -def update_user_json_fields(user, data): - """Helper function to update JSON fields in user profile""" - for field in ['experience', 'projects', 'education', 'portfolio_links', 'applicant_values', 'desired_salary_range']: - if field in data: - # Skip relationship fields - they need special handling - if is_relationship_field(user, field): - current_app.logger.warning(f"Skipping JSON serialization for relationship field: {field}") - continue - - if isinstance(data[field], (list, dict)): - setattr(user, field, data[field]) # Store directly as JSON for SQLAlchemy JSON type - elif isinstance(data[field], str): - try: - json.loads(data[field]) # Validate JSON - setattr(user, field, json.loads(data[field])) # Parse and store as object - except json.JSONDecodeError: - current_app.logger.warning(f"Invalid {field} JSON: {data[field]}, storing as string") - setattr(user, field, data[field]) - -def update_user_basic_fields(user, data): - """Helper function to update basic fields in user profile""" - # Define which fields are safe to update - exclude readonly fields - safe_fields = [ - 'name', 'location', 'github_url', 'linkedin_url', - 'professional_summary', 'work_mode_preference', 'career_goals', - 'biggest_achievement', 'industry_attraction', - 'willing_to_relocate', 'authorization_status', 'veteran_status', - 'needs_sponsorship', 'visa_status', 'race_ethnicity', 'years_of_experience', - 'education_level', 'industry_preference', 'company_size_preference', - 'remote_preference', 'available_start_date', 'preferred_company_type', - 'graduation_date', 'phone_number', 'first_name', 'last_name' - ] - - # Only update fields that are both in data and in safe_fields - for field in safe_fields: - if field in data: - # Handle special case where name might need to be split - if field == 'name' and data.get('name'): - full_name = data['name'] - if " " in full_name: - name_parts = full_name.split() - user.first_name = name_parts[0] - user.last_name = " ".join(name_parts[1:]) - else: - user.first_name = full_name - user.last_name = "" - else: - setattr(user, field, data.get(field)) - -@profile_bp.route('/update', methods=['POST', 'OPTIONS']) -@login_required -def update_profile(): - """Update profile data from API requests""" - # Handle OPTIONS request for CORS preflight - if request.method == 'OPTIONS': - response = jsonify({'status': 'ok'}) - response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') - response.headers['Access-Control-Allow-Headers'] = 'Content-Type,Authorization' - response.headers['Access-Control-Allow-Methods'] = 'POST,OPTIONS' - response.headers['Access-Control-Allow-Credentials'] = 'true' - return response - - try: - data = request.json - current_app.logger.info(f"Received profile update with {len(data.keys()) if data else 0} fields") - - if not data: - return jsonify({ - 'success': False, - 'message': 'No data provided in request' - }), 400 - - # Filter out readonly fields to prevent errors - readonly_fields = { - 'id', 'created_at', 'updated_at', 'is_active', 'is_verified', - 'last_login', 'role', 'applications', 'orders', 'subscription_history', - 'experiences', 'projects', 'assigned_users', 'completion_percentage', - 'group_completions', 'resume', 'resume_file_path', 'resume_filename', - 'resume_mime_type', 'resume_url' - } - - # Create filtered data without readonly fields - filtered_data = {k: v for k, v in data.items() if k not in readonly_fields} - current_app.logger.info(f"Filtered data to {len(filtered_data.keys())} safe fields") - - # Update basic fields - update_user_basic_fields(current_user, filtered_data) - - # Update relationships - for relationship in ['skills', 'certifications', 'languages', 'job_titles']: - if relationship in filtered_data or f'desired_{relationship}' in filtered_data: - update_user_relationships(current_user, filtered_data, relationship) - - # Update JSON fields - update_user_json_fields(current_user, filtered_data) - - # Process date fields - date_fields = ['available_start_date', 'graduation_date', 'military_discharge_date'] - for date_field in date_fields: - if date_field in filtered_data: - if filtered_data[date_field]: - try: - date_value = dt.fromisoformat( - filtered_data[date_field].replace('Z', '+00:00') - ).date() - setattr(current_user, date_field, date_value) - except ValueError as e: - current_app.logger.warning( - f"Invalid {date_field} format: {filtered_data[date_field]}. Error: {str(e)}" - ) - else: - setattr(current_user, date_field, None) - - # Save changes to database - db.session.commit() - current_app.logger.info(f"Profile updated for user: {current_user.id}") - - # Return updated profile with CORS headers - db.session.refresh(current_user) # Refresh to ensure all relationships are loaded - response = jsonify({ - 'success': True, - 'message': 'Profile updated successfully', - 'user': current_user.to_dict() - }) - response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') - response.headers['Access-Control-Allow-Credentials'] = 'true' - return response, 200 - - except Exception as e: - current_app.logger.error(f"Error updating profile: {str(e)}") - current_app.logger.error(traceback.format_exc()) - db.session.rollback() - response = jsonify({ - 'success': False, - 'message': f'Error updating profile: {str(e)}', - 'error_details': traceback.format_exc() - }) - response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') - response.headers['Access-Control-Allow-Credentials'] = 'true' - return response, 500 - - -@profile_bp.route('/api', methods=['GET', 'OPTIONS']) -def api_get_profile(): - """API endpoint to get the current user's profile data""" - # Handle OPTIONS request for CORS preflight - if request.method == 'OPTIONS': - response = jsonify({'status': 'ok'}) - response.headers.add('Access-Control-Allow-Origin', request.headers.get('Origin', '*')) - response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization') - response.headers.add('Access-Control-Allow-Methods', 'GET,OPTIONS') - response.headers.add('Access-Control-Allow-Credentials', 'true') - return response - - try: - # Check if user is authenticated - if not current_user.is_authenticated: - response = jsonify({'error': 'Authentication required'}) - response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') - response.headers['Access-Control-Allow-Credentials'] = 'true' - return response, 401 - - # Re-fetch the current user from the database to ensure it's attached to the session - user = User.query.get(current_user.id) - if not user: - current_app.logger.error(f"Could not find user with ID {current_user.id}") - response = jsonify({'error': 'User not found'}) - response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') - response.headers['Access-Control-Allow-Credentials'] = 'true' - return response, 404 - - try: - # Use the existing to_dict method to serialize user data - if hasattr(user, 'to_dict'): - # Refresh the user object to ensure all relationships are loaded - db.session.refresh(user) - profile_data = user.to_dict() - else: - # Fallback for SimpleUser or other user types - profile_data = { - 'id': user.id, - 'email': getattr(user, 'email', ''), - 'first_name': getattr(user, 'first_name', ''), - 'last_name': getattr(user, 'last_name', ''), - 'name': f"{getattr(user, 'first_name', '')} {getattr(user, 'last_name', '')}".strip(), - 'role': getattr(user, 'role', 'user'), - 'is_active': getattr(user, 'is_active', True), - 'is_verified': getattr(user, 'is_verified', False), - 'phone_number': getattr(user, 'phone_number', ''), - 'location': getattr(user, 'location', ''), - 'professional_summary': getattr(user, 'professional_summary', ''), - 'linkedin_url': getattr(user, 'linkedin_url', ''), - 'github_url': getattr(user, 'github_url', ''), - 'portfolio_url': getattr(user, 'portfolio_url', ''), - 'desired_job_titles': getattr(user, 'desired_job_titles', ''), - 'work_mode_preference': getattr(user, 'work_mode_preference', ''), - 'min_salary_hourly': getattr(user, 'min_salary_hourly', None), - 'created_at': getattr(user, 'created_at', dt.utcnow()).isoformat() if hasattr(user, 'created_at') else dt.utcnow().isoformat(), - 'updated_at': getattr(user, 'updated_at', dt.utcnow()).isoformat() if hasattr(user, 'updated_at') else dt.utcnow().isoformat(), - # Initialize empty relationships - 'skills': [], - 'languages': [], - 'certifications': [], - 'experiences': [], - 'projects': [], - 'portfolio_links': [], - 'demographic': None, - 'military_info': None, - 'applicant_value_entries': [], - 'job_title_entries': [], - 'assigned_users': [], - 'subscription_history': [], - 'applications': [], - 'orders': [] - } - - # Calculate profile completion data - completion_data = calculate_profile_completion(user) - profile_data.update(completion_data) - - except Exception as dict_error: - current_app.logger.error(f"Error serializing user data: {str(dict_error)}") - current_app.logger.error(traceback.format_exc()) - response = jsonify({ - 'success': False, - 'message': 'Error serializing profile data', - 'error_details': str(dict_error) - }) - response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') - response.headers['Access-Control-Allow-Credentials'] = 'true' - return response, 500 - - # Return JSON with successful status - response = jsonify(profile_data) - response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') - response.headers['Access-Control-Allow-Credentials'] = 'true' - return response, 200 - - except Exception as e: - current_app.logger.error(f"Error getting profile data: {str(e)}") - current_app.logger.error(traceback.format_exc()) - response = jsonify({ - 'success': False, - 'message': f'Error fetching profile: {str(e)}', - 'error_details': traceback.format_exc() - }) - response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') - response.headers['Access-Control-Allow-Credentials'] = 'true' - return response, 500 - - -@profile_bp.route('/api', methods=['POST', 'OPTIONS']) -@login_required -def api_update_profile(): - """API endpoint to update the current user's profile from the React frontend""" - # Handle OPTIONS request for CORS preflight - if request.method == 'OPTIONS': - response = jsonify({'status': 'ok'}) - response.headers.add('Access-Control-Allow-Origin', request.headers.get('Origin', '*')) - response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization') - response.headers.add('Access-Control-Allow-Methods', 'POST,OPTIONS') - response.headers.add('Access-Control-Allow-Credentials', 'true') - return response - - # Process POST request to update profile - try: - if not request.is_json: - return jsonify({'error': 'Request must be JSON'}), 400 - - # Re-fetch the current user from the database to ensure it's attached to the session - user = User.query.get(current_user.id) - if not user: - current_app.logger.error(f"Could not find user with ID {current_user.id}") - return jsonify({'error': 'User not found'}), 404 - - data = request.json - current_app.logger.debug(f"API profile update received with {len(data.keys()) if data else 0} fields") - - # Filter out readonly fields to prevent errors - readonly_fields = { - 'id', 'created_at', 'updated_at', 'is_active', 'is_verified', - 'last_login', 'role', 'applications', 'orders', 'subscription_history', - 'experiences', 'projects', 'assigned_users', 'completion_percentage', - 'group_completions', 'resume', 'resume_file_path', 'resume_filename', - 'resume_mime_type', 'resume_url' - } - - # Create filtered data without readonly fields - filtered_data = {k: v for k, v in data.items() if k not in readonly_fields} - current_app.logger.debug(f"Filtered data to {len(filtered_data.keys())} safe fields") - - # Update basic fields - update_user_basic_fields(user, filtered_data) - - # Update relationships - for relationship in ['skills', 'certifications', 'languages', 'job_titles']: - if relationship in filtered_data or f'desired_{relationship}' in filtered_data: - update_user_relationships(user, filtered_data, relationship) - - # Update JSON fields - update_user_json_fields(user, filtered_data) - - # Process date fields - date_fields = ['available_start_date', 'graduation_date', 'military_discharge_date'] - for date_field in date_fields: - if date_field in filtered_data: - if filtered_data[date_field]: - try: - date_value = dt.fromisoformat( - filtered_data[date_field].replace('Z', '+00:00') - ).date() - setattr(user, date_field, date_value) - except ValueError as e: - current_app.logger.warning( - f"Invalid {date_field} format: {filtered_data[date_field]}. Error: {str(e)}" - ) - else: - setattr(user, date_field, None) - - # Save all changes to database - db.session.commit() - - current_app.logger.info(f"Profile updated successfully for user {user.id}") - - # Return updated profile data - db.session.refresh(user) # Refresh to ensure all relationships are loaded - return jsonify({ - 'success': True, - 'message': 'Profile updated successfully', - 'user': user.to_dict() - }), 200 - - except Exception as e: - current_app.logger.error(f"Error updating profile: {str(e)}") - current_app.logger.error(traceback.format_exc()) - db.session.rollback() - return jsonify({ - 'success': False, - 'message': f'Error updating profile: {str(e)}', - 'error_details': traceback.format_exc() - }), 500 - - -@profile_bp.route('/profile/jobs', methods=['GET']) -@login_required -def profile_jobs(): - """View jobs relevant to the user's profile""" - try: - # Instead of rendering a template, redirect to the React frontend's job route - return redirect('/jobs/recommendations') - except Exception as e: - current_app.logger.error(f"Error redirecting to jobs: {str(e)}") - flash(f"Error loading job recommendations: {str(e)}", "danger") - return redirect(url_for('profile.profile')) - - -def extract_text_from_resume(file_path): - """Extract text from various file formats.""" - try: - file_ext = os.path.splitext(file_path)[1].lower() - - if file_ext == '.pdf': - with open(file_path, 'rb') as file: - reader = PyPDF2.PdfReader(file) - text = '' - for page_num in range(len(reader.pages)): - text += reader.pages[page_num].extract_text() - return text - - elif file_ext in ['.docx', '.doc']: - text = docx2txt.process(file_path) - return text - - elif file_ext == '.txt': - with open(file_path, 'r', encoding='utf-8') as file: - return file.read() - - else: - return "Unsupported file format." - except Exception as e: - current_app.logger.error(f"Error extracting text from resume: {str(e)}") - return f"Error processing file: {str(e)}" - - -def process_resume_file(file): - """Process an uploaded resume file, extract text, and auto-fill fields.""" - # Use imported parse_pdf function - - filename = secure_filename(file.filename) - timestamp = dt.now().strftime("%Y%m%d_%H%M%S") - unique_filename = f"{timestamp}_{filename}" - - # Ensure upload folder exists with proper error handling - upload_folder = current_app.config.get('UPLOAD_FOLDER', 'uploads') - if not os.path.isabs(upload_folder): - upload_folder = os.path.join(current_app.root_path, upload_folder) - - try: - os.makedirs(upload_folder, exist_ok=True) - # Test write permissions - test_file = os.path.join(upload_folder, 'test_write.tmp') - with open(test_file, 'w') as f: - f.write('test') - os.remove(test_file) - current_app.logger.info(f"Upload folder verified: {upload_folder}") - except Exception as e: - current_app.logger.error(f"Upload folder creation/permission error: {str(e)}") - # Fallback to a temporary directory - import tempfile - upload_folder = tempfile.mkdtemp() - current_app.logger.info(f"Using temporary upload folder: {upload_folder}") - - file_path = os.path.join(upload_folder, unique_filename) - - try: - file.save(file_path) - current_app.logger.info(f"File saved successfully: {file_path}") - except Exception as e: - current_app.logger.error(f"Error saving file: {str(e)}") - raise e - - # Extract text from the file - resume_text = extract_text_from_resume(file_path) - - # Parse resume using the parse_pdf function that uses Gemini first - parsed_data = parse_pdf(file_path) - current_app.logger.info(f"Parsed resume data: {parsed_data}") - try: - # Add detailed debugging for parsed data - current_app.logger.info(f"DEBUG: Parsed data keys: {list(parsed_data.keys()) if parsed_data else 'None'}") - if parsed_data: - current_app.logger.info(f"DEBUG: Skills found: {parsed_data.get('skills', [])}") - current_app.logger.info(f"DEBUG: Experience found: {len(parsed_data.get('experience', []))} entries") - current_app.logger.info(f"DEBUG: Projects found: {len(parsed_data.get('projects', []))} entries") - current_app.logger.info(f"DEBUG: Name found: {parsed_data.get('name')}") - else: - current_app.logger.warning("DEBUG: No parsed data returned from parse_pdf") - - # Basic fields - if parsed_data.get("name"): - full_name = parsed_data["name"] - if " " in full_name: - name_parts = full_name.split() - current_user.first_name = name_parts[0] - current_user.last_name = " ".join(name_parts[1:]) - else: - current_user.first_name = full_name - - if parsed_data.get("professional_summary"): - current_user.professional_summary = parsed_data["professional_summary"] - - if parsed_data.get("phone"): - current_user.phone_number = parsed_data["phone"] - - if parsed_data.get("location"): - current_user.location = parsed_data["location"] - - if parsed_data.get("linkedin"): - current_user.linkedin_url = parsed_data["linkedin"] - - if parsed_data.get("github"): - current_user.github_url = parsed_data["github"] - - # Convert job_titles list to JSON string for database storage - if parsed_data.get("job_titles"): - current_user.desired_job_titles = json.dumps(parsed_data["job_titles"]) - - # Store the resume content - current_user.resume = resume_text - current_user.resume_file_path = file_path - current_user.resume_filename = filename - - # Handle skills properly through the relationship - if parsed_data.get("skills"): - # Clear existing skills using direct database deletion - db.session.execute( - text("DELETE FROM user_skills WHERE user_id = :user_id"), - {"user_id": current_user.id} - ) - db.session.flush() - - # Add each skill through the relationship - for skill_name in parsed_data["skills"]: - # Try to find existing skill - skill = Skill.query.filter_by(name=skill_name).first() - if not skill: - # Create new skill if it doesn't exist - skill = Skill(name=skill_name) - db.session.add(skill) - db.session.flush() # Flush to get the ID - # Add skill to user's skills using direct insertion - db.session.execute( - text("INSERT OR IGNORE INTO user_skills (user_id, skill_id) VALUES (:user_id, :skill_id)"), - {"user_id": current_user.id, "skill_id": skill.id} - ) - - # Handle certifications properly through the relationship - if parsed_data.get("certifications"): - # Clear existing certifications using direct database deletion - db.session.execute( - text("DELETE FROM user_certifications WHERE user_id = :user_id"), - {"user_id": current_user.id} - ) - db.session.flush() - - # Add each certification through the relationship - for cert_name in parsed_data["certifications"]: - # Try to find existing certification - cert = Certification.query.filter_by(name=cert_name).first() - if not cert: - # Create new certification if it doesn't exist - cert = Certification(name=cert_name) - db.session.add(cert) - db.session.flush() # Flush to get the ID - # Add certification to user's certifications using direct insertion - db.session.execute( - text("INSERT OR IGNORE INTO user_certifications (user_id, certification_id) VALUES (:user_id, :certification_id)"), - {"user_id": current_user.id, "certification_id": cert.id} - ) - - # Handle languages properly through the relationship - if parsed_data.get("languages"): - # Clear existing languages using direct database deletion - db.session.execute( - text("DELETE FROM user_languages WHERE user_id = :user_id"), - {"user_id": current_user.id} - ) - db.session.flush() - - # Add each language through the relationship - for lang_name in parsed_data["languages"]: - # Try to find existing language - lang = Language.query.filter_by(name=lang_name).first() - if not lang: - # Create new language if it doesn't exist - lang = Language(name=lang_name) - db.session.add(lang) - db.session.flush() # Flush to get the ID - # Add language to user's languages using direct insertion - db.session.execute( - text("INSERT OR IGNORE INTO user_languages (user_id, language_id) VALUES (:user_id, :language_id)"), - {"user_id": current_user.id, "language_id": lang.id} - ) - - # Process experience field with proper structure - if parsed_data.get("experience") and isinstance(parsed_data["experience"], list): - # Clear existing experiences using direct database deletion - db.session.execute( - text("DELETE FROM experiences WHERE user_id = :user_id"), - {"user_id": current_user.id} - ) - db.session.flush() - - for exp_data in parsed_data["experience"]: - if not isinstance(exp_data, dict): - continue - - # Parse start_date first - it's required - start_date = None - if "start_date" in exp_data: - start_date = parse_date(exp_data["start_date"]) - - # If start_date parsing failed, use a default or skip this experience - if not start_date: - current_app.logger.warning(f"Skipping experience due to invalid start_date: {exp_data}") - continue - - # Create a new Experience object with appropriate fields - experience = Experience( - user_id=current_user.id, - company_name=exp_data.get("company", "Unknown Company"), - position=exp_data.get("title", "Unknown Title"), - description=exp_data.get("description", ""), - location=exp_data.get("location", ""), - start_date=start_date # Ensure start_date is always set - ) - - # Parse end_date - if "end_date" in exp_data: - end_date_str = exp_data.get("end_date", "").lower() - if end_date_str in ["present", "current"]: - experience.is_current = True - else: - end_date = parse_date(exp_data["end_date"]) - if end_date: - experience.end_date = end_date - - db.session.add(experience) - current_app.logger.info(f"Added experience: {experience.company_name} - {experience.position}") - - # Process projects field with proper structure - if parsed_data.get("projects") and isinstance(parsed_data["projects"], list): - # Clear existing projects using direct database deletion - db.session.execute( - text("DELETE FROM projects WHERE user_id = :user_id"), - {"user_id": current_user.id} - ) - db.session.flush() - - for proj_data in parsed_data["projects"]: - if not isinstance(proj_data, dict): - continue - - # Create a new Project object with appropriate fields - project = Project( - user_id=current_user.id, - name=proj_data.get("name", "Unknown Project"), - description=proj_data.get("description", ""), - url=proj_data.get("url", "") - ) - - # Handle technologies array - if "technologies" in proj_data: - if isinstance(proj_data["technologies"], list): - project.technologies = proj_data["technologies"] - elif isinstance(proj_data["technologies"], str): - # Split comma-separated technologies - project.technologies = [tech.strip() for tech in proj_data["technologies"].split(",") if tech.strip()] - - db.session.add(project) - current_app.logger.info(f"Added project: {project.name}") - - # Handle portfolio links from resume data if available - if parsed_data.get("portfolio_links") and isinstance(parsed_data["portfolio_links"], list): - # Clear existing portfolio links using direct database deletion - db.session.execute( - text("DELETE FROM portfolio_links WHERE user_id = :user_id"), - {"user_id": current_user.id} - ) - db.session.flush() - - for link_data in parsed_data["portfolio_links"]: - if not isinstance(link_data, dict) or not link_data.get("url"): - continue - - portfolio_link = PortfolioLink( - user_id=current_user.id, - platform=link_data.get("platform", "Website"), - url=link_data.get("url", ""), - description=link_data.get("description", "") - ) - - db.session.add(portfolio_link) - - # Handle education field separately - if parsed_data.get("education") and isinstance(parsed_data["education"], list): - # Store education JSON in the appropriate field - current_user._education = json.dumps(parsed_data["education"]) - - # Handle values and applicant_values properly through relationships - if parsed_data.get("values") and isinstance(parsed_data["values"], list): - # Clear existing values - current_user.applicant_value_entries = [] - - # Add each value as a proper applicant value entry - for i, value_item in enumerate(parsed_data["values"]): - if isinstance(value_item, dict): - # If it's already a structured dict with category and value - category = value_item.get("category", "General") - value = value_item.get("value") - priority = value_item.get("priority", i+1) - elif isinstance(value_item, str): - # If it's just a string, use a default category - category = "Values" - value = value_item - priority = i+1 - else: - continue - - if value: - applicant_value = ApplicantValue( - user_id=current_user.id, - category=category, - value=value, - priority=priority - ) - db.session.add(applicant_value) - current_user.applicant_value_entries.append(applicant_value) - - # Also handle applicant_values field directly if it exists in parsed_data - if parsed_data.get("applicant_values") and isinstance(parsed_data["applicant_values"], list) and not current_user.applicant_value_entries: - # Only process this if we don't already have values from the "values" field - # Clear existing values if we haven't already - current_user.applicant_value_entries = [] - - # Add each value as a proper applicant value entry - for i, value_item in enumerate(parsed_data["applicant_values"]): - if isinstance(value_item, dict): - category = value_item.get("category", "General") - value = value_item.get("value") - priority = value_item.get("priority", i+1) - elif isinstance(value_item, str): - category = "Values" - value = value_item - priority = i+1 - else: - continue - - if value: - applicant_value = ApplicantValue( - user_id=current_user.id, - category=category, - value=value, - priority=priority - ) - db.session.add(applicant_value) - current_user.applicant_value_entries.append(applicant_value) - # Handle work preferences and other fields - if parsed_data.get("work_mode_preference"): - work_mode = parsed_data["work_mode_preference"] - # Normalize work mode preference to standard values - if isinstance(work_mode, str): - work_mode = work_mode.lower() - if "remote" in work_mode: - current_user.work_mode_preference = "Remote" - elif "hybrid" in work_mode: - current_user.work_mode_preference = "Hybrid" - elif "office" in work_mode or "onsite" in work_mode or "on-site" in work_mode: - current_user.work_mode_preference = "In-office" - else: - current_user.work_mode_preference = work_mode.capitalize() - else: - current_user.work_mode_preference = str(work_mode) - - # Handle relocation preference if specified - if parsed_data.get("willing_to_relocate") is not None: - if isinstance(parsed_data["willing_to_relocate"], bool): - current_user.willing_to_relocate = parsed_data["willing_to_relocate"] - elif isinstance(parsed_data["willing_to_relocate"], str): - willing = parsed_data["willing_to_relocate"].lower() - current_user.willing_to_relocate = willing in ["yes", "true", "y", "1", "willing"] - - # Handle other user profile fields - if parsed_data.get("career_goals"): - current_user.career_goals = parsed_data["career_goals"] - if parsed_data.get("biggest_achievement"): - current_user.biggest_achievement = parsed_data["biggest_achievement"] - if parsed_data.get("work_style"): - current_user.work_style = parsed_data["work_style"] - if parsed_data.get("industry_attraction"): - current_user.industry_attraction = parsed_data["industry_attraction"] - - # Commit changes to database - try: - db.session.commit() - except Exception as e: - current_app.logger.error(f"Error saving parsed resume data: {str(e)}") - db.session.rollback() - raise e - - return file_path, filename, resume_text - except Exception as e: - current_app.logger.error(f"Error processing resume: {str(e)}") - current_app.logger.error(traceback.format_exc()) - return None, None, None - -def calculate_profile_completion(user): - """Calculate profile completion percentage and group completions""" - group_completions = {} - total_fields = 0 - completed_fields = 0 - - # Basic Info Group (25% weight) - basic_info_fields = [ - ('first_name', user.first_name), - ('last_name', user.last_name), - ('email', user.email), - ('location', user.location), - ('phone_number', user.phone_number), - ('professional_summary', user.professional_summary) - ] - basic_info_completed = sum(1 for _, value in basic_info_fields if value and str(value).strip()) - basic_info_percentage = (basic_info_completed / len(basic_info_fields)) * 100 - group_completions['basic_info'] = round(basic_info_percentage) - total_fields += len(basic_info_fields) - completed_fields += basic_info_completed - - # Skills & Experience Group (25% weight) - skills_experience_fields = [ - ('skills', user.skills.count() if hasattr(user.skills, 'count') else 0), - ('experiences', user.experiences.count() if hasattr(user.experiences, 'count') else 0), - ('projects', user.projects.count() if hasattr(user.projects, 'count') else 0), - ('certifications', user.certifications.count() if hasattr(user.certifications, 'count') else 0) - ] - skills_experience_completed = sum(1 for _, value in skills_experience_fields if value > 0) - skills_experience_percentage = (skills_experience_completed / len(skills_experience_fields)) * 100 - group_completions['skills_experience'] = round(skills_experience_percentage) - total_fields += len(skills_experience_fields) - completed_fields += skills_experience_completed - - # Resume Group (20% weight) - resume_fields = [ - ('resume', user.resume), - ('resume_file_path', user.resume_file_path), - ('resume_url', user.resume_url) - ] - resume_completed = sum(1 for _, value in resume_fields if value and str(value).strip()) - resume_percentage = (resume_completed / len(resume_fields)) * 100 - group_completions['resume'] = round(resume_percentage) - total_fields += len(resume_fields) - completed_fields += resume_completed - - # Work Preferences Group (15% weight) - work_preferences_fields = [ - ('desired_job_titles', user.desired_job_titles), - ('work_mode_preference', user.work_mode_preference), - ('min_salary_hourly', user.min_salary_hourly) - ] - work_preferences_completed = sum(1 for _, value in work_preferences_fields if value and str(value).strip()) - work_preferences_percentage = (work_preferences_completed / len(work_preferences_fields)) * 100 - group_completions['work_preferences'] = round(work_preferences_percentage) - total_fields += len(work_preferences_fields) - completed_fields += work_preferences_completed - - # Additional Qualifications Group (10% weight) - additional_qualifications_fields = [ - ('languages', user.languages.count() if hasattr(user.languages, 'count') else 0), - ('portfolio_links', user.portfolio_links.count() if hasattr(user.portfolio_links, 'count') else 0), - ('linkedin_url', user.linkedin_url), - ('github_url', user.github_url), - ('portfolio_url', user.portfolio_url) - ] - additional_qualifications_completed = sum(1 for _, value in additional_qualifications_fields if value and str(value).strip()) - additional_qualifications_percentage = (additional_qualifications_completed / len(additional_qualifications_fields)) * 100 - group_completions['additional_qualifications'] = round(additional_qualifications_percentage) - total_fields += len(additional_qualifications_fields) - completed_fields += additional_qualifications_completed - - # Professional Details Group (5% weight) - professional_details_fields = [ - ('demographic', user.demographic is not None), - ('military_info', user.military_info is not None), - ('applicant_value_entries', user.applicant_value_entries.count() if hasattr(user.applicant_value_entries, 'count') else 0) - ] - professional_details_completed = sum(1 for _, value in professional_details_fields if value and str(value).strip()) - professional_details_percentage = (professional_details_completed / len(professional_details_fields)) * 100 - group_completions['professional_details'] = round(professional_details_percentage) - total_fields += len(professional_details_fields) - completed_fields += professional_details_completed - - # Calculate overall completion percentage - overall_percentage = (completed_fields / total_fields) * 100 if total_fields > 0 else 0 - - return { - 'completion_percentage': round(overall_percentage), - 'group_completions': group_completions - } - -@profile_bp.route('/resume/upload-with-keywords', methods=['POST']) -@login_required -def upload_resume_with_keywords(): - """Upload resume and extract keywords""" - try: - if 'resume' not in request.files: - return jsonify({'error': 'No file uploaded'}), 400 - - file = request.files['resume'] - if file.filename == '': - return jsonify({'error': 'No file selected'}), 400 - - # Save file temporarily and parse content - filename = secure_filename(file.filename) - temp_path = os.path.join(current_app.config['UPLOAD_FOLDER'], filename) - file.save(temp_path) - - # Parse resume content based on file type - if filename.lower().endswith('.pdf'): - resume_data = parse_pdf(temp_path) - resume_text = resume_data.get('text', '') or str(resume_data) - elif filename.lower().endswith('.docx'): - from backend.utils.document_parser import parse_docx - resume_text = parse_docx(temp_path) - else: - resume_text = file.read().decode('utf-8') - - # Clean up temp file - os.remove(temp_path) - - # Update user's resume - current_user.resume = resume_text - current_user.resume_filename = filename - current_user.resume_mime_type = file.content_type - - # Extract keywords from resume - current_app.logger.debug(f"[KEYWORD EXTRACTION] Raw resume text (first 300 chars): {resume_text[:300] if resume_text else 'None'}") - extraction_result = resume_service.extract_keywords_from_resume( - user_id=current_user.id, - resume_text=resume_text - ) - current_app.logger.debug(f"[KEYWORD EXTRACTION] Extracted {extraction_result['keywords_extracted']} keywords: {extraction_result['keywords']}") - try: - current_app.logger.info(f"[KEYWORD EXTRACTION] Extracted {extraction_result['keywords_extracted']} keywords from resume for user {current_user.id}") - current_app.logger.info(f"[KEYWORD EXTRACTION] Keywords: {extraction_result['keywords']}") - except Exception as e: - current_app.logger.error(f"[KEYWORD EXTRACTION ERROR] {str(e)}") - # Continue despite keyword extraction errors - - db.session.commit() - - return jsonify({ - 'success': True, - 'message': 'Resume uploaded and keywords extracted successfully', - 'keywords_extracted': extraction_result['keywords_extracted'], - 'keywords': extraction_result['keywords'] - }) - - except Exception as e: - current_app.logger.error(f"Resume upload error: {str(e)}") - db.session.rollback() - return jsonify({'error': str(e)}), 500 - -@profile_bp.route('/keywords', methods=['GET']) -@login_required -def get_user_keywords(): - """Get keywords extracted from user's resume""" - try: - keywords = resume_service.get_user_keywords(current_user.id) - return jsonify({ - 'success': True, - 'keywords': keywords - }) - except Exception as e: - current_app.logger.error(f"Error getting user keywords: {str(e)}") - return jsonify({'error': str(e)}), 500 - -@profile_bp.route('/keywords/statistics', methods=['GET']) -@login_required -def get_keyword_statistics(): - """Get keyword database statistics""" - try: - stats = resume_service.get_keyword_statistics() - return jsonify({ - 'success': True, - 'statistics': stats - }) - except Exception as e: - current_app.logger.error(f"Error getting keyword statistics: {str(e)}") - return jsonify({'error': str(e)}), 500 - -@profile_bp.route('/keywords/add', methods=['POST']) -@login_required -def add_user_keyword(): - """Add a new keyword to user's profile""" - try: - data = request.get_json() - keyword_text = data.get('keyword', '').strip() - category = data.get('category', 'skill') - - if not keyword_text: - return jsonify({'error': 'Keyword is required'}), 400 - - # Add keyword using the service - result = resume_service.add_user_keyword( - user_id=current_user.id, - keyword=keyword_text, - category=category - ) - - return jsonify({ - 'success': True, - 'message': 'Keyword added successfully', - 'keyword': result - }) - except Exception as e: - current_app.logger.error(f"Error adding user keyword: {str(e)}") - return jsonify({'error': str(e)}), 500 - -@profile_bp.route('/keywords/remove', methods=['POST']) -@login_required -def remove_user_keyword(): - """Remove a keyword from user's profile""" - try: - data = request.get_json() - keyword_id = data.get('keyword_id') - - if not keyword_id: - return jsonify({'error': 'Keyword ID is required'}), 400 - - # Remove keyword using the service - result = resume_service.remove_user_keyword( - user_id=current_user.id, - keyword_id=keyword_id - ) - - return jsonify({ - 'success': True, - 'message': 'Keyword removed successfully' - }) - except Exception as e: - current_app.logger.error(f"Error removing user keyword: {str(e)}") - return jsonify({'error': str(e)}), 500 - -@profile_bp.route('/keywords/update', methods=['POST']) -@login_required -def update_user_keyword(): - """Update a keyword in user's profile""" - try: - data = request.get_json() - keyword_id = data.get('keyword_id') - keyword_text = data.get('keyword', '').strip() - category = data.get('category', 'skill') - - if not keyword_id or not keyword_text: - return jsonify({'error': 'Keyword ID and keyword text are required'}), 400 - - # Update keyword using the service - result = resume_service.update_user_keyword( - user_id=current_user.id, - keyword_id=keyword_id, - keyword=keyword_text, - category=category - ) - - return jsonify({ - 'success': True, - 'message': 'Keyword updated successfully', - 'keyword': result - }) - except Exception as e: - current_app.logger.error(f"Error updating user keyword: {str(e)}") - return jsonify({'error': str(e)}), 500 \ No newline at end of file +""" +Profile routes - organized in subfolder structure. +This file imports from the profile package. +""" +from .profile import register_profile_routes + +# For backward compatibility +__all__ = ['register_profile_routes'] \ No newline at end of file diff --git a/backend/routes/profile/__init__.py b/backend/routes/profile/__init__.py new file mode 100644 index 00000000..8e20ca8e --- /dev/null +++ b/backend/routes/profile/__init__.py @@ -0,0 +1,32 @@ +""" +Profile routes package - organized profile functionality. +""" +from .main import profile_main_bp +from .resume import resume_bp +from .keywords import keyword_bp +from flask import Blueprint + + +def register_profile_routes(app): + """Register all profile-related routes with the Flask app""" + # Create the main profile blueprint + profile_bp = Blueprint('profile', __name__) + + # Register sub-blueprints with the main profile blueprint + profile_bp.register_blueprint(profile_main_bp) + profile_bp.register_blueprint(resume_bp) + profile_bp.register_blueprint(keyword_bp) + + # Register the main profile blueprint with the app + app.register_blueprint(profile_bp, url_prefix='/profile') + + # Also register with /api/profile prefix for API access + app.register_blueprint(profile_bp, url_prefix='/api/profile', name='api_profile') + + # Log registration + app.logger.info("Profile routes registered successfully") + app.logger.info(f"Profile module routes loaded: main, resume, keywords") + + +# For backward compatibility - export the main blueprint +__all__ = ['register_profile_routes'] \ No newline at end of file diff --git a/backend/routes/profile/keywords.py b/backend/routes/profile/keywords.py new file mode 100644 index 00000000..57044953 --- /dev/null +++ b/backend/routes/profile/keywords.py @@ -0,0 +1,162 @@ +""" +Keyword routes for managing user keywords and keyword statistics. +""" +import traceback +from flask import Blueprint, request, jsonify, current_app +from flask_login import login_required, current_user + +from models.db import db +from models.all_models import User +from models import JobKeyword +from services.resume_keyword_service import ResumeKeywordService + + +keyword_bp = Blueprint('keyword', __name__) +resume_service = ResumeKeywordService() + + +@keyword_bp.route('/keywords', methods=['GET']) +@login_required +def get_user_keywords(): + """Get all keywords for the current user""" + try: + keywords = JobKeyword.query.filter_by(user_id=current_user.id).all() + keyword_data = [{'id': k.id, 'keyword': k.keyword, 'frequency': k.frequency} for k in keywords] + return jsonify({'keywords': keyword_data}) + except Exception as e: + current_app.logger.error(f"Error fetching user keywords: {str(e)}") + return jsonify({'error': 'Failed to fetch keywords'}), 500 + + +@keyword_bp.route('/keywords/statistics', methods=['GET']) +@login_required +def get_keyword_statistics(): + """Get keyword statistics for the current user""" + try: + total_keywords = JobKeyword.query.filter_by(user_id=current_user.id).count() + top_keywords = JobKeyword.query.filter_by(user_id=current_user.id).order_by( + JobKeyword.frequency.desc()).limit(10).all() + + return jsonify({ + 'total_keywords': total_keywords, + 'top_keywords': [{'keyword': k.keyword, 'frequency': k.frequency} for k in top_keywords] + }) + except Exception as e: + current_app.logger.error(f"Error fetching keyword statistics: {str(e)}") + return jsonify({'error': 'Failed to fetch statistics'}), 500 + + +@keyword_bp.route('/keywords/add', methods=['POST']) +@login_required +def add_user_keyword(): + """Add a new keyword for the current user""" + try: + data = request.get_json() + keyword_text = data.get('keyword', '').strip() + + if not keyword_text: + return jsonify({'error': 'Keyword is required'}), 400 + + # Check if keyword already exists for this user + existing_keyword = JobKeyword.query.filter_by( + user_id=current_user.id, + keyword=keyword_text + ).first() + + if existing_keyword: + existing_keyword.frequency += 1 + db.session.commit() + return jsonify({ + 'success': True, + 'message': 'Keyword frequency updated', + 'keyword': {'id': existing_keyword.id, 'keyword': existing_keyword.keyword, 'frequency': existing_keyword.frequency} + }) + else: + new_keyword = JobKeyword( + user_id=current_user.id, + keyword=keyword_text, + frequency=1 + ) + db.session.add(new_keyword) + db.session.commit() + return jsonify({ + 'success': True, + 'message': 'Keyword added successfully', + 'keyword': {'id': new_keyword.id, 'keyword': new_keyword.keyword, 'frequency': new_keyword.frequency} + }) + except Exception as e: + current_app.logger.error(f"Error adding keyword: {str(e)}") + db.session.rollback() + return jsonify({'error': 'Failed to add keyword'}), 500 + + +@keyword_bp.route('/keywords/remove', methods=['POST']) +@login_required +def remove_user_keyword(): + """Remove a keyword for the current user""" + try: + data = request.get_json() + keyword_id = data.get('keyword_id') + + if not keyword_id: + return jsonify({'error': 'Keyword ID is required'}), 400 + + keyword = JobKeyword.query.filter_by( + id=keyword_id, + user_id=current_user.id + ).first() + + if not keyword: + return jsonify({'error': 'Keyword not found'}), 404 + + db.session.delete(keyword) + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'Keyword removed successfully' + }) + except Exception as e: + current_app.logger.error(f"Error removing keyword: {str(e)}") + db.session.rollback() + return jsonify({'error': 'Failed to remove keyword'}), 500 + + +@keyword_bp.route('/keywords/update', methods=['POST']) +@login_required +def update_user_keyword(): + """Update a keyword for the current user""" + try: + data = request.get_json() + keyword_id = data.get('keyword_id') + new_keyword_text = data.get('keyword', '').strip() + new_frequency = data.get('frequency') + + if not keyword_id: + return jsonify({'error': 'Keyword ID is required'}), 400 + + keyword = JobKeyword.query.filter_by( + id=keyword_id, + user_id=current_user.id + ).first() + + if not keyword: + return jsonify({'error': 'Keyword not found'}), 404 + + # Update fields if provided + if new_keyword_text: + keyword.keyword = new_keyword_text + if new_frequency is not None: + keyword.frequency = new_frequency + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'Keyword updated successfully', + 'keyword': {'id': keyword.id, 'keyword': keyword.keyword, 'frequency': keyword.frequency} + }) + except Exception as e: + current_app.logger.error(f"Error updating keyword: {str(e)}") + db.session.rollback() + return jsonify({'error': 'Failed to update keyword'}), 500 \ No newline at end of file diff --git a/backend/routes/profile/main.py b/backend/routes/profile/main.py new file mode 100644 index 00000000..5c732de5 --- /dev/null +++ b/backend/routes/profile/main.py @@ -0,0 +1,197 @@ +""" +Main profile routes for user profile management. +""" +import json +import traceback +from flask import Blueprint, request, jsonify, current_app, abort +from flask_login import login_required, current_user +from sqlalchemy.orm.exc import DetachedInstanceError + +from models.db import db +from models.all_models import User, Experience, Project +from utils.profile_utils import ( + update_user_basic_fields, + update_user_json_fields, + update_user_relationships, + calculate_profile_completion, + is_relationship_field +) + + +profile_main_bp = Blueprint('profile_main', __name__) + + +@profile_main_bp.route('', methods=['GET', 'POST', 'OPTIONS']) +@profile_main_bp.route('/', methods=['GET', 'POST', 'OPTIONS']) +def profile(): + """Handle profile requests - serve React frontend for browser, API for AJAX""" + # Handle OPTIONS request for CORS preflight + if request.method == 'OPTIONS': + response = jsonify({'status': 'ok'}) + response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') + response.headers['Access-Control-Allow-Headers'] = 'Content-Type,Authorization' + response.headers['Access-Control-Allow-Methods'] = 'GET,POST,OPTIONS' + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response + + # For GET requests, check if it's an API request (AJAX call) or a browser request (HTML) + if request.method == 'GET': + # Check if this is an API request (AJAX call from React frontend) + is_ajax = ( + request.headers.get('Content-Type', '').startswith('application/json') or + request.headers.get('Accept', '').find('application/json') != -1 or + request.headers.get('X-Requested-With') == 'XMLHttpRequest' or + request.args.get('format') == 'json' + ) + + if is_ajax: + # Return JSON data for API requests - call the API function directly + return api_get_profile() + else: + # For direct browser access, serve the React frontend + # This will be handled by the main app's catch-all route + # We need to not handle this route for browser requests + abort(404) # Let the main app's catch-all route handle this + + # For POST requests, call the API update function directly + if request.method == 'POST': + return api_update_profile() + + return jsonify({'error': 'Method not allowed'}), 405 + + +@profile_main_bp.route('/api', methods=['GET', 'OPTIONS']) +@login_required +def api_get_profile(): + """API endpoint to get user profile data""" + # Handle OPTIONS request for CORS preflight + if request.method == 'OPTIONS': + response = jsonify({'status': 'ok'}) + response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') + response.headers['Access-Control-Allow-Headers'] = 'Content-Type,Authorization' + response.headers['Access-Control-Allow-Methods'] = 'GET,OPTIONS' + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response + + try: + # Re-attach current_user to the current session to avoid DetachedInstanceError + user = User.query.get(current_user.id) + if not user: + return jsonify({'error': 'User not found'}), 404 + + # Get related data + experiences = Experience.query.filter_by(user_id=user.id).all() + projects = Project.query.filter_by(user_id=user.id).all() + + # Convert user to dictionary + profile_data = user.to_dict() + + # Add relationships data + profile_data['experiences'] = [exp.to_dict() for exp in experiences] + profile_data['projects'] = [proj.to_dict() for proj in projects] + + # Calculate profile completion + profile_data['completion_percentage'] = calculate_profile_completion(user) + + # Add cache-busting timestamp + profile_data['last_updated'] = user.updated_at.isoformat() if user.updated_at else None + + response = jsonify({ + 'success': True, + 'profile': profile_data + }) + response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response + + except DetachedInstanceError: + current_app.logger.warning("DetachedInstanceError in api_get_profile - refreshing user session") + try: + db.session.add(current_user) + db.session.refresh(current_user) + return api_get_profile() # Retry + except Exception as e: + current_app.logger.error(f"Error refreshing user session: {str(e)}") + return jsonify({'error': 'Session error'}), 500 + except Exception as e: + current_app.logger.error(f"Error in api_get_profile: {str(e)}") + current_app.logger.error(traceback.format_exc()) + return jsonify({'error': 'Failed to load profile'}), 500 + + +@profile_main_bp.route('/api', methods=['POST', 'OPTIONS']) +@login_required +def api_update_profile(): + """API endpoint to update user profile""" + # Handle OPTIONS request for CORS preflight + if request.method == 'OPTIONS': + response = jsonify({'status': 'ok'}) + response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') + response.headers['Access-Control-Allow-Headers'] = 'Content-Type,Authorization' + response.headers['Access-Control-Allow-Methods'] = 'POST,OPTIONS' + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response + + try: + data = request.get_json() + if not data: + return jsonify({'success': False, 'error': 'No data provided'}), 400 + + # Re-fetch the current user from the database to ensure it's attached to the session + user = User.query.get(current_user.id) + if not user: + current_app.logger.error(f"Could not find user with ID {current_user.id}") + return jsonify({'success': False, 'error': 'User not found'}), 404 + + # Update different types of fields + update_user_basic_fields(user, data) + update_user_json_fields(user, data) + + # Handle relationship fields + if 'experience' in data: + update_user_relationships(user, data, 'experiences') + if 'projects' in data: + update_user_relationships(user, data, 'projects') + + # Commit changes + db.session.commit() + current_app.logger.info(f"Successfully updated profile for user {user.id}") + + response = jsonify({ + 'success': True, + 'message': 'Profile updated successfully' + }) + response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response + + except Exception as e: + current_app.logger.error(f"Error in api_update_profile: {str(e)}") + current_app.logger.error(traceback.format_exc()) + db.session.rollback() + return jsonify({'success': False, 'error': f'Failed to update profile: {str(e)}'}), 500 + + +@profile_main_bp.route('/update', methods=['POST', 'OPTIONS']) +@login_required +def update_profile(): + """Legacy profile update endpoint - redirects to API endpoint""" + return api_update_profile() + + +@profile_main_bp.route('/profile/jobs', methods=['GET']) +@login_required +def profile_jobs(): + """Get jobs related to user's profile""" + try: + # This could be expanded to show recommended jobs based on profile + response = jsonify({ + 'success': True, + 'jobs': [] # Placeholder for job recommendations + }) + response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response + except Exception as e: + current_app.logger.error(f"Error getting profile jobs: {str(e)}") + return jsonify({'success': False, 'error': 'Failed to get jobs'}), 500 \ No newline at end of file diff --git a/backend/routes/profile/resume.py b/backend/routes/profile/resume.py new file mode 100644 index 00000000..73bab75f --- /dev/null +++ b/backend/routes/profile/resume.py @@ -0,0 +1,320 @@ +""" +Resume routes for file upload, processing, and keyword extraction. +""" +import os +import traceback +from flask import Blueprint, request, jsonify, current_app, session +from flask_login import login_required, current_user +from werkzeug.utils import secure_filename + +from models.db import db +from models.all_models import User +from forms.profile import ProfileForm +from utils.resume_utils import ( + process_resume_file, + extract_text_from_resume, + extract_keywords_from_resume +) +from utils.document_parser import parse_pdf + + +resume_bp = Blueprint('resume', __name__) + + +@resume_bp.route('/upload-resume', methods=['GET', 'POST']) +@login_required +def upload_resume(): + """Handle resume upload via API for React frontend.""" + form = ProfileForm() + + # For POST requests with file uploads + if request.method == 'POST': + if 'resume_file' in request.files: + file = request.files['resume_file'] + if file and file.filename != '': + try: + # Process the resume file + file_path, filename, resume_text = process_resume_file(file) + + if file_path: + # Update user's resume information + current_user.resume_filename = filename + current_user.resume_file_path = file_path + if resume_text: + current_user.resume = resume_text + + # Save changes to database + db.session.commit() + + # Extract keywords from resume and save to keyword database + current_app.logger.debug(f"[KEYWORD EXTRACTION] Raw resume text (first 300 chars): {resume_text[:300] if resume_text else 'None'}") + extraction_result = extract_keywords_from_resume( + user_id=current_user.id, + resume_text=resume_text + ) + current_app.logger.debug(f"[KEYWORD EXTRACTION] Extracted {extraction_result['keywords_extracted']} keywords: {extraction_result['keywords']}") + try: + current_app.logger.info(f"[KEYWORD EXTRACTION] Extracted {extraction_result['keywords_extracted']} keywords from resume for user {current_user.id}") + current_app.logger.info(f"[KEYWORD EXTRACTION] Keywords: {extraction_result['keywords']}") + except Exception as e: + current_app.logger.error(f"[KEYWORD EXTRACTION ERROR] {str(e)}") + # Continue despite keyword extraction errors + + # Return success JSON response + return jsonify({ + 'success': True, + 'message': 'Resume uploaded and parsed successfully. Please review your profile information.', + 'fileInfo': { + 'filename': filename, + 'parsedData': parse_pdf(file_path) # Include parsed data in the response + } + }), 200 + except Exception as e: + current_app.logger.error(f"Error in resume upload: {str(e)}") + return jsonify({ + 'success': False, + 'message': f'Error processing resume: {str(e)}' + }), 400 + else: + return jsonify({ + 'success': False, + 'message': 'No resume file selected.' + }), 400 + else: + return jsonify({ + 'success': False, + 'message': 'No resume file provided in request' + }), 400 + + # Skip the resume upload if requested + if request.args.get('skip') == 'true': + session['skip_resume_upload'] = True + return jsonify({ + 'success': True, + 'redirect': '/profile' + }), 200 + + # For GET requests, return form config instead of rendering a template + form_config = { + 'csrf_token': form.csrf_token.data, + 'resume_field': { + 'label': 'Upload Resume', + 'description': 'Upload your resume (PDF, DOCX, or TXT)', + 'required': True, + 'accepted_types': '.pdf,.docx,.doc,.txt' + } + } + + return jsonify({ + 'success': True, + 'form_config': form_config + }), 200 + + +@resume_bp.route('/resume', methods=['POST']) +@login_required +def api_upload_resume(): + """API endpoint for resume uploads from the React frontend""" + try: + current_app.logger.info(f"Resume upload attempt for user {current_user.id}") + current_app.logger.info(f"Request content type: {request.content_type}") + current_app.logger.info(f"Request files: {list(request.files.keys())}") + current_app.logger.info(f"Request form: {list(request.form.keys())}") + current_app.logger.info(f"Is JSON: {request.is_json}") + + # Re-fetch the current user from the database to ensure it's attached to the session + user = User.query.get(current_user.id) + if not user: + current_app.logger.error(f"Could not find user with ID {current_user.id}") + return jsonify({'success': False, 'error': 'User not found'}), 404 + + # Ensure upload folder exists with proper permissions + upload_folder = current_app.config.get('UPLOAD_FOLDER', 'uploads') + if not os.path.isabs(upload_folder): + upload_folder = os.path.join(current_app.root_path, upload_folder) + + try: + os.makedirs(upload_folder, exist_ok=True) + # Test write permissions + test_file = os.path.join(upload_folder, 'test_write.tmp') + with open(test_file, 'w') as f: + f.write('test') + os.remove(test_file) + current_app.logger.info(f"Upload folder verified: {upload_folder}") + except Exception as e: + current_app.logger.error(f"Upload folder creation/permission error: {str(e)}") + # Fallback to a temporary directory + import tempfile + upload_folder = tempfile.mkdtemp() + current_app.logger.info(f"Using temporary upload folder: {upload_folder}") + + # Check if we have JSON data with a base64 encoded file + if request.is_json: + current_app.logger.info("Processing JSON request with base64 file") + data = request.json + if 'resume_file' in data and data['resume_file'].startswith('data:'): + try: + from utils.document_parser import parse_and_save_resume + current_app.logger.info("Parsing resume from base64 data") + parsed_text, file_path, filename, mime_type = parse_and_save_resume( + data['resume_file'], user.id) + user.resume = parsed_text + user.resume_file_path = file_path + user.resume_filename = filename + user.resume_mime_type = mime_type + + db.session.commit() + current_app.logger.info(f"Successfully saved base64 resume for user {user.id}") + + return jsonify({ + 'success': True, + 'message': 'Resume uploaded successfully from base64 data', + 'fileInfo': { + 'filename': filename, + 'mimeType': mime_type + } + }), 200 + except Exception as e: + current_app.logger.error(f"Error processing base64 resume: {str(e)}") + current_app.logger.error(traceback.format_exc()) + db.session.rollback() + return jsonify({'success': False, 'error': f'Error processing resume: {str(e)}'}), 400 + else: + return jsonify({'success': False, 'error': 'No valid base64 resume file found in JSON data'}), 400 + + # Handle multipart form data (file upload) + elif 'resume_file' in request.files: + current_app.logger.info("Processing multipart form file upload") + file = request.files['resume_file'] + if file and file.filename != '': + try: + # Validate file type + allowed_extensions = current_app.config.get('ALLOWED_EXTENSIONS', {'pdf', 'doc', 'docx', 'txt'}) + file_ext = file.filename.rsplit('.', 1)[1].lower() if '.' in file.filename else '' + if file_ext not in allowed_extensions: + return jsonify({ + 'success': False, + 'error': f'File type not allowed. Please use: {", ".join(allowed_extensions)}' + }), 400 + + # Check file size (Flask's MAX_CONTENT_LENGTH should handle this, but double-check) + max_size = current_app.config.get('MAX_CONTENT_LENGTH', 16 * 1024 * 1024) # 16MB default + if hasattr(file, 'content_length') and file.content_length and file.content_length > max_size: + return jsonify({ + 'success': False, + 'error': f'File too large. Maximum size: {max_size // (1024*1024)}MB' + }), 400 + + # Process the resume file + file_path, filename, resume_text = process_resume_file(file) + + if file_path: + # Update user's resume information + user.resume_filename = filename + user.resume_file_path = file_path + if resume_text: + user.resume = resume_text + + # Save changes to database + db.session.commit() + current_app.logger.info(f"Successfully processed and saved resume file for user {user.id}") + + # Try to extract keywords (but don't fail if this fails) + try: + extraction_result = extract_keywords_from_resume( + user_id=user.id, + resume_text=resume_text + ) + current_app.logger.info(f"Extracted {extraction_result['keywords_extracted']} keywords") + except Exception as e: + current_app.logger.warning(f"Keyword extraction failed but continuing: {str(e)}") + + return jsonify({ + 'success': True, + 'message': 'Resume uploaded and processed successfully', + 'fileInfo': { + 'filename': filename + } + }), 200 + else: + return jsonify({'success': False, 'error': 'Failed to process resume file'}), 400 + except Exception as e: + current_app.logger.error(f"Error processing resume file: {str(e)}") + current_app.logger.error(traceback.format_exc()) + db.session.rollback() + return jsonify({'success': False, 'error': f'Error processing resume: {str(e)}'}), 400 + else: + return jsonify({'success': False, 'error': 'No file selected or file is empty'}), 400 + else: + current_app.logger.warning(f"No resume file provided. Request files: {request.files.keys()}, Form: {request.form.keys()}") + return jsonify({'success': False, 'error': 'No resume file provided in request'}), 400 + + except Exception as e: + current_app.logger.error(f"Unexpected error in API resume upload: {str(e)}") + current_app.logger.error(traceback.format_exc()) + db.session.rollback() + return jsonify({'success': False, 'error': f'Unexpected error: {str(e)}'}), 500 + + +@resume_bp.route('/resume/upload-with-keywords', methods=['POST']) +@login_required +def upload_resume_with_keywords(): + """Upload resume and extract keywords""" + try: + if 'resume' not in request.files: + return jsonify({'error': 'No file uploaded'}), 400 + + file = request.files['resume'] + if file.filename == '': + return jsonify({'error': 'No file selected'}), 400 + + # Save file temporarily and parse content + filename = secure_filename(file.filename) + temp_path = os.path.join(current_app.config['UPLOAD_FOLDER'], filename) + file.save(temp_path) + + # Parse resume content based on file type + if filename.lower().endswith('.pdf'): + resume_data = parse_pdf(temp_path) + resume_text = resume_data.get('text', '') or str(resume_data) + elif filename.lower().endswith('.docx'): + from utils.document_parser import parse_docx + resume_text = parse_docx(temp_path) + else: + resume_text = file.read().decode('utf-8') + + # Clean up temp file + os.remove(temp_path) + + # Update user's resume + current_user.resume = resume_text + current_user.resume_filename = filename + current_user.resume_mime_type = file.content_type + + # Extract keywords from resume + current_app.logger.debug(f"[KEYWORD EXTRACTION] Raw resume text (first 300 chars): {resume_text[:300] if resume_text else 'None'}") + extraction_result = extract_keywords_from_resume( + user_id=current_user.id, + resume_text=resume_text + ) + current_app.logger.debug(f"[KEYWORD EXTRACTION] Extracted {extraction_result['keywords_extracted']} keywords: {extraction_result['keywords']}") + try: + current_app.logger.info(f"[KEYWORD EXTRACTION] Extracted {extraction_result['keywords_extracted']} keywords from resume for user {current_user.id}") + current_app.logger.info(f"[KEYWORD EXTRACTION] Keywords: {extraction_result['keywords']}") + except Exception as e: + current_app.logger.error(f"[KEYWORD EXTRACTION ERROR] {str(e)}") + # Continue despite keyword extraction errors + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'Resume uploaded and keywords extracted successfully', + 'keywords_extracted': extraction_result['keywords_extracted'], + 'keywords': extraction_result['keywords'] + }) + + except Exception as e: + current_app.logger.error(f"Resume upload error: {str(e)}") + db.session.rollback() + return jsonify({'error': str(e)}), 500 \ No newline at end of file diff --git a/backend/routes/profile_backup.py b/backend/routes/profile_backup.py new file mode 100644 index 00000000..2f3ebc36 --- /dev/null +++ b/backend/routes/profile_backup.py @@ -0,0 +1,1503 @@ +from flask import Blueprint, render_template, redirect, url_for, flash, request, current_app, jsonify, session, abort, send_from_directory +from flask_login import login_required, current_user +import os +import json +from werkzeug.utils import secure_filename +import PyPDF2 +import docx2txt +from datetime import datetime as dt +import logging +import time +import traceback +import re +from dateutil import parser as date_parser +from sqlalchemy.orm import RelationshipProperty +from sqlalchemy import text +from models.db import db +from models.all_models import User, Experience, Project, PortfolioLink, ApplicantValue, Skill, Language, Certification, DesiredJobTitle +from forms.profile import ProfileForm +from utils.document_parser import parse_pdf +from sqlalchemy.orm.exc import DetachedInstanceError +from models import JobKeyword +from services.resume_keyword_service import ResumeKeywordService +from utils.document_parser import parse_pdf +from utils.auth import admin_required, moderator_required + +# Helper function to parse various date formats +def parse_date(date_string): + """ + Parse various date formats into a date object + """ + if not date_string: + return None + + # First try to use dateutil parser which handles most common formats + try: + return date_parser.parse(date_string).date() + except: + # Handle common date formats manually + patterns = [ + r'(\w+)\s+(\d{4})', # Month YYYY (e.g., "October 2024", "May 2024") + r'(\d{1,2})[/\.-](\d{1,2})[/\.-](\d{2,4})', # MM/DD/YYYY or DD/MM/YYYY + r'(\d{4})' # Just year + ] + + for pattern in patterns: + match = re.search(pattern, date_string) + if match: + groups = match.groups() + try: + if len(groups) == 2 and groups[0].isalpha(): + # Month YYYY format (e.g., "October 2024") + month_map = { + 'january': 1, 'february': 2, 'march': 3, 'april': 4, 'may': 5, 'june': 6, + 'july': 7, 'august': 8, 'september': 9, 'october': 10, 'november': 11, 'december': 12, + 'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'may': 5, 'jun': 6, + 'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12 + } + month_text = groups[0].lower() + month = month_map.get(month_text, 1) + year = int(groups[1]) + return dt.date(year, month, 1) + elif len(groups) == 3: + # MM/DD/YYYY or DD/MM/YYYY + month = int(groups[0]) + day = int(groups[1]) + year = int(groups[2]) + if year < 100: + year += 2000 if year < 50 else 1900 + return dt.date(year, month, day) + elif len(groups) == 1: + # Just year + year = int(groups[0]) + return dt.date(year, 1, 1) + except: + continue + + # If nothing worked, try to extract just a year + year_match = re.search(r'(\d{4})', date_string) + if year_match: + try: + year = int(year_match.group(1)) + return dt.date(year, 1, 1) + except: + pass + + # If all attempts failed, return None + return None + +profile_bp = Blueprint('profile', __name__) +resume_service = ResumeKeywordService() +document_parser = parse_pdf + +@profile_bp.route('', methods=['GET', 'POST', 'OPTIONS']) +@profile_bp.route('/', methods=['GET', 'POST', 'OPTIONS']) +def profile(): + """Handle profile requests - serve React frontend for browser, API for AJAX""" + # Handle OPTIONS request for CORS preflight + if request.method == 'OPTIONS': + response = jsonify({'status': 'ok'}) + response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') + response.headers['Access-Control-Allow-Headers'] = 'Content-Type,Authorization' + response.headers['Access-Control-Allow-Methods'] = 'GET,POST,OPTIONS' + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response + + # For GET requests, check if it's an AJAX request (API call) or a browser request (HTML) + if request.method == 'GET': + # Check if this is an API request (AJAX call from React frontend) + is_ajax = ( + request.headers.get('Content-Type', '').startswith('application/json') or + request.headers.get('Accept', '').find('application/json') != -1 or + request.headers.get('X-Requested-With') == 'XMLHttpRequest' or + request.args.get('format') == 'json' + ) + + if is_ajax: + # Return JSON data for API requests - call the API function directly + return api_get_profile() + else: + # For direct browser access, serve the React frontend + # This will be handled by the main app's catch-all route + # We need to not handle this route for browser requests + abort(404) # Let the main app's catch-all route handle this + + # For POST requests, call the API update function directly + if request.method == 'POST': + return api_update_profile() + + return jsonify({'error': 'Method not allowed'}), 405 + + +@profile_bp.route('/upload-resume', methods=['GET', 'POST']) +@login_required +def upload_resume(): + """Handle resume upload via API for React frontend.""" + form = ProfileForm() + + # For POST requests with file uploads + if request.method == 'POST': + if 'resume_file' in request.files: + file = request.files['resume_file'] + if file and file.filename != '': + try: + # Process the resume file + file_path, filename, resume_text = process_resume_file(file) + + if file_path: + # Update user's resume information + current_user.resume_filename = filename + current_user.resume_file_path = file_path + if resume_text: + current_user.resume = resume_text + + # Save changes to database + db.session.commit() + + # Extract keywords from resume and save to keyword database + current_app.logger.debug(f"[KEYWORD EXTRACTION] Raw resume text (first 300 chars): {resume_text[:300] if resume_text else 'None'}") + extraction_result = resume_service.extract_keywords_from_resume( + user_id=current_user.id, + resume_text=resume_text + ) + current_app.logger.debug(f"[KEYWORD EXTRACTION] Extracted {extraction_result['keywords_extracted']} keywords: {extraction_result['keywords']}") + try: + current_app.logger.info(f"[KEYWORD EXTRACTION] Extracted {extraction_result['keywords_extracted']} keywords from resume for user {current_user.id}") + current_app.logger.info(f"[KEYWORD EXTRACTION] Keywords: {extraction_result['keywords']}") + except Exception as e: + current_app.logger.error(f"[KEYWORD EXTRACTION ERROR] {str(e)}") + # Continue despite keyword extraction errors + + # Return success JSON response + return jsonify({ + 'success': True, + 'message': 'Resume uploaded and parsed successfully. Please review your profile information.', + 'fileInfo': { + 'filename': filename, + 'parsedData': parse_pdf(file_path) # Include parsed data in the response + } + }), 200 + except Exception as e: + current_app.logger.error(f"Error in resume upload: {str(e)}") + return jsonify({ + 'success': False, + 'message': f'Error processing resume: {str(e)}' + }), 400 + else: + return jsonify({ + 'success': False, + 'message': 'No resume file selected.' + }), 400 + else: + return jsonify({ + 'success': False, + 'message': 'No resume file provided in request' + }), 400 + + # Skip the resume upload if requested + if request.args.get('skip') == 'true': + session['skip_resume_upload'] = True + return jsonify({ + 'success': True, + 'redirect': url_for('profile.profile') + }), 200 + + # For GET requests, return form config instead of rendering a template + form_config = { + 'csrf_token': form.csrf_token.data, + 'resume_field': { + 'label': 'Upload Resume', + 'description': 'Upload your resume (PDF, DOCX, or TXT)', + 'required': True, + 'accepted_types': '.pdf,.docx,.doc,.txt' + } + } + + return jsonify({ + 'success': True, + 'form_config': form_config + }), 200 + + +@profile_bp.route('/upload-resume', methods=['GET']) +@login_required +def upload_resume_react(): + """Handler for the React-based resume upload page""" + try: + # Redirect to the correct URL for resume uploads + return redirect(url_for('profile.upload_resume')) + except Exception as e: + current_app.logger.error(f"Error redirecting to resume upload: {str(e)}") + flash(f"Error loading resume upload: {str(e)}", "danger") + return redirect(url_for('profile.profile')) + + +@profile_bp.route('/resume', methods=['POST']) +@login_required +def api_upload_resume(): + """API endpoint for resume uploads from the React frontend""" + try: + current_app.logger.info(f"Resume upload attempt for user {current_user.id}") + current_app.logger.info(f"Request content type: {request.content_type}") + current_app.logger.info(f"Request files: {list(request.files.keys())}") + current_app.logger.info(f"Request form: {list(request.form.keys())}") + current_app.logger.info(f"Is JSON: {request.is_json}") + + # Re-fetch the current user from the database to ensure it's attached to the session + user = User.query.get(current_user.id) + if not user: + current_app.logger.error(f"Could not find user with ID {current_user.id}") + return jsonify({'success': False, 'error': 'User not found'}), 404 + + # Ensure upload folder exists with proper permissions + upload_folder = current_app.config.get('UPLOAD_FOLDER', 'uploads') + if not os.path.isabs(upload_folder): + upload_folder = os.path.join(current_app.root_path, upload_folder) + + try: + os.makedirs(upload_folder, exist_ok=True) + # Test write permissions + test_file = os.path.join(upload_folder, 'test_write.tmp') + with open(test_file, 'w') as f: + f.write('test') + os.remove(test_file) + current_app.logger.info(f"Upload folder verified: {upload_folder}") + except Exception as e: + current_app.logger.error(f"Upload folder creation/permission error: {str(e)}") + # Fallback to a temporary directory + import tempfile + upload_folder = tempfile.mkdtemp() + current_app.logger.info(f"Using temporary upload folder: {upload_folder}") + + # Check if we have JSON data with a base64 encoded file + if request.is_json: + current_app.logger.info("Processing JSON request with base64 file") + data = request.json + if 'resume_file' in data and data['resume_file'].startswith('data:'): + try: + from utils.document_parser import parse_and_save_resume + current_app.logger.info("Parsing resume from base64 data") + parsed_text, file_path, filename, mime_type = parse_and_save_resume( + data['resume_file'], user.id) + user.resume = parsed_text + user.resume_file_path = file_path + user.resume_filename = filename + user.resume_mime_type = mime_type + + db.session.commit() + current_app.logger.info(f"Successfully saved base64 resume for user {user.id}") + + return jsonify({ + 'success': True, + 'message': 'Resume uploaded successfully from base64 data', + 'fileInfo': { + 'filename': filename, + 'mimeType': mime_type + } + }), 200 + except Exception as e: + current_app.logger.error(f"Error processing base64 resume: {str(e)}") + current_app.logger.error(traceback.format_exc()) + db.session.rollback() + return jsonify({'success': False, 'error': f'Error processing resume: {str(e)}'}), 400 + else: + return jsonify({'success': False, 'error': 'No valid base64 resume file found in JSON data'}), 400 + + # Handle multipart form data (file upload) + elif 'resume_file' in request.files: + current_app.logger.info("Processing multipart form file upload") + file = request.files['resume_file'] + if file and file.filename != '': + try: + # Validate file type + allowed_extensions = current_app.config.get('ALLOWED_EXTENSIONS', {'pdf', 'doc', 'docx', 'txt'}) + file_ext = file.filename.rsplit('.', 1)[1].lower() if '.' in file.filename else '' + if file_ext not in allowed_extensions: + return jsonify({ + 'success': False, + 'error': f'File type not allowed. Please use: {", ".join(allowed_extensions)}' + }), 400 + + # Check file size (Flask's MAX_CONTENT_LENGTH should handle this, but double-check) + max_size = current_app.config.get('MAX_CONTENT_LENGTH', 16 * 1024 * 1024) # 16MB default + if hasattr(file, 'content_length') and file.content_length and file.content_length > max_size: + return jsonify({ + 'success': False, + 'error': f'File too large. Maximum size: {max_size // (1024*1024)}MB' + }), 400 + + # Process the resume file + file_path, filename, resume_text = process_resume_file(file) + + if file_path: + # Update user's resume information + user.resume_filename = filename + user.resume_file_path = file_path + if resume_text: + user.resume = resume_text + + # Save changes to database + db.session.commit() + current_app.logger.info(f"Successfully processed and saved resume file for user {user.id}") + + # Try to extract keywords (but don't fail if this fails) + try: + from services.resume_keyword_service import ResumeKeywordService + resume_service = ResumeKeywordService() + extraction_result = resume_service.extract_keywords_from_resume( + user_id=user.id, + resume_text=resume_text + ) + current_app.logger.info(f"Extracted {extraction_result['keywords_extracted']} keywords") + except Exception as e: + current_app.logger.warning(f"Keyword extraction failed but continuing: {str(e)}") + + return jsonify({ + 'success': True, + 'message': 'Resume uploaded and processed successfully', + 'fileInfo': { + 'filename': filename + } + }), 200 + else: + return jsonify({'success': False, 'error': 'Failed to process resume file'}), 400 + except Exception as e: + current_app.logger.error(f"Error processing resume file: {str(e)}") + current_app.logger.error(traceback.format_exc()) + db.session.rollback() + return jsonify({'success': False, 'error': f'Error processing resume: {str(e)}'}), 400 + else: + return jsonify({'success': False, 'error': 'No file selected or file is empty'}), 400 + else: + current_app.logger.warning(f"No resume file provided. Request files: {request.files.keys()}, Form: {request.form.keys()}") + return jsonify({'success': False, 'error': 'No resume file provided in request'}), 400 + + except Exception as e: + current_app.logger.error(f"Unexpected error in API resume upload: {str(e)}") + current_app.logger.error(traceback.format_exc()) + db.session.rollback() + return jsonify({'success': False, 'error': f'Unexpected error: {str(e)}'}), 500 + + +def update_user_relationships(user, data, relationship_type): + """Helper function to update user relationships (skills, certifications, languages, job titles)""" + if relationship_type == 'skills': + # Clear existing skills + user.skills = [] + + items_list = data.get('skills', []) + if isinstance(items_list, str): + try: + items_list = json.loads(items_list) + except json.JSONDecodeError: + items_list = [s.strip() for s in items_list.split(',') if s.strip()] + + for item in items_list: + # Handle both string and object formats + if isinstance(item, dict): + item_name = item.get('name') or item.get('skill') + else: + item_name = str(item) + + if item_name and item_name.strip(): + skill = Skill.query.filter_by(name=item_name.strip()).first() + if not skill: + skill = Skill(name=item_name.strip()) + db.session.add(skill) + user.skills.append(skill) + + elif relationship_type == 'certifications': + # Clear existing certifications + user.certifications = [] + + items_list = data.get('certifications', []) + if isinstance(items_list, str): + try: + items_list = json.loads(items_list) + except json.JSONDecodeError: + items_list = [] + + for item_data in items_list: + if isinstance(item_data, dict): + item_name = item_data.get('name') + else: + item_name = str(item_data) + + if item_name and item_name.strip(): + cert = Certification.query.filter_by(name=item_name.strip()).first() + if not cert: + cert = Certification(name=item_name.strip()) + db.session.add(cert) + user.certifications.append(cert) + + elif relationship_type == 'languages': + # Clear existing languages + user.languages = [] + + items_list = data.get('languages', []) + if isinstance(items_list, str): + try: + items_list = json.loads(items_list) + except json.JSONDecodeError: + items_list = [] + + for item_data in items_list: + if isinstance(item_data, dict): + item_name = item_data.get('language') + else: + item_name = str(item_data) + + if item_name and item_name.strip(): + lang = Language.query.filter_by(name=item_name.strip()).first() + if not lang: + lang = Language(name=item_name.strip()) + db.session.add(lang) + user.languages.append(lang) + + elif relationship_type == 'job_titles': + # Clear existing job title entries for this user + DesiredJobTitle.query.filter_by(user_id=user.id).delete() + + items_list = data.get('desired_job_titles', []) + if isinstance(items_list, str): + try: + items_list = json.loads(items_list) + except json.JSONDecodeError: + items_list = [t.strip() for t in items_list.split(',') if t.strip()] + + # Add new job title entries + for title in items_list: + if title and str(title).strip(): # Only add non-empty titles + job_title = DesiredJobTitle(user_id=user.id, title=str(title).strip()) + db.session.add(job_title) + +def is_relationship_field(user, field_name): + """Check if the given field is a SQLAlchemy relationship field""" + if not hasattr(user.__class__, field_name): + return False + attr = getattr(user.__class__, field_name) + if not hasattr(attr, 'property'): + return False + return isinstance(attr.property, RelationshipProperty) + +def update_user_json_fields(user, data): + """Helper function to update JSON fields in user profile""" + for field in ['experience', 'projects', 'education', 'portfolio_links', 'applicant_values', 'desired_salary_range']: + if field in data: + # Skip relationship fields - they need special handling + if is_relationship_field(user, field): + current_app.logger.warning(f"Skipping JSON serialization for relationship field: {field}") + continue + + if isinstance(data[field], (list, dict)): + setattr(user, field, data[field]) # Store directly as JSON for SQLAlchemy JSON type + elif isinstance(data[field], str): + try: + json.loads(data[field]) # Validate JSON + setattr(user, field, json.loads(data[field])) # Parse and store as object + except json.JSONDecodeError: + current_app.logger.warning(f"Invalid {field} JSON: {data[field]}, storing as string") + setattr(user, field, data[field]) + +def update_user_basic_fields(user, data): + """Helper function to update basic fields in user profile""" + # Define which fields are safe to update - exclude readonly fields + safe_fields = [ + 'name', 'location', 'github_url', 'linkedin_url', + 'professional_summary', 'work_mode_preference', 'career_goals', + 'biggest_achievement', 'industry_attraction', + 'willing_to_relocate', 'authorization_status', 'veteran_status', + 'needs_sponsorship', 'visa_status', 'race_ethnicity', 'years_of_experience', + 'education_level', 'industry_preference', 'company_size_preference', + 'remote_preference', 'available_start_date', 'preferred_company_type', + 'graduation_date', 'phone_number', 'first_name', 'last_name' + ] + + # Only update fields that are both in data and in safe_fields + for field in safe_fields: + if field in data: + # Handle special case where name might need to be split + if field == 'name' and data.get('name'): + full_name = data['name'] + if " " in full_name: + name_parts = full_name.split() + user.first_name = name_parts[0] + user.last_name = " ".join(name_parts[1:]) + else: + user.first_name = full_name + user.last_name = "" + else: + setattr(user, field, data.get(field)) + +@profile_bp.route('/update', methods=['POST', 'OPTIONS']) +@login_required +def update_profile(): + """Update profile data from API requests""" + # Handle OPTIONS request for CORS preflight + if request.method == 'OPTIONS': + response = jsonify({'status': 'ok'}) + response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') + response.headers['Access-Control-Allow-Headers'] = 'Content-Type,Authorization' + response.headers['Access-Control-Allow-Methods'] = 'POST,OPTIONS' + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response + + try: + data = request.json + current_app.logger.info(f"Received profile update with {len(data.keys()) if data else 0} fields") + + if not data: + return jsonify({ + 'success': False, + 'message': 'No data provided in request' + }), 400 + + # Filter out readonly fields to prevent errors + readonly_fields = { + 'id', 'created_at', 'updated_at', 'is_active', 'is_verified', + 'last_login', 'role', 'applications', 'orders', 'subscription_history', + 'experiences', 'projects', 'assigned_users', 'completion_percentage', + 'group_completions', 'resume', 'resume_file_path', 'resume_filename', + 'resume_mime_type', 'resume_url' + } + + # Create filtered data without readonly fields + filtered_data = {k: v for k, v in data.items() if k not in readonly_fields} + current_app.logger.info(f"Filtered data to {len(filtered_data.keys())} safe fields") + + # Update basic fields + update_user_basic_fields(current_user, filtered_data) + + # Update relationships + for relationship in ['skills', 'certifications', 'languages', 'job_titles']: + if relationship in filtered_data or f'desired_{relationship}' in filtered_data: + update_user_relationships(current_user, filtered_data, relationship) + + # Update JSON fields + update_user_json_fields(current_user, filtered_data) + + # Process date fields + date_fields = ['available_start_date', 'graduation_date', 'military_discharge_date'] + for date_field in date_fields: + if date_field in filtered_data: + if filtered_data[date_field]: + try: + date_value = dt.fromisoformat( + filtered_data[date_field].replace('Z', '+00:00') + ).date() + setattr(current_user, date_field, date_value) + except ValueError as e: + current_app.logger.warning( + f"Invalid {date_field} format: {filtered_data[date_field]}. Error: {str(e)}" + ) + else: + setattr(current_user, date_field, None) + + # Save changes to database + db.session.commit() + current_app.logger.info(f"Profile updated for user: {current_user.id}") + + # Return updated profile with CORS headers + db.session.refresh(current_user) # Refresh to ensure all relationships are loaded + response = jsonify({ + 'success': True, + 'message': 'Profile updated successfully', + 'user': current_user.to_dict() + }) + response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response, 200 + + except Exception as e: + current_app.logger.error(f"Error updating profile: {str(e)}") + current_app.logger.error(traceback.format_exc()) + db.session.rollback() + response = jsonify({ + 'success': False, + 'message': f'Error updating profile: {str(e)}', + 'error_details': traceback.format_exc() + }) + response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response, 500 + + +@profile_bp.route('/api', methods=['GET', 'OPTIONS']) +def api_get_profile(): + """API endpoint to get the current user's profile data""" + # Handle OPTIONS request for CORS preflight + if request.method == 'OPTIONS': + response = jsonify({'status': 'ok'}) + response.headers.add('Access-Control-Allow-Origin', request.headers.get('Origin', '*')) + response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization') + response.headers.add('Access-Control-Allow-Methods', 'GET,OPTIONS') + response.headers.add('Access-Control-Allow-Credentials', 'true') + return response + + try: + # Check if user is authenticated + if not current_user.is_authenticated: + response = jsonify({'error': 'Authentication required'}) + response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response, 401 + + # Re-fetch the current user from the database to ensure it's attached to the session + user = User.query.get(current_user.id) + if not user: + current_app.logger.error(f"Could not find user with ID {current_user.id}") + response = jsonify({'error': 'User not found'}) + response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response, 404 + + try: + # Use the existing to_dict method to serialize user data + if hasattr(user, 'to_dict'): + # Refresh the user object to ensure all relationships are loaded + db.session.refresh(user) + profile_data = user.to_dict() + else: + # Fallback for SimpleUser or other user types + profile_data = { + 'id': user.id, + 'email': getattr(user, 'email', ''), + 'first_name': getattr(user, 'first_name', ''), + 'last_name': getattr(user, 'last_name', ''), + 'name': f"{getattr(user, 'first_name', '')} {getattr(user, 'last_name', '')}".strip(), + 'role': getattr(user, 'role', 'user'), + 'is_active': getattr(user, 'is_active', True), + 'is_verified': getattr(user, 'is_verified', False), + 'phone_number': getattr(user, 'phone_number', ''), + 'location': getattr(user, 'location', ''), + 'professional_summary': getattr(user, 'professional_summary', ''), + 'linkedin_url': getattr(user, 'linkedin_url', ''), + 'github_url': getattr(user, 'github_url', ''), + 'portfolio_url': getattr(user, 'portfolio_url', ''), + 'desired_job_titles': getattr(user, 'desired_job_titles', ''), + 'work_mode_preference': getattr(user, 'work_mode_preference', ''), + 'min_salary_hourly': getattr(user, 'min_salary_hourly', None), + 'created_at': getattr(user, 'created_at', dt.utcnow()).isoformat() if hasattr(user, 'created_at') else dt.utcnow().isoformat(), + 'updated_at': getattr(user, 'updated_at', dt.utcnow()).isoformat() if hasattr(user, 'updated_at') else dt.utcnow().isoformat(), + # Initialize empty relationships + 'skills': [], + 'languages': [], + 'certifications': [], + 'experiences': [], + 'projects': [], + 'portfolio_links': [], + 'demographic': None, + 'military_info': None, + 'applicant_value_entries': [], + 'job_title_entries': [], + 'assigned_users': [], + 'subscription_history': [], + 'applications': [], + 'orders': [] + } + + # Calculate profile completion data + completion_data = calculate_profile_completion(user) + profile_data.update(completion_data) + + except Exception as dict_error: + current_app.logger.error(f"Error serializing user data: {str(dict_error)}") + current_app.logger.error(traceback.format_exc()) + response = jsonify({ + 'success': False, + 'message': 'Error serializing profile data', + 'error_details': str(dict_error) + }) + response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response, 500 + + # Return JSON with successful status + response = jsonify(profile_data) + response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response, 200 + + except Exception as e: + current_app.logger.error(f"Error getting profile data: {str(e)}") + current_app.logger.error(traceback.format_exc()) + response = jsonify({ + 'success': False, + 'message': f'Error fetching profile: {str(e)}', + 'error_details': traceback.format_exc() + }) + response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response, 500 + + +@profile_bp.route('/api', methods=['POST', 'OPTIONS']) +@login_required +def api_update_profile(): + """API endpoint to update the current user's profile from the React frontend""" + # Handle OPTIONS request for CORS preflight + if request.method == 'OPTIONS': + response = jsonify({'status': 'ok'}) + response.headers.add('Access-Control-Allow-Origin', request.headers.get('Origin', '*')) + response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization') + response.headers.add('Access-Control-Allow-Methods', 'POST,OPTIONS') + response.headers.add('Access-Control-Allow-Credentials', 'true') + return response + + # Process POST request to update profile + try: + if not request.is_json: + return jsonify({'error': 'Request must be JSON'}), 400 + + # Re-fetch the current user from the database to ensure it's attached to the session + user = User.query.get(current_user.id) + if not user: + current_app.logger.error(f"Could not find user with ID {current_user.id}") + return jsonify({'error': 'User not found'}), 404 + + data = request.json + current_app.logger.debug(f"API profile update received with {len(data.keys()) if data else 0} fields") + + # Filter out readonly fields to prevent errors + readonly_fields = { + 'id', 'created_at', 'updated_at', 'is_active', 'is_verified', + 'last_login', 'role', 'applications', 'orders', 'subscription_history', + 'experiences', 'projects', 'assigned_users', 'completion_percentage', + 'group_completions', 'resume', 'resume_file_path', 'resume_filename', + 'resume_mime_type', 'resume_url' + } + + # Create filtered data without readonly fields + filtered_data = {k: v for k, v in data.items() if k not in readonly_fields} + current_app.logger.debug(f"Filtered data to {len(filtered_data.keys())} safe fields") + + # Update basic fields + update_user_basic_fields(user, filtered_data) + + # Update relationships + for relationship in ['skills', 'certifications', 'languages', 'job_titles']: + if relationship in filtered_data or f'desired_{relationship}' in filtered_data: + update_user_relationships(user, filtered_data, relationship) + + # Update JSON fields + update_user_json_fields(user, filtered_data) + + # Process date fields + date_fields = ['available_start_date', 'graduation_date', 'military_discharge_date'] + for date_field in date_fields: + if date_field in filtered_data: + if filtered_data[date_field]: + try: + date_value = dt.fromisoformat( + filtered_data[date_field].replace('Z', '+00:00') + ).date() + setattr(user, date_field, date_value) + except ValueError as e: + current_app.logger.warning( + f"Invalid {date_field} format: {filtered_data[date_field]}. Error: {str(e)}" + ) + else: + setattr(user, date_field, None) + + # Save all changes to database + db.session.commit() + + current_app.logger.info(f"Profile updated successfully for user {user.id}") + + # Return updated profile data + db.session.refresh(user) # Refresh to ensure all relationships are loaded + return jsonify({ + 'success': True, + 'message': 'Profile updated successfully', + 'user': user.to_dict() + }), 200 + + except Exception as e: + current_app.logger.error(f"Error updating profile: {str(e)}") + current_app.logger.error(traceback.format_exc()) + db.session.rollback() + return jsonify({ + 'success': False, + 'message': f'Error updating profile: {str(e)}', + 'error_details': traceback.format_exc() + }), 500 + + +@profile_bp.route('/profile/jobs', methods=['GET']) +@login_required +def profile_jobs(): + """View jobs relevant to the user's profile""" + try: + # Instead of rendering a template, redirect to the React frontend's job route + return redirect('/jobs/recommendations') + except Exception as e: + current_app.logger.error(f"Error redirecting to jobs: {str(e)}") + flash(f"Error loading job recommendations: {str(e)}", "danger") + return redirect(url_for('profile.profile')) + + +def extract_text_from_resume(file_path): + """Extract text from various file formats.""" + try: + file_ext = os.path.splitext(file_path)[1].lower() + + if file_ext == '.pdf': + with open(file_path, 'rb') as file: + reader = PyPDF2.PdfReader(file) + text = '' + for page_num in range(len(reader.pages)): + text += reader.pages[page_num].extract_text() + return text + + elif file_ext in ['.docx', '.doc']: + text = docx2txt.process(file_path) + return text + + elif file_ext == '.txt': + with open(file_path, 'r', encoding='utf-8') as file: + return file.read() + + else: + return "Unsupported file format." + except Exception as e: + current_app.logger.error(f"Error extracting text from resume: {str(e)}") + return f"Error processing file: {str(e)}" + + +def process_resume_file(file): + """Process an uploaded resume file, extract text, and auto-fill fields.""" + # Use imported parse_pdf function + + filename = secure_filename(file.filename) + timestamp = dt.now().strftime("%Y%m%d_%H%M%S") + unique_filename = f"{timestamp}_{filename}" + + # Ensure upload folder exists with proper error handling + upload_folder = current_app.config.get('UPLOAD_FOLDER', 'uploads') + if not os.path.isabs(upload_folder): + upload_folder = os.path.join(current_app.root_path, upload_folder) + + try: + os.makedirs(upload_folder, exist_ok=True) + # Test write permissions + test_file = os.path.join(upload_folder, 'test_write.tmp') + with open(test_file, 'w') as f: + f.write('test') + os.remove(test_file) + current_app.logger.info(f"Upload folder verified: {upload_folder}") + except Exception as e: + current_app.logger.error(f"Upload folder creation/permission error: {str(e)}") + # Fallback to a temporary directory + import tempfile + upload_folder = tempfile.mkdtemp() + current_app.logger.info(f"Using temporary upload folder: {upload_folder}") + + file_path = os.path.join(upload_folder, unique_filename) + + try: + file.save(file_path) + current_app.logger.info(f"File saved successfully: {file_path}") + except Exception as e: + current_app.logger.error(f"Error saving file: {str(e)}") + raise e + + # Extract text from the file + resume_text = extract_text_from_resume(file_path) + + # Parse resume using the parse_pdf function that uses Gemini first + parsed_data = parse_pdf(file_path) + current_app.logger.info(f"Parsed resume data: {parsed_data}") + try: + # Add detailed debugging for parsed data + current_app.logger.info(f"DEBUG: Parsed data keys: {list(parsed_data.keys()) if parsed_data else 'None'}") + if parsed_data: + current_app.logger.info(f"DEBUG: Skills found: {parsed_data.get('skills', [])}") + current_app.logger.info(f"DEBUG: Experience found: {len(parsed_data.get('experience', []))} entries") + current_app.logger.info(f"DEBUG: Projects found: {len(parsed_data.get('projects', []))} entries") + current_app.logger.info(f"DEBUG: Name found: {parsed_data.get('name')}") + else: + current_app.logger.warning("DEBUG: No parsed data returned from parse_pdf") + + # Basic fields + if parsed_data.get("name"): + full_name = parsed_data["name"] + if " " in full_name: + name_parts = full_name.split() + current_user.first_name = name_parts[0] + current_user.last_name = " ".join(name_parts[1:]) + else: + current_user.first_name = full_name + + if parsed_data.get("professional_summary"): + current_user.professional_summary = parsed_data["professional_summary"] + + if parsed_data.get("phone"): + current_user.phone_number = parsed_data["phone"] + + if parsed_data.get("location"): + current_user.location = parsed_data["location"] + + if parsed_data.get("linkedin"): + current_user.linkedin_url = parsed_data["linkedin"] + + if parsed_data.get("github"): + current_user.github_url = parsed_data["github"] + + # Convert job_titles list to JSON string for database storage + if parsed_data.get("job_titles"): + current_user.desired_job_titles = json.dumps(parsed_data["job_titles"]) + + # Store the resume content + current_user.resume = resume_text + current_user.resume_file_path = file_path + current_user.resume_filename = filename + + # Handle skills properly through the relationship + if parsed_data.get("skills"): + # Clear existing skills using direct database deletion + db.session.execute( + text("DELETE FROM user_skills WHERE user_id = :user_id"), + {"user_id": current_user.id} + ) + db.session.flush() + + # Add each skill through the relationship + for skill_name in parsed_data["skills"]: + # Try to find existing skill + skill = Skill.query.filter_by(name=skill_name).first() + if not skill: + # Create new skill if it doesn't exist + skill = Skill(name=skill_name) + db.session.add(skill) + db.session.flush() # Flush to get the ID + # Add skill to user's skills using direct insertion + db.session.execute( + text("INSERT OR IGNORE INTO user_skills (user_id, skill_id) VALUES (:user_id, :skill_id)"), + {"user_id": current_user.id, "skill_id": skill.id} + ) + + # Handle certifications properly through the relationship + if parsed_data.get("certifications"): + # Clear existing certifications using direct database deletion + db.session.execute( + text("DELETE FROM user_certifications WHERE user_id = :user_id"), + {"user_id": current_user.id} + ) + db.session.flush() + + # Add each certification through the relationship + for cert_name in parsed_data["certifications"]: + # Try to find existing certification + cert = Certification.query.filter_by(name=cert_name).first() + if not cert: + # Create new certification if it doesn't exist + cert = Certification(name=cert_name) + db.session.add(cert) + db.session.flush() # Flush to get the ID + # Add certification to user's certifications using direct insertion + db.session.execute( + text("INSERT OR IGNORE INTO user_certifications (user_id, certification_id) VALUES (:user_id, :certification_id)"), + {"user_id": current_user.id, "certification_id": cert.id} + ) + + # Handle languages properly through the relationship + if parsed_data.get("languages"): + # Clear existing languages using direct database deletion + db.session.execute( + text("DELETE FROM user_languages WHERE user_id = :user_id"), + {"user_id": current_user.id} + ) + db.session.flush() + + # Add each language through the relationship + for lang_name in parsed_data["languages"]: + # Try to find existing language + lang = Language.query.filter_by(name=lang_name).first() + if not lang: + # Create new language if it doesn't exist + lang = Language(name=lang_name) + db.session.add(lang) + db.session.flush() # Flush to get the ID + # Add language to user's languages using direct insertion + db.session.execute( + text("INSERT OR IGNORE INTO user_languages (user_id, language_id) VALUES (:user_id, :language_id)"), + {"user_id": current_user.id, "language_id": lang.id} + ) + + # Process experience field with proper structure + if parsed_data.get("experience") and isinstance(parsed_data["experience"], list): + # Clear existing experiences using direct database deletion + db.session.execute( + text("DELETE FROM experiences WHERE user_id = :user_id"), + {"user_id": current_user.id} + ) + db.session.flush() + + for exp_data in parsed_data["experience"]: + if not isinstance(exp_data, dict): + continue + + # Parse start_date first - it's required + start_date = None + if "start_date" in exp_data: + start_date = parse_date(exp_data["start_date"]) + + # If start_date parsing failed, use a default or skip this experience + if not start_date: + current_app.logger.warning(f"Skipping experience due to invalid start_date: {exp_data}") + continue + + # Create a new Experience object with appropriate fields + experience = Experience( + user_id=current_user.id, + company_name=exp_data.get("company", "Unknown Company"), + position=exp_data.get("title", "Unknown Title"), + description=exp_data.get("description", ""), + location=exp_data.get("location", ""), + start_date=start_date # Ensure start_date is always set + ) + + # Parse end_date + if "end_date" in exp_data: + end_date_str = exp_data.get("end_date", "").lower() + if end_date_str in ["present", "current"]: + experience.is_current = True + else: + end_date = parse_date(exp_data["end_date"]) + if end_date: + experience.end_date = end_date + + db.session.add(experience) + current_app.logger.info(f"Added experience: {experience.company_name} - {experience.position}") + + # Process projects field with proper structure + if parsed_data.get("projects") and isinstance(parsed_data["projects"], list): + # Clear existing projects using direct database deletion + db.session.execute( + text("DELETE FROM projects WHERE user_id = :user_id"), + {"user_id": current_user.id} + ) + db.session.flush() + + for proj_data in parsed_data["projects"]: + if not isinstance(proj_data, dict): + continue + + # Create a new Project object with appropriate fields + project = Project( + user_id=current_user.id, + name=proj_data.get("name", "Unknown Project"), + description=proj_data.get("description", ""), + url=proj_data.get("url", "") + ) + + # Handle technologies array + if "technologies" in proj_data: + if isinstance(proj_data["technologies"], list): + project.technologies = proj_data["technologies"] + elif isinstance(proj_data["technologies"], str): + # Split comma-separated technologies + project.technologies = [tech.strip() for tech in proj_data["technologies"].split(",") if tech.strip()] + + db.session.add(project) + current_app.logger.info(f"Added project: {project.name}") + + # Handle portfolio links from resume data if available + if parsed_data.get("portfolio_links") and isinstance(parsed_data["portfolio_links"], list): + # Clear existing portfolio links using direct database deletion + db.session.execute( + text("DELETE FROM portfolio_links WHERE user_id = :user_id"), + {"user_id": current_user.id} + ) + db.session.flush() + + for link_data in parsed_data["portfolio_links"]: + if not isinstance(link_data, dict) or not link_data.get("url"): + continue + + portfolio_link = PortfolioLink( + user_id=current_user.id, + platform=link_data.get("platform", "Website"), + url=link_data.get("url", ""), + description=link_data.get("description", "") + ) + + db.session.add(portfolio_link) + + # Handle education field separately + if parsed_data.get("education") and isinstance(parsed_data["education"], list): + # Store education JSON in the appropriate field + current_user._education = json.dumps(parsed_data["education"]) + + # Handle values and applicant_values properly through relationships + if parsed_data.get("values") and isinstance(parsed_data["values"], list): + # Clear existing values + current_user.applicant_value_entries = [] + + # Add each value as a proper applicant value entry + for i, value_item in enumerate(parsed_data["values"]): + if isinstance(value_item, dict): + # If it's already a structured dict with category and value + category = value_item.get("category", "General") + value = value_item.get("value") + priority = value_item.get("priority", i+1) + elif isinstance(value_item, str): + # If it's just a string, use a default category + category = "Values" + value = value_item + priority = i+1 + else: + continue + + if value: + applicant_value = ApplicantValue( + user_id=current_user.id, + category=category, + value=value, + priority=priority + ) + db.session.add(applicant_value) + current_user.applicant_value_entries.append(applicant_value) + + # Also handle applicant_values field directly if it exists in parsed_data + if parsed_data.get("applicant_values") and isinstance(parsed_data["applicant_values"], list) and not current_user.applicant_value_entries: + # Only process this if we don't already have values from the "values" field + # Clear existing values if we haven't already + current_user.applicant_value_entries = [] + + # Add each value as a proper applicant value entry + for i, value_item in enumerate(parsed_data["applicant_values"]): + if isinstance(value_item, dict): + category = value_item.get("category", "General") + value = value_item.get("value") + priority = value_item.get("priority", i+1) + elif isinstance(value_item, str): + category = "Values" + value = value_item + priority = i+1 + else: + continue + + if value: + applicant_value = ApplicantValue( + user_id=current_user.id, + category=category, + value=value, + priority=priority + ) + db.session.add(applicant_value) + current_user.applicant_value_entries.append(applicant_value) + # Handle work preferences and other fields + if parsed_data.get("work_mode_preference"): + work_mode = parsed_data["work_mode_preference"] + # Normalize work mode preference to standard values + if isinstance(work_mode, str): + work_mode = work_mode.lower() + if "remote" in work_mode: + current_user.work_mode_preference = "Remote" + elif "hybrid" in work_mode: + current_user.work_mode_preference = "Hybrid" + elif "office" in work_mode or "onsite" in work_mode or "on-site" in work_mode: + current_user.work_mode_preference = "In-office" + else: + current_user.work_mode_preference = work_mode.capitalize() + else: + current_user.work_mode_preference = str(work_mode) + + # Handle relocation preference if specified + if parsed_data.get("willing_to_relocate") is not None: + if isinstance(parsed_data["willing_to_relocate"], bool): + current_user.willing_to_relocate = parsed_data["willing_to_relocate"] + elif isinstance(parsed_data["willing_to_relocate"], str): + willing = parsed_data["willing_to_relocate"].lower() + current_user.willing_to_relocate = willing in ["yes", "true", "y", "1", "willing"] + + # Handle other user profile fields + if parsed_data.get("career_goals"): + current_user.career_goals = parsed_data["career_goals"] + if parsed_data.get("biggest_achievement"): + current_user.biggest_achievement = parsed_data["biggest_achievement"] + if parsed_data.get("work_style"): + current_user.work_style = parsed_data["work_style"] + if parsed_data.get("industry_attraction"): + current_user.industry_attraction = parsed_data["industry_attraction"] + + # Commit changes to database + try: + db.session.commit() + except Exception as e: + current_app.logger.error(f"Error saving parsed resume data: {str(e)}") + db.session.rollback() + raise e + + return file_path, filename, resume_text + except Exception as e: + current_app.logger.error(f"Error processing resume: {str(e)}") + current_app.logger.error(traceback.format_exc()) + return None, None, None + +def calculate_profile_completion(user): + """Calculate profile completion percentage and group completions""" + group_completions = {} + total_fields = 0 + completed_fields = 0 + + # Basic Info Group (25% weight) + basic_info_fields = [ + ('first_name', user.first_name), + ('last_name', user.last_name), + ('email', user.email), + ('location', user.location), + ('phone_number', user.phone_number), + ('professional_summary', user.professional_summary) + ] + basic_info_completed = sum(1 for _, value in basic_info_fields if value and str(value).strip()) + basic_info_percentage = (basic_info_completed / len(basic_info_fields)) * 100 + group_completions['basic_info'] = round(basic_info_percentage) + total_fields += len(basic_info_fields) + completed_fields += basic_info_completed + + # Skills & Experience Group (25% weight) + skills_experience_fields = [ + ('skills', user.skills.count() if hasattr(user.skills, 'count') else 0), + ('experiences', user.experiences.count() if hasattr(user.experiences, 'count') else 0), + ('projects', user.projects.count() if hasattr(user.projects, 'count') else 0), + ('certifications', user.certifications.count() if hasattr(user.certifications, 'count') else 0) + ] + skills_experience_completed = sum(1 for _, value in skills_experience_fields if value > 0) + skills_experience_percentage = (skills_experience_completed / len(skills_experience_fields)) * 100 + group_completions['skills_experience'] = round(skills_experience_percentage) + total_fields += len(skills_experience_fields) + completed_fields += skills_experience_completed + + # Resume Group (20% weight) + resume_fields = [ + ('resume', user.resume), + ('resume_file_path', user.resume_file_path), + ('resume_url', user.resume_url) + ] + resume_completed = sum(1 for _, value in resume_fields if value and str(value).strip()) + resume_percentage = (resume_completed / len(resume_fields)) * 100 + group_completions['resume'] = round(resume_percentage) + total_fields += len(resume_fields) + completed_fields += resume_completed + + # Work Preferences Group (15% weight) + work_preferences_fields = [ + ('desired_job_titles', user.desired_job_titles), + ('work_mode_preference', user.work_mode_preference), + ('min_salary_hourly', user.min_salary_hourly) + ] + work_preferences_completed = sum(1 for _, value in work_preferences_fields if value and str(value).strip()) + work_preferences_percentage = (work_preferences_completed / len(work_preferences_fields)) * 100 + group_completions['work_preferences'] = round(work_preferences_percentage) + total_fields += len(work_preferences_fields) + completed_fields += work_preferences_completed + + # Additional Qualifications Group (10% weight) + additional_qualifications_fields = [ + ('languages', user.languages.count() if hasattr(user.languages, 'count') else 0), + ('portfolio_links', user.portfolio_links.count() if hasattr(user.portfolio_links, 'count') else 0), + ('linkedin_url', user.linkedin_url), + ('github_url', user.github_url), + ('portfolio_url', user.portfolio_url) + ] + additional_qualifications_completed = sum(1 for _, value in additional_qualifications_fields if value and str(value).strip()) + additional_qualifications_percentage = (additional_qualifications_completed / len(additional_qualifications_fields)) * 100 + group_completions['additional_qualifications'] = round(additional_qualifications_percentage) + total_fields += len(additional_qualifications_fields) + completed_fields += additional_qualifications_completed + + # Professional Details Group (5% weight) + professional_details_fields = [ + ('demographic', user.demographic is not None), + ('military_info', user.military_info is not None), + ('applicant_value_entries', user.applicant_value_entries.count() if hasattr(user.applicant_value_entries, 'count') else 0) + ] + professional_details_completed = sum(1 for _, value in professional_details_fields if value and str(value).strip()) + professional_details_percentage = (professional_details_completed / len(professional_details_fields)) * 100 + group_completions['professional_details'] = round(professional_details_percentage) + total_fields += len(professional_details_fields) + completed_fields += professional_details_completed + + # Calculate overall completion percentage + overall_percentage = (completed_fields / total_fields) * 100 if total_fields > 0 else 0 + + return { + 'completion_percentage': round(overall_percentage), + 'group_completions': group_completions + } + +@profile_bp.route('/resume/upload-with-keywords', methods=['POST']) +@login_required +def upload_resume_with_keywords(): + """Upload resume and extract keywords""" + try: + if 'resume' not in request.files: + return jsonify({'error': 'No file uploaded'}), 400 + + file = request.files['resume'] + if file.filename == '': + return jsonify({'error': 'No file selected'}), 400 + + # Save file temporarily and parse content + filename = secure_filename(file.filename) + temp_path = os.path.join(current_app.config['UPLOAD_FOLDER'], filename) + file.save(temp_path) + + # Parse resume content based on file type + if filename.lower().endswith('.pdf'): + resume_data = parse_pdf(temp_path) + resume_text = resume_data.get('text', '') or str(resume_data) + elif filename.lower().endswith('.docx'): + from backend.utils.document_parser import parse_docx + resume_text = parse_docx(temp_path) + else: + resume_text = file.read().decode('utf-8') + + # Clean up temp file + os.remove(temp_path) + + # Update user's resume + current_user.resume = resume_text + current_user.resume_filename = filename + current_user.resume_mime_type = file.content_type + + # Extract keywords from resume + current_app.logger.debug(f"[KEYWORD EXTRACTION] Raw resume text (first 300 chars): {resume_text[:300] if resume_text else 'None'}") + extraction_result = resume_service.extract_keywords_from_resume( + user_id=current_user.id, + resume_text=resume_text + ) + current_app.logger.debug(f"[KEYWORD EXTRACTION] Extracted {extraction_result['keywords_extracted']} keywords: {extraction_result['keywords']}") + try: + current_app.logger.info(f"[KEYWORD EXTRACTION] Extracted {extraction_result['keywords_extracted']} keywords from resume for user {current_user.id}") + current_app.logger.info(f"[KEYWORD EXTRACTION] Keywords: {extraction_result['keywords']}") + except Exception as e: + current_app.logger.error(f"[KEYWORD EXTRACTION ERROR] {str(e)}") + # Continue despite keyword extraction errors + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'Resume uploaded and keywords extracted successfully', + 'keywords_extracted': extraction_result['keywords_extracted'], + 'keywords': extraction_result['keywords'] + }) + + except Exception as e: + current_app.logger.error(f"Resume upload error: {str(e)}") + db.session.rollback() + return jsonify({'error': str(e)}), 500 + +@profile_bp.route('/keywords', methods=['GET']) +@login_required +def get_user_keywords(): + """Get keywords extracted from user's resume""" + try: + keywords = resume_service.get_user_keywords(current_user.id) + return jsonify({ + 'success': True, + 'keywords': keywords + }) + except Exception as e: + current_app.logger.error(f"Error getting user keywords: {str(e)}") + return jsonify({'error': str(e)}), 500 + +@profile_bp.route('/keywords/statistics', methods=['GET']) +@login_required +def get_keyword_statistics(): + """Get keyword database statistics""" + try: + stats = resume_service.get_keyword_statistics() + return jsonify({ + 'success': True, + 'statistics': stats + }) + except Exception as e: + current_app.logger.error(f"Error getting keyword statistics: {str(e)}") + return jsonify({'error': str(e)}), 500 + +@profile_bp.route('/keywords/add', methods=['POST']) +@login_required +def add_user_keyword(): + """Add a new keyword to user's profile""" + try: + data = request.get_json() + keyword_text = data.get('keyword', '').strip() + category = data.get('category', 'skill') + + if not keyword_text: + return jsonify({'error': 'Keyword is required'}), 400 + + # Add keyword using the service + result = resume_service.add_user_keyword( + user_id=current_user.id, + keyword=keyword_text, + category=category + ) + + return jsonify({ + 'success': True, + 'message': 'Keyword added successfully', + 'keyword': result + }) + except Exception as e: + current_app.logger.error(f"Error adding user keyword: {str(e)}") + return jsonify({'error': str(e)}), 500 + +@profile_bp.route('/keywords/remove', methods=['POST']) +@login_required +def remove_user_keyword(): + """Remove a keyword from user's profile""" + try: + data = request.get_json() + keyword_id = data.get('keyword_id') + + if not keyword_id: + return jsonify({'error': 'Keyword ID is required'}), 400 + + # Remove keyword using the service + result = resume_service.remove_user_keyword( + user_id=current_user.id, + keyword_id=keyword_id + ) + + return jsonify({ + 'success': True, + 'message': 'Keyword removed successfully' + }) + except Exception as e: + current_app.logger.error(f"Error removing user keyword: {str(e)}") + return jsonify({'error': str(e)}), 500 + +@profile_bp.route('/keywords/update', methods=['POST']) +@login_required +def update_user_keyword(): + """Update a keyword in user's profile""" + try: + data = request.get_json() + keyword_id = data.get('keyword_id') + keyword_text = data.get('keyword', '').strip() + category = data.get('category', 'skill') + + if not keyword_id or not keyword_text: + return jsonify({'error': 'Keyword ID and keyword text are required'}), 400 + + # Update keyword using the service + result = resume_service.update_user_keyword( + user_id=current_user.id, + keyword_id=keyword_id, + keyword=keyword_text, + category=category + ) + + return jsonify({ + 'success': True, + 'message': 'Keyword updated successfully', + 'keyword': result + }) + except Exception as e: + current_app.logger.error(f"Error updating user keyword: {str(e)}") + return jsonify({'error': str(e)}), 500 \ No newline at end of file diff --git a/backend/utils/job_recommenders/__init__.py b/backend/utils/job_recommenders/__init__.py index bbc47757..9d0433f8 100644 --- a/backend/utils/job_recommenders/__init__.py +++ b/backend/utils/job_recommenders/__init__.py @@ -25,7 +25,7 @@ """ # Import main functions from the advanced recommender -from backend.utils.job_recommenders.advanced import ( +from .advanced import ( search_and_get_jobs_for_user, extract_user_profile as extract_advanced_profile, search_jobs_from_database as advanced_search_jobs_from_database, @@ -34,7 +34,7 @@ ) # Import main functions from the simple recommender -from backend.utils.job_recommenders.simple import ( +from .simple import ( get_job_recommendations, search_jobs_from_database as simple_search_jobs_from_database, analyze_job_match_with_gemini, @@ -43,7 +43,7 @@ ) # Import pipeline functions -from backend.utils.job_recommenders.pipeline import ( +from .pipeline import ( init_db, get_latest_jobs, search_jobs, @@ -60,7 +60,7 @@ ) # Import user recommender functions -from backend.utils.job_recommenders.user_recommender import ( +from .user_recommender import ( get_recommendations_for_user, refresh_recommendations_for_user, mark_job_selected, diff --git a/backend/utils/job_recommenders/advanced.py b/backend/utils/job_recommenders/advanced.py index b3e6d4f9..60282afd 100644 --- a/backend/utils/job_recommenders/advanced.py +++ b/backend/utils/job_recommenders/advanced.py @@ -38,11 +38,11 @@ # --- Attempt to import User model, provide fallback --- User = None try: - from backend.models.all_models import User as DBUser # Rename to avoid conflict + from models.all_models import User as DBUser # Rename to avoid conflict User = DBUser # Use the database user model if import succeeds - print("Successfully imported User model from backend.models.all_models") + print("Successfully imported User model from models.all_models") except ImportError: - print("Could not import User from backend.models.all_models. Using fallback class for standalone run.") + print("Could not import User from models.all_models. Using fallback class for standalone run.") # If running standalone without Flask context, define a simple User class class FallbackUser: """Simple User class for standalone testing""" @@ -76,7 +76,7 @@ def get_id(self): return str(self.id) logger = logging.getLogger(__name__) # --- API Configurations --- -from backend.utils.gemini_api_manager import configure_gemini_api, has_gemini_api_keys, rotate_api_key +from utils.gemini_api_manager import configure_gemini_api, has_gemini_api_keys, rotate_api_key RAPID_API_KEY = os.environ.get('RAPID_API_KEY', '') # --- Gemini Setup --- diff --git a/backend/utils/job_recommenders/simple.py b/backend/utils/job_recommenders/simple.py index 6ee70641..0fdb652e 100644 --- a/backend/utils/job_recommenders/simple.py +++ b/backend/utils/job_recommenders/simple.py @@ -15,10 +15,10 @@ import sys from typing import List, Dict, Any from dotenv import load_dotenv -from backend.models.job_recommendation import JobRecommendation +from models.job_recommendation import JobRecommendation from flask_login import current_user -from backend.models.db import db -from backend.models import User +from models.db import db +from models import User # Add the project root to the Python path when running standalone sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) @@ -31,7 +31,7 @@ logger = logging.getLogger(__name__) # Configure Gemini API with multiple key support -from backend.utils.gemini_api_manager import configure_gemini_api, has_gemini_api_keys, rotate_api_key +from utils.gemini_api_manager import configure_gemini_api, has_gemini_api_keys, rotate_api_key # Only import and configure if keys are available if has_gemini_api_keys(): @@ -854,8 +854,8 @@ def recommend_jobs_for_user_id(user_id: int, job_title: str = None, location: st try: # This import is here to avoid circular imports from flask import current_app - from backend.models.db import db - from backend.models import User + from models.db import db + from models import User with current_app.app_context(): user = User.query.get(user_id) diff --git a/backend/utils/profile_utils.py b/backend/utils/profile_utils.py new file mode 100644 index 00000000..9e795a76 --- /dev/null +++ b/backend/utils/profile_utils.py @@ -0,0 +1,211 @@ +""" +Profile utility functions for data processing and validation. +""" +import re +from datetime import datetime as dt +from dateutil import parser as date_parser +from sqlalchemy.orm import RelationshipProperty +from models.all_models import User, Experience, Project, PortfolioLink, ApplicantValue, Skill, Language, Certification, DesiredJobTitle + + +def parse_date(date_string): + """ + Parse various date formats into a date object + """ + if not date_string: + return None + + # First try to use dateutil parser which handles most common formats + try: + return date_parser.parse(date_string).date() + except: + # Handle common date formats manually + patterns = [ + r'(\w+)\s+(\d{4})', # Month YYYY (e.g., "October 2024", "May 2024") + r'(\d{1,2})[/\.-](\d{1,2})[/\.-](\d{2,4})', # MM/DD/YYYY or DD/MM/YYYY + r'(\d{4})' # Just year + ] + + for pattern in patterns: + match = re.search(pattern, date_string) + if match: + groups = match.groups() + try: + if len(groups) == 2 and groups[0].isalpha(): + # Month YYYY format (e.g., "October 2024") + month_map = { + 'january': 1, 'february': 2, 'march': 3, 'april': 4, 'may': 5, 'june': 6, + 'july': 7, 'august': 8, 'september': 9, 'october': 10, 'november': 11, 'december': 12, + 'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'may': 5, 'jun': 6, + 'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12 + } + month_text = groups[0].lower() + month = month_map.get(month_text, 1) + year = int(groups[1]) + return dt(year, month, 1).date() + elif len(groups) == 3: + # MM/DD/YYYY or DD/MM/YYYY + month = int(groups[0]) + day = int(groups[1]) + year = int(groups[2]) + if year < 100: + year += 2000 if year < 50 else 1900 + return dt(year, month, day).date() + elif len(groups) == 1: + # Just year + year = int(groups[0]) + return dt(year, 1, 1).date() + except: + continue + + # If nothing worked, try to extract just a year + year_match = re.search(r'(\d{4})', date_string) + if year_match: + try: + year = int(year_match.group(1)) + return dt(year, 1, 1).date() + except: + pass + + # If all attempts failed, return None + return None + + +def is_relationship_field(user, field_name): + """ + Check if a field is a relationship field that should be handled specially + """ + mapper = user.__class__.__mapper__ + return field_name in mapper.relationships + + +def update_user_basic_fields(user, data): + """Update basic user fields from data dictionary""" + basic_fields = [ + 'first_name', 'last_name', 'email', 'phone_number', 'location', + 'linkedin_url', 'github_url', 'personal_website', 'professional_summary', + 'willing_to_relocate', 'work_mode_preference' + ] + + for field in basic_fields: + if field in data: + setattr(user, field, data[field]) + + +def update_user_json_fields(user, data): + """Update JSON fields with proper serialization""" + json_fields = ['skills', 'desired_job_titles', 'experience', 'projects', 'education'] + + for field in json_fields: + if field in data: + value = data[field] + if isinstance(value, str): + try: + import json + # Validate JSON + json.loads(value) + setattr(user, field, value) + except json.JSONDecodeError: + # If not valid JSON, treat as simple string/list + if field in ['skills', 'desired_job_titles']: + items = [item.strip() for item in value.split(',') if item.strip()] + setattr(user, field, json.dumps(items)) + else: + setattr(user, field, json.dumps([value])) + elif isinstance(value, (list, dict)): + import json + setattr(user, field, json.dumps(value)) + + +def update_user_relationships(user, data, relationship_type): + """Update user relationship fields (experiences, projects, etc.)""" + from models.db import db + + if relationship_type == 'experiences' and 'experience' in data: + # Clear existing experiences + Experience.query.filter_by(user_id=user.id).delete() + + experiences_data = data['experience'] + if isinstance(experiences_data, str): + import json + try: + experiences_data = json.loads(experiences_data) + except: + return + + if isinstance(experiences_data, list): + for exp_data in experiences_data: + experience = Experience( + user_id=user.id, + job_title=exp_data.get('title', ''), + company=exp_data.get('company', ''), + location=exp_data.get('location', ''), + start_date=parse_date(exp_data.get('start_date')), + end_date=parse_date(exp_data.get('end_date')), + description=exp_data.get('description', '') + ) + db.session.add(experience) + + elif relationship_type == 'projects' and 'projects' in data: + # Clear existing projects + Project.query.filter_by(user_id=user.id).delete() + + projects_data = data['projects'] + if isinstance(projects_data, str): + import json + try: + projects_data = json.loads(projects_data) + except: + return + + if isinstance(projects_data, list): + for proj_data in projects_data: + project = Project( + user_id=user.id, + name=proj_data.get('name', ''), + description=proj_data.get('description', ''), + technologies=','.join(proj_data.get('technologies', [])) if isinstance(proj_data.get('technologies'), list) else proj_data.get('technologies', ''), + url=proj_data.get('url'), + start_date=parse_date(proj_data.get('start_date')), + end_date=parse_date(proj_data.get('end_date')) + ) + db.session.add(project) + + +def calculate_profile_completion(user): + """Calculate the completion percentage of a user's profile""" + total_fields = 15 + completed_fields = 0 + + # Basic fields (weight: 1 each) + basic_fields = [ + 'first_name', 'last_name', 'email', 'phone_number', 'location', + 'professional_summary', 'linkedin_url' + ] + + for field in basic_fields: + if getattr(user, field, None): + completed_fields += 1 + + # Skills (weight: 2) + if user.skills: + try: + import json + skills = json.loads(user.skills) if isinstance(user.skills, str) else user.skills + if skills and len(skills) > 0: + completed_fields += 2 + except: + if user.skills.strip(): + completed_fields += 2 + + # Experience (weight: 3) + experiences = Experience.query.filter_by(user_id=user.id).all() + if experiences: + completed_fields += 3 + + # Projects (weight: 2) + projects = Project.query.filter_by(user_id=user.id).all() + if projects: + completed_fields += 2 + + return min(100, int((completed_fields / total_fields) * 100)) \ No newline at end of file diff --git a/backend/utils/resume_utils.py b/backend/utils/resume_utils.py new file mode 100644 index 00000000..251358f9 --- /dev/null +++ b/backend/utils/resume_utils.py @@ -0,0 +1,215 @@ +""" +Resume processing utilities for file upload, text extraction, and parsing. +""" +import os +import tempfile +import traceback +from datetime import datetime as dt +from werkzeug.utils import secure_filename +from flask import current_app +import PyPDF2 +import docx2txt + +from models.db import db +from models.all_models import User +from utils.document_parser import parse_pdf +from services.resume_keyword_service import ResumeKeywordService + + +def extract_text_from_resume(file_path): + """Extract text from various resume file formats""" + try: + if file_path.lower().endswith('.pdf'): + with open(file_path, 'rb') as file: + reader = PyPDF2.PdfReader(file) + text = '' + for page in reader.pages: + text += page.extract_text() + return text + elif file_path.lower().endswith(('.docx', '.doc')): + return docx2txt.process(file_path) + elif file_path.lower().endswith('.txt'): + with open(file_path, 'r', encoding='utf-8') as file: + return file.read() + else: + return None + except Exception as e: + current_app.logger.error(f"Error extracting text from {file_path}: {str(e)}") + return None + + +def process_resume_file(file): + """Process an uploaded resume file, extract text, and auto-fill fields.""" + filename = secure_filename(file.filename) + timestamp = dt.now().strftime("%Y%m%d_%H%M%S") + unique_filename = f"{timestamp}_{filename}" + + # Ensure upload folder exists with proper error handling + upload_folder = current_app.config.get('UPLOAD_FOLDER', 'uploads') + if not os.path.isabs(upload_folder): + upload_folder = os.path.join(current_app.root_path, upload_folder) + + try: + os.makedirs(upload_folder, exist_ok=True) + # Test write permissions + test_file = os.path.join(upload_folder, 'test_write.tmp') + with open(test_file, 'w') as f: + f.write('test') + os.remove(test_file) + current_app.logger.info(f"Upload folder verified: {upload_folder}") + except Exception as e: + current_app.logger.error(f"Upload folder creation/permission error: {str(e)}") + # Fallback to a temporary directory + upload_folder = tempfile.mkdtemp() + current_app.logger.info(f"Using temporary upload folder: {upload_folder}") + + file_path = os.path.join(upload_folder, unique_filename) + + try: + file.save(file_path) + current_app.logger.info(f"File saved successfully: {file_path}") + except Exception as e: + current_app.logger.error(f"Error saving file: {str(e)}") + raise e + + # Extract text from the file + resume_text = extract_text_from_resume(file_path) + + # Parse resume using the parse_pdf function that uses Gemini first + parsed_data = parse_pdf(file_path) + current_app.logger.info(f"Parsed resume data: {parsed_data}") + + try: + from flask_login import current_user + from .profile_utils import parse_date + + # Add detailed debugging for parsed data + current_app.logger.info(f"DEBUG: Parsed data keys: {list(parsed_data.keys()) if parsed_data else 'None'}") + if parsed_data: + current_app.logger.info(f"DEBUG: Skills found: {parsed_data.get('skills', [])}") + current_app.logger.info(f"DEBUG: Experience found: {len(parsed_data.get('experience', []))} entries") + current_app.logger.info(f"DEBUG: Projects found: {len(parsed_data.get('projects', []))} entries") + current_app.logger.info(f"DEBUG: Name found: {parsed_data.get('name')}") + else: + current_app.logger.warning("DEBUG: No parsed data returned from parse_pdf") + + # Update user profile with parsed data + _update_user_from_parsed_data(current_user, parsed_data, resume_text) + + # Commit changes to database + try: + db.session.commit() + except Exception as e: + current_app.logger.error(f"Error saving parsed resume data: {str(e)}") + db.session.rollback() + raise e + + return file_path, filename, resume_text + except Exception as e: + current_app.logger.error(f"Error processing resume: {str(e)}") + current_app.logger.error(traceback.format_exc()) + return None, None, None + + +def _update_user_from_parsed_data(user, parsed_data, resume_text): + """Update user profile with data parsed from resume""" + import json + from .profile_utils import parse_date + + if not parsed_data: + return + + # Basic fields + if parsed_data.get("name"): + full_name = parsed_data["name"] + if " " in full_name: + name_parts = full_name.split() + user.first_name = name_parts[0] + user.last_name = " ".join(name_parts[1:]) + else: + user.first_name = full_name + + if parsed_data.get("professional_summary"): + user.professional_summary = parsed_data["professional_summary"] + + if parsed_data.get("phone"): + user.phone_number = parsed_data["phone"] + + if parsed_data.get("location"): + user.location = parsed_data["location"] + + if parsed_data.get("linkedin"): + user.linkedin_url = parsed_data["linkedin"] + + if parsed_data.get("github"): + user.github_url = parsed_data["github"] + + # Convert job_titles list to JSON string for database storage + if parsed_data.get("job_titles"): + user.desired_job_titles = json.dumps(parsed_data["job_titles"]) + + # Store the resume content + user.resume = resume_text + + # Process skills + if parsed_data.get("skills"): + skills_list = parsed_data["skills"] + if isinstance(skills_list, list): + user.skills = json.dumps(skills_list) + else: + user.skills = json.dumps([skills_list]) + + # Process experience + if parsed_data.get("experience"): + from models.all_models import Experience + + # Clear existing experiences + Experience.query.filter_by(user_id=user.id).delete() + + for exp_data in parsed_data["experience"]: + current_app.logger.info(f"Added experience: {exp_data.get('company')} - {exp_data.get('title')}") + experience = Experience( + user_id=user.id, + job_title=exp_data.get("title", ""), + company=exp_data.get("company", ""), + location=exp_data.get("location", ""), + start_date=parse_date(exp_data.get("start_date")), + end_date=parse_date(exp_data.get("end_date")), + description=exp_data.get("description", "") + ) + db.session.add(experience) + + # Process projects + if parsed_data.get("projects"): + from models.all_models import Project + + # Clear existing projects + Project.query.filter_by(user_id=user.id).delete() + + for proj_data in parsed_data["projects"]: + current_app.logger.info(f"Added project: {proj_data.get('name')}") + project = Project( + user_id=user.id, + name=proj_data.get("name", ""), + description=proj_data.get("description", ""), + technologies=",".join(proj_data.get("technologies", [])) if isinstance(proj_data.get("technologies"), list) else proj_data.get("technologies", ""), + url=proj_data.get("url"), + start_date=parse_date(proj_data.get("start_date")), + end_date=parse_date(proj_data.get("end_date")) + ) + db.session.add(project) + + +def extract_keywords_from_resume(user_id, resume_text): + """Extract keywords from resume text and save to database""" + try: + resume_service = ResumeKeywordService() + extraction_result = resume_service.extract_keywords_from_resume( + user_id=user_id, + resume_text=resume_text + ) + current_app.logger.info(f"Extracted {extraction_result['keywords_extracted']} keywords") + return extraction_result + except Exception as e: + current_app.logger.warning(f"Keyword extraction failed: {str(e)}") + return {'keywords_extracted': 0, 'keywords': []} \ No newline at end of file From eba275017f2e6e2f6f039e2ba39b440ad94c02f1 Mon Sep 17 00:00:00 2001 From: lifee77 Date: Fri, 4 Jul 2025 21:55:29 -0500 Subject: [PATCH 03/19] cleaned up root directory --- .../KEYWORD_HIGHLIGHTING_IMPLEMENTATION.md | 0 package-lock.json | 6 - run_keyword_tests.py | 147 -------- run_migrations.py | 55 --- .../debug_keyword_extraction.py | 0 debug_sorting.py => tests/debug_sorting.py | 0 .../extract_keywords_for_all_jobs.py | 0 merge_job_data.py => tests/merge_job_data.py | 0 .../merge_schema_and_data.py | 0 .../populate_keywords.py | 0 tests/run_keyword_tests.py | 333 +++++++----------- .../show_last_job_keywords.py | 0 .../show_recent_job_keywords.py | 0 .../show_recent_keywords.py | 0 .../test_job_keyword_extraction.py | 0 15 files changed, 123 insertions(+), 418 deletions(-) rename KEYWORD_HIGHLIGHTING_IMPLEMENTATION.md => documentations/KEYWORD_HIGHLIGHTING_IMPLEMENTATION.md (100%) delete mode 100644 package-lock.json delete mode 100644 run_keyword_tests.py delete mode 100644 run_migrations.py rename debug_keyword_extraction.py => tests/debug_keyword_extraction.py (100%) rename debug_sorting.py => tests/debug_sorting.py (100%) rename extract_keywords_for_all_jobs.py => tests/extract_keywords_for_all_jobs.py (100%) rename merge_job_data.py => tests/merge_job_data.py (100%) rename merge_schema_and_data.py => tests/merge_schema_and_data.py (100%) rename populate_keywords.py => tests/populate_keywords.py (100%) rename show_last_job_keywords.py => tests/show_last_job_keywords.py (100%) rename show_recent_job_keywords.py => tests/show_recent_job_keywords.py (100%) rename show_recent_keywords.py => tests/show_recent_keywords.py (100%) rename test_job_keyword_extraction.py => tests/test_job_keyword_extraction.py (100%) diff --git a/KEYWORD_HIGHLIGHTING_IMPLEMENTATION.md b/documentations/KEYWORD_HIGHLIGHTING_IMPLEMENTATION.md similarity index 100% rename from KEYWORD_HIGHLIGHTING_IMPLEMENTATION.md rename to documentations/KEYWORD_HIGHLIGHTING_IMPLEMENTATION.md diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 34e57adf..00000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "InstantApply", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/run_keyword_tests.py b/run_keyword_tests.py deleted file mode 100644 index 57052109..00000000 --- a/run_keyword_tests.py +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env python3 -""" -Test runner for comprehensive keyword functionality tests -Run this from the project root directory. -""" - -import sys -import os -import subprocess - -def run_keyword_tests(): - """Run the comprehensive keyword functionality tests""" - - print("🧪 Running Comprehensive Keyword Functionality Tests") - print("=" * 60) - - # Add backend to path - sys.path.append(os.path.join(os.path.dirname(__file__), 'backend')) - - try: - # Import and run tests - from tests.test_keyword_functionality import TestKeywordFunctionality, TestKeywordEdgeCases - from tests.test_keyword_performance import TestKeywordPerformance - - import unittest - - # Create test suite - test_suite = unittest.TestSuite() - - # Add all tests from all classes - test_suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestKeywordFunctionality)) - test_suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestKeywordEdgeCases)) - test_suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestKeywordPerformance)) - - # Run tests - runner = unittest.TextTestRunner(verbosity=2) - result = runner.run(test_suite) - - # Print summary - print("\n" + "=" * 60) - print("📊 TEST SUMMARY") - print("=" * 60) - print(f"Tests run: {result.testsRun}") - print(f"Failures: {len(result.failures)}") - print(f"Errors: {len(result.errors)}") - print(f"Success rate: {((result.testsRun - len(result.failures) - len(result.errors)) / result.testsRun * 100):.1f}%") - - if result.failures: - print("\n❌ FAILURES:") - for test, traceback in result.failures: - print(f" - {test}: {traceback.split('AssertionError:')[-1].strip()}") - - if result.errors: - print("\n🚨 ERRORS:") - for test, traceback in result.errors: - print(f" - {test}: {traceback.split('Exception:')[-1].strip()}") - - if result.wasSuccessful(): - print("\n✅ All tests passed!") - print("\n🎉 KEYWORD SYSTEM STATUS:") - print(" • Resume keyword extraction ✓") - print(" • Job keyword extraction ✓") - print(" • Database operations ✓") - print(" • Category management ✓") - print(" • Performance benchmarks ✓") - print(" • Edge case handling ✓") - print(" • Memory efficiency ✓") - print(" • Concurrent user support ✓") - return 0 - else: - print("\n❌ Some tests failed!") - return 1 - - except ImportError as e: - print(f"❌ Import error: {str(e)}") - print("Make sure you're running this from the project root directory.") - return 1 - except Exception as e: - print(f"❌ Unexpected error: {str(e)}") - return 1 - -def run_functional_tests_only(): - """Run only functional tests (skip performance tests)""" - print("🧪 Running Functional Keyword Tests Only") - print("=" * 50) - - sys.path.append(os.path.join(os.path.dirname(__file__), 'backend')) - - try: - from tests.test_keyword_functionality import TestKeywordFunctionality, TestKeywordEdgeCases - import unittest - - test_suite = unittest.TestSuite() - test_suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestKeywordFunctionality)) - test_suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestKeywordEdgeCases)) - - runner = unittest.TextTestRunner(verbosity=2) - result = runner.run(test_suite) - - print(f"\n📊 Functional Tests: {result.testsRun} run, {len(result.failures)} failures, {len(result.errors)} errors") - return 0 if result.wasSuccessful() else 1 - - except Exception as e: - print(f"❌ Error: {str(e)}") - return 1 - -def run_performance_tests_only(): - """Run only performance tests""" - print("⚡ Running Performance Tests Only") - print("=" * 40) - - sys.path.append(os.path.join(os.path.dirname(__file__), 'backend')) - - try: - from tests.test_keyword_performance import TestKeywordPerformance - import unittest - - test_suite = unittest.TestSuite() - test_suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestKeywordPerformance)) - - runner = unittest.TextTestRunner(verbosity=2) - result = runner.run(test_suite) - - print(f"\n📊 Performance Tests: {result.testsRun} run, {len(result.failures)} failures, {len(result.errors)} errors") - return 0 if result.wasSuccessful() else 1 - - except Exception as e: - print(f"❌ Error: {str(e)}") - return 1 - -if __name__ == "__main__": - import argparse - - parser = argparse.ArgumentParser(description='Run keyword functionality tests') - parser.add_argument('--functional-only', action='store_true', help='Run only functional tests') - parser.add_argument('--performance-only', action='store_true', help='Run only performance tests') - - args = parser.parse_args() - - if args.functional_only: - exit_code = run_functional_tests_only() - elif args.performance_only: - exit_code = run_performance_tests_only() - else: - exit_code = run_keyword_tests() - - sys.exit(exit_code) \ No newline at end of file diff --git a/run_migrations.py b/run_migrations.py deleted file mode 100644 index 90c86470..00000000 --- a/run_migrations.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python3 -""" -Script to run database migrations -""" -import os -import sys - -# Add the backend directory to the path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'backend')) - -# Set the environment variable to use sync driver -os.environ['DATABASE_URL'] = 'sqlite:///backend/instance/instant_apply.db' - -from backend.app import create_app -from backend.models.db import db -from flask_migrate import upgrade, current, history - -def run_migrations(): - """Run database migrations""" - app = create_app() - - with app.app_context(): - print("Current migration status:") - try: - current_rev = current() - print(f"Current revision: {current_rev}") - except Exception as e: - print(f"Error getting current revision: {e}") - - print("\nMigration history:") - try: - for rev in history(): - print(f" {rev.revision}: {rev.doc}") - except Exception as e: - print(f"Error getting history: {e}") - - print("\nRunning database migrations...") - try: - upgrade() - print("✅ Migrations completed successfully!") - - print("\nNew current migration status:") - try: - current_rev = current() - print(f"Current revision: {current_rev}") - except Exception as e: - print(f"Error getting current revision: {e}") - - except Exception as e: - print(f"❌ Migration error: {e}") - import traceback - print(traceback.format_exc()) - -if __name__ == '__main__': - run_migrations() \ No newline at end of file diff --git a/debug_keyword_extraction.py b/tests/debug_keyword_extraction.py similarity index 100% rename from debug_keyword_extraction.py rename to tests/debug_keyword_extraction.py diff --git a/debug_sorting.py b/tests/debug_sorting.py similarity index 100% rename from debug_sorting.py rename to tests/debug_sorting.py diff --git a/extract_keywords_for_all_jobs.py b/tests/extract_keywords_for_all_jobs.py similarity index 100% rename from extract_keywords_for_all_jobs.py rename to tests/extract_keywords_for_all_jobs.py diff --git a/merge_job_data.py b/tests/merge_job_data.py similarity index 100% rename from merge_job_data.py rename to tests/merge_job_data.py diff --git a/merge_schema_and_data.py b/tests/merge_schema_and_data.py similarity index 100% rename from merge_schema_and_data.py rename to tests/merge_schema_and_data.py diff --git a/populate_keywords.py b/tests/populate_keywords.py similarity index 100% rename from populate_keywords.py rename to tests/populate_keywords.py diff --git a/tests/run_keyword_tests.py b/tests/run_keyword_tests.py index d295b701..57052109 100644 --- a/tests/run_keyword_tests.py +++ b/tests/run_keyword_tests.py @@ -1,234 +1,147 @@ #!/usr/bin/env python3 """ -Test runner script for keyword functionality tests -Run with: python tests/run_keyword_tests.py +Test runner for comprehensive keyword functionality tests +Run this from the project root directory. """ import sys import os import subprocess -from pathlib import Path -# Add project root to Python path -project_root = Path(__file__).parent.parent -sys.path.insert(0, str(project_root)) - -def run_tests(): - """Run all keyword-related tests""" - - print("🔍 Running Keyword Tests for InstantApply") - print("=" * 50) - - # Test categories and their files - test_categories = [ - { - "name": "Unit Tests - JobKeyword Model", - "path": "tests/backend/unit/models/test_job_keyword.py", - "description": "Testing the JobKeyword model functionality" - }, - { - "name": "Unit Tests - KeywordExtractor", - "path": "tests/backend/unit/utils/test_keyword_extractor.py", - "description": "Testing the JobKeywordExtractor class" - }, - { - "name": "Integration Tests - Keyword Workflow", - "path": "tests/backend/integration/utils/test_keyword_integration.py", - "description": "Testing end-to-end keyword extraction and storage" - }, - { - "name": "API Tests - Keyword Endpoints", - "path": "tests/backend/integration/routes/test_keyword_api.py", - "description": "Testing keyword extraction REST API" - }, - { - "name": "Database Tests - Keyword Associations", - "path": "tests/backend/integration/models/test_keyword_associations.py", - "description": "Testing keyword-job association table operations" - } - ] - - # Check if files exist - missing_files = [] - for category in test_categories: - file_path = project_root / category["path"] - if not file_path.exists(): - missing_files.append(category["path"]) +def run_keyword_tests(): + """Run the comprehensive keyword functionality tests""" - if missing_files: - print("❌ Missing test files:") - for file in missing_files: - print(f" - {file}") - print("\nPlease ensure all test files are created before running tests.") - return False + print("🧪 Running Comprehensive Keyword Functionality Tests") + print("=" * 60) - # Run tests for each category - total_passed = 0 - total_failed = 0 + # Add backend to path + sys.path.append(os.path.join(os.path.dirname(__file__), 'backend')) - for i, category in enumerate(test_categories, 1): - print(f"\n{i}. {category['name']}") - print(f" {category['description']}") - print(f" File: {category['path']}") - - try: - # Run pytest for the specific file - result = subprocess.run([ - sys.executable, "-m", "pytest", - category["path"], - "-v", - "--tb=short" - ], - cwd=project_root, - capture_output=True, - text=True - ) + try: + # Import and run tests + from tests.test_keyword_functionality import TestKeywordFunctionality, TestKeywordEdgeCases + from tests.test_keyword_performance import TestKeywordPerformance + + import unittest + + # Create test suite + test_suite = unittest.TestSuite() + + # Add all tests from all classes + test_suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestKeywordFunctionality)) + test_suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestKeywordEdgeCases)) + test_suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestKeywordPerformance)) + + # Run tests + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(test_suite) + + # Print summary + print("\n" + "=" * 60) + print("📊 TEST SUMMARY") + print("=" * 60) + print(f"Tests run: {result.testsRun}") + print(f"Failures: {len(result.failures)}") + print(f"Errors: {len(result.errors)}") + print(f"Success rate: {((result.testsRun - len(result.failures) - len(result.errors)) / result.testsRun * 100):.1f}%") + + if result.failures: + print("\n❌ FAILURES:") + for test, traceback in result.failures: + print(f" - {test}: {traceback.split('AssertionError:')[-1].strip()}") + + if result.errors: + print("\n🚨 ERRORS:") + for test, traceback in result.errors: + print(f" - {test}: {traceback.split('Exception:')[-1].strip()}") + + if result.wasSuccessful(): + print("\n✅ All tests passed!") + print("\n🎉 KEYWORD SYSTEM STATUS:") + print(" • Resume keyword extraction ✓") + print(" • Job keyword extraction ✓") + print(" • Database operations ✓") + print(" • Category management ✓") + print(" • Performance benchmarks ✓") + print(" • Edge case handling ✓") + print(" • Memory efficiency ✓") + print(" • Concurrent user support ✓") + return 0 + else: + print("\n❌ Some tests failed!") + return 1 - if result.returncode == 0: - print(f" ✅ PASSED") - total_passed += 1 - else: - print(f" ❌ FAILED") - print(f" Error output:") - print(f" {result.stdout}") - print(f" {result.stderr}") - total_failed += 1 - - except Exception as e: - print(f" ❌ ERROR: {str(e)}") - total_failed += 1 + except ImportError as e: + print(f"❌ Import error: {str(e)}") + print("Make sure you're running this from the project root directory.") + return 1 + except Exception as e: + print(f"❌ Unexpected error: {str(e)}") + return 1 + +def run_functional_tests_only(): + """Run only functional tests (skip performance tests)""" + print("🧪 Running Functional Keyword Tests Only") + print("=" * 50) - # Summary - print("\n" + "=" * 50) - print("📊 Test Summary") - print(f"✅ Passed: {total_passed}") - print(f"❌ Failed: {total_failed}") - print(f"📝 Total: {total_passed + total_failed}") + sys.path.append(os.path.join(os.path.dirname(__file__), 'backend')) - if total_failed == 0: - print("\n🎉 All keyword tests passed!") - return True - else: - print(f"\n⚠️ {total_failed} test categories failed. Please check the output above.") - return False + try: + from tests.test_keyword_functionality import TestKeywordFunctionality, TestKeywordEdgeCases + import unittest + + test_suite = unittest.TestSuite() + test_suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestKeywordFunctionality)) + test_suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestKeywordEdgeCases)) + + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(test_suite) + + print(f"\n📊 Functional Tests: {result.testsRun} run, {len(result.failures)} failures, {len(result.errors)} errors") + return 0 if result.wasSuccessful() else 1 + + except Exception as e: + print(f"❌ Error: {str(e)}") + return 1 -def run_specific_test(test_name): - """Run a specific test category""" - test_mapping = { - "model": "tests/backend/unit/models/test_job_keyword.py", - "extractor": "tests/backend/unit/utils/test_keyword_extractor.py", - "integration": "tests/backend/integration/utils/test_keyword_integration.py", - "api": "tests/backend/integration/routes/test_keyword_api.py", - "associations": "tests/backend/integration/models/test_keyword_associations.py" - } +def run_performance_tests_only(): + """Run only performance tests""" + print("⚡ Running Performance Tests Only") + print("=" * 40) - if test_name not in test_mapping: - print(f"❌ Unknown test: {test_name}") - print(f"Available tests: {', '.join(test_mapping.keys())}") - return False - - test_path = test_mapping[test_name] - print(f"🔍 Running {test_name} tests: {test_path}") + sys.path.append(os.path.join(os.path.dirname(__file__), 'backend')) try: - result = subprocess.run([ - sys.executable, "-m", "pytest", - test_path, - "-v", - "--tb=long" - ], - cwd=project_root - ) - return result.returncode == 0 + from tests.test_keyword_performance import TestKeywordPerformance + import unittest + + test_suite = unittest.TestSuite() + test_suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestKeywordPerformance)) + + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(test_suite) + + print(f"\n📊 Performance Tests: {result.testsRun} run, {len(result.failures)} failures, {len(result.errors)} errors") + return 0 if result.wasSuccessful() else 1 + except Exception as e: - print(f"❌ Error running test: {str(e)}") - return False + print(f"❌ Error: {str(e)}") + return 1 -def show_test_coverage(): - """Show what aspects of the keyword system are tested""" - print("📋 Keyword Test Coverage") - print("=" * 50) +if __name__ == "__main__": + import argparse - coverage_areas = [ - { - "area": "JobKeyword Model", - "tests": [ - "✅ Basic CRUD operations", - "✅ Unique constraint validation", - "✅ Relationship with jobs", - "✅ Timestamps and metadata", - "✅ Data validation", - "✅ Query patterns" - ] - }, - { - "area": "KeywordExtractor Class", - "tests": [ - "✅ Keyword extraction logic", - "✅ Common word filtering", - "✅ Title keyword boosting", - "✅ Relevance scoring", - "✅ Database operations", - "✅ Async functionality" - ] - }, - { - "area": "Integration Workflow", - "tests": [ - "✅ End-to-end extraction", - "✅ Database storage", - "✅ Keyword deduplication", - "✅ Association management", - "✅ Concurrent processing", - "✅ Error handling" - ] - }, - { - "area": "REST API", - "tests": [ - "✅ Endpoint functionality", - "✅ Authentication", - "✅ Input validation", - "✅ Error responses", - "✅ Edge cases", - "✅ Performance" - ] - }, - { - "area": "Database Associations", - "tests": [ - "✅ Association creation", - "✅ Bulk operations", - "✅ Complex queries", - "✅ Constraint validation", - "✅ Cascade operations", - "✅ Analytics queries" - ] - } - ] + parser = argparse.ArgumentParser(description='Run keyword functionality tests') + parser.add_argument('--functional-only', action='store_true', help='Run only functional tests') + parser.add_argument('--performance-only', action='store_true', help='Run only performance tests') - for area in coverage_areas: - print(f"\n🎯 {area['area']}") - for test in area['tests']: - print(f" {test}") + args = parser.parse_args() - print(f"\n📈 Total test coverage areas: {len(coverage_areas)}") - total_tests = sum(len(area['tests']) for area in coverage_areas) - print(f"📊 Total individual tests: {total_tests}") - -if __name__ == "__main__": - if len(sys.argv) > 1: - command = sys.argv[1] - - if command == "coverage": - show_test_coverage() - elif command in ["model", "extractor", "integration", "api", "associations"]: - success = run_specific_test(command) - sys.exit(0 if success else 1) - else: - print(f"❌ Unknown command: {command}") - print("Available commands: coverage, model, extractor, integration, api, associations") - sys.exit(1) + if args.functional_only: + exit_code = run_functional_tests_only() + elif args.performance_only: + exit_code = run_performance_tests_only() else: - success = run_tests() - sys.exit(0 if success else 1) \ No newline at end of file + exit_code = run_keyword_tests() + + sys.exit(exit_code) \ No newline at end of file diff --git a/show_last_job_keywords.py b/tests/show_last_job_keywords.py similarity index 100% rename from show_last_job_keywords.py rename to tests/show_last_job_keywords.py diff --git a/show_recent_job_keywords.py b/tests/show_recent_job_keywords.py similarity index 100% rename from show_recent_job_keywords.py rename to tests/show_recent_job_keywords.py diff --git a/show_recent_keywords.py b/tests/show_recent_keywords.py similarity index 100% rename from show_recent_keywords.py rename to tests/show_recent_keywords.py diff --git a/test_job_keyword_extraction.py b/tests/test_job_keyword_extraction.py similarity index 100% rename from test_job_keyword_extraction.py rename to tests/test_job_keyword_extraction.py From 02504caabc21c54f4819dfb594c17ba166a00cec Mon Sep 17 00:00:00 2001 From: lifee77 Date: Fri, 4 Jul 2025 22:10:09 -0500 Subject: [PATCH 04/19] universal import error fix -- app can now work from both backend and frontend --- backend/routes/profile/keywords.py | 8 +- backend/routes/profile/main.py | 6 +- backend/routes/profile/resume.py | 10 +-- backend/services/resume_keyword_service.py | 10 +-- backend/utils/import_utils.py | 97 ++++++++++++++++++++++ backend/utils/job_recommenders/__init__.py | 8 +- backend/utils/job_recommenders/advanced.py | 8 +- backend/utils/job_recommenders/simple.py | 12 +-- backend/utils/profile_utils.py | 5 +- backend/utils/resume_utils.py | 16 ++-- 10 files changed, 139 insertions(+), 41 deletions(-) create mode 100644 backend/utils/import_utils.py diff --git a/backend/routes/profile/keywords.py b/backend/routes/profile/keywords.py index 57044953..b35f4948 100644 --- a/backend/routes/profile/keywords.py +++ b/backend/routes/profile/keywords.py @@ -5,10 +5,10 @@ from flask import Blueprint, request, jsonify, current_app from flask_login import login_required, current_user -from models.db import db -from models.all_models import User -from models import JobKeyword -from services.resume_keyword_service import ResumeKeywordService +from backend.models.db import db +from backend.models.all_models import User +from backend.models import JobKeyword +from backend.services.resume_keyword_service import ResumeKeywordService keyword_bp = Blueprint('keyword', __name__) diff --git a/backend/routes/profile/main.py b/backend/routes/profile/main.py index 5c732de5..10e31769 100644 --- a/backend/routes/profile/main.py +++ b/backend/routes/profile/main.py @@ -7,9 +7,9 @@ from flask_login import login_required, current_user from sqlalchemy.orm.exc import DetachedInstanceError -from models.db import db -from models.all_models import User, Experience, Project -from utils.profile_utils import ( +from backend.models.db import db +from backend.models.all_models import User, Experience, Project +from backend.utils.profile_utils import ( update_user_basic_fields, update_user_json_fields, update_user_relationships, diff --git a/backend/routes/profile/resume.py b/backend/routes/profile/resume.py index 73bab75f..82c800df 100644 --- a/backend/routes/profile/resume.py +++ b/backend/routes/profile/resume.py @@ -7,15 +7,15 @@ from flask_login import login_required, current_user from werkzeug.utils import secure_filename -from models.db import db -from models.all_models import User -from forms.profile import ProfileForm -from utils.resume_utils import ( +from backend.models.db import db +from backend.models.all_models import User +from backend.forms.profile import ProfileForm +from backend.utils.resume_utils import ( process_resume_file, extract_text_from_resume, extract_keywords_from_resume ) -from utils.document_parser import parse_pdf +from backend.utils.document_parser import parse_pdf resume_bp = Blueprint('resume', __name__) diff --git a/backend/services/resume_keyword_service.py b/backend/services/resume_keyword_service.py index c39ce87f..84b5987c 100644 --- a/backend/services/resume_keyword_service.py +++ b/backend/services/resume_keyword_service.py @@ -5,11 +5,11 @@ import time from typing import List, Dict, Set, Optional, Tuple from sqlalchemy.orm import Session -from models.db import db -from models import User, JobPosting -from models.job_keyword import JobKeyword, job_keywords_association -from models.base_models import resume_keywords_association -from utils.job_recommenders.enhanced_extractor import EnhancedKeywordExtractor +from backend.models.db import db +from backend.models import User, JobPosting +from backend.models.job_keyword import JobKeyword, job_keywords_association +from backend.models.base_models import resume_keywords_association +from backend.utils.job_recommenders.enhanced_extractor import EnhancedKeywordExtractor class ResumeKeywordService: diff --git a/backend/utils/import_utils.py b/backend/utils/import_utils.py new file mode 100644 index 00000000..5307669a --- /dev/null +++ b/backend/utils/import_utils.py @@ -0,0 +1,97 @@ +""" +Import utilities to handle path resolution for both root and backend directory execution. +""" +import os +import sys +from typing import Any, Optional + + +def get_import_path(module_path: str) -> str: + """ + Get the correct import path based on the current execution context. + + Args: + module_path: The module path relative to backend directory (e.g., 'models.all_models') + + Returns: + The correct import path for the current execution context + """ + # Check if we're running from root directory (where backend is a subdirectory) + # or from backend directory itself + current_dir = os.getcwd() + + # If we're in the backend directory or a subdirectory of it + if current_dir.endswith('backend') or 'backend' in current_dir.split(os.sep): + return module_path + + # If we're in the root directory (where backend is a subdirectory) + if os.path.exists(os.path.join(current_dir, 'backend')): + return f'backend.{module_path}' + + # Default to assuming we're in backend context + return module_path + + +def safe_import(module_path: str, item_name: Optional[str] = None) -> Any: + """ + Safely import a module or item from a module, handling both root and backend contexts. + + Args: + module_path: The module path relative to backend directory + item_name: Optional specific item to import from the module + + Returns: + The imported module or item + """ + import importlib + + # Try with backend prefix first (for root directory execution) + try: + full_path = get_import_path(module_path) + module = importlib.import_module(full_path) + + if item_name: + return getattr(module, item_name) + return module + + except ImportError: + # Try without backend prefix (for backend directory execution) + try: + module = importlib.import_module(module_path) + + if item_name: + return getattr(module, item_name) + return module + + except ImportError: + # Try with explicit backend prefix + try: + module = importlib.import_module(f'backend.{module_path}') + + if item_name: + return getattr(module, item_name) + return module + + except ImportError as e: + raise ImportError(f"Could not import {module_path} (item: {item_name}) from any context: {e}") + + +def setup_backend_paths(): + """Setup paths to ensure backend modules can be imported correctly.""" + current_dir = os.getcwd() + + # If we're in the root directory, add backend to path + if os.path.exists(os.path.join(current_dir, 'backend')): + backend_path = os.path.join(current_dir, 'backend') + if backend_path not in sys.path: + sys.path.insert(0, backend_path) + + # If we're in backend directory, add parent (root) to path + if current_dir.endswith('backend'): + root_path = os.path.dirname(current_dir) + if root_path not in sys.path: + sys.path.insert(0, root_path) + + +# Auto-setup paths when this module is imported +setup_backend_paths() \ No newline at end of file diff --git a/backend/utils/job_recommenders/__init__.py b/backend/utils/job_recommenders/__init__.py index 9d0433f8..bbc47757 100644 --- a/backend/utils/job_recommenders/__init__.py +++ b/backend/utils/job_recommenders/__init__.py @@ -25,7 +25,7 @@ """ # Import main functions from the advanced recommender -from .advanced import ( +from backend.utils.job_recommenders.advanced import ( search_and_get_jobs_for_user, extract_user_profile as extract_advanced_profile, search_jobs_from_database as advanced_search_jobs_from_database, @@ -34,7 +34,7 @@ ) # Import main functions from the simple recommender -from .simple import ( +from backend.utils.job_recommenders.simple import ( get_job_recommendations, search_jobs_from_database as simple_search_jobs_from_database, analyze_job_match_with_gemini, @@ -43,7 +43,7 @@ ) # Import pipeline functions -from .pipeline import ( +from backend.utils.job_recommenders.pipeline import ( init_db, get_latest_jobs, search_jobs, @@ -60,7 +60,7 @@ ) # Import user recommender functions -from .user_recommender import ( +from backend.utils.job_recommenders.user_recommender import ( get_recommendations_for_user, refresh_recommendations_for_user, mark_job_selected, diff --git a/backend/utils/job_recommenders/advanced.py b/backend/utils/job_recommenders/advanced.py index 60282afd..b3e6d4f9 100644 --- a/backend/utils/job_recommenders/advanced.py +++ b/backend/utils/job_recommenders/advanced.py @@ -38,11 +38,11 @@ # --- Attempt to import User model, provide fallback --- User = None try: - from models.all_models import User as DBUser # Rename to avoid conflict + from backend.models.all_models import User as DBUser # Rename to avoid conflict User = DBUser # Use the database user model if import succeeds - print("Successfully imported User model from models.all_models") + print("Successfully imported User model from backend.models.all_models") except ImportError: - print("Could not import User from models.all_models. Using fallback class for standalone run.") + print("Could not import User from backend.models.all_models. Using fallback class for standalone run.") # If running standalone without Flask context, define a simple User class class FallbackUser: """Simple User class for standalone testing""" @@ -76,7 +76,7 @@ def get_id(self): return str(self.id) logger = logging.getLogger(__name__) # --- API Configurations --- -from utils.gemini_api_manager import configure_gemini_api, has_gemini_api_keys, rotate_api_key +from backend.utils.gemini_api_manager import configure_gemini_api, has_gemini_api_keys, rotate_api_key RAPID_API_KEY = os.environ.get('RAPID_API_KEY', '') # --- Gemini Setup --- diff --git a/backend/utils/job_recommenders/simple.py b/backend/utils/job_recommenders/simple.py index 0fdb652e..6ee70641 100644 --- a/backend/utils/job_recommenders/simple.py +++ b/backend/utils/job_recommenders/simple.py @@ -15,10 +15,10 @@ import sys from typing import List, Dict, Any from dotenv import load_dotenv -from models.job_recommendation import JobRecommendation +from backend.models.job_recommendation import JobRecommendation from flask_login import current_user -from models.db import db -from models import User +from backend.models.db import db +from backend.models import User # Add the project root to the Python path when running standalone sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) @@ -31,7 +31,7 @@ logger = logging.getLogger(__name__) # Configure Gemini API with multiple key support -from utils.gemini_api_manager import configure_gemini_api, has_gemini_api_keys, rotate_api_key +from backend.utils.gemini_api_manager import configure_gemini_api, has_gemini_api_keys, rotate_api_key # Only import and configure if keys are available if has_gemini_api_keys(): @@ -854,8 +854,8 @@ def recommend_jobs_for_user_id(user_id: int, job_title: str = None, location: st try: # This import is here to avoid circular imports from flask import current_app - from models.db import db - from models import User + from backend.models.db import db + from backend.models import User with current_app.app_context(): user = User.query.get(user_id) diff --git a/backend/utils/profile_utils.py b/backend/utils/profile_utils.py index 9e795a76..4b8cb144 100644 --- a/backend/utils/profile_utils.py +++ b/backend/utils/profile_utils.py @@ -5,7 +5,7 @@ from datetime import datetime as dt from dateutil import parser as date_parser from sqlalchemy.orm import RelationshipProperty -from models.all_models import User, Experience, Project, PortfolioLink, ApplicantValue, Skill, Language, Certification, DesiredJobTitle +from backend.models.all_models import User, Experience, Project, PortfolioLink, ApplicantValue, Skill, Language, Certification, DesiredJobTitle def parse_date(date_string): @@ -119,7 +119,7 @@ def update_user_json_fields(user, data): def update_user_relationships(user, data, relationship_type): """Update user relationship fields (experiences, projects, etc.)""" - from models.db import db + from backend.models.db import db if relationship_type == 'experiences' and 'experience' in data: # Clear existing experiences @@ -199,6 +199,7 @@ def calculate_profile_completion(user): completed_fields += 2 # Experience (weight: 3) + from backend.models.all_models import Experience, Project experiences = Experience.query.filter_by(user_id=user.id).all() if experiences: completed_fields += 3 diff --git a/backend/utils/resume_utils.py b/backend/utils/resume_utils.py index 251358f9..b5a79211 100644 --- a/backend/utils/resume_utils.py +++ b/backend/utils/resume_utils.py @@ -10,10 +10,10 @@ import PyPDF2 import docx2txt -from models.db import db -from models.all_models import User -from utils.document_parser import parse_pdf -from services.resume_keyword_service import ResumeKeywordService +from backend.models.db import db +from backend.models.all_models import User +from backend.utils.document_parser import parse_pdf +from backend.services.resume_keyword_service import ResumeKeywordService def extract_text_from_resume(file_path): @@ -81,7 +81,7 @@ def process_resume_file(file): try: from flask_login import current_user - from .profile_utils import parse_date + from backend.utils.profile_utils import parse_date # Add detailed debugging for parsed data current_app.logger.info(f"DEBUG: Parsed data keys: {list(parsed_data.keys()) if parsed_data else 'None'}") @@ -114,7 +114,7 @@ def process_resume_file(file): def _update_user_from_parsed_data(user, parsed_data, resume_text): """Update user profile with data parsed from resume""" import json - from .profile_utils import parse_date + from backend.utils.profile_utils import parse_date if not parsed_data: return @@ -161,7 +161,7 @@ def _update_user_from_parsed_data(user, parsed_data, resume_text): # Process experience if parsed_data.get("experience"): - from models.all_models import Experience + from backend.models.all_models import Experience # Clear existing experiences Experience.query.filter_by(user_id=user.id).delete() @@ -181,7 +181,7 @@ def _update_user_from_parsed_data(user, parsed_data, resume_text): # Process projects if parsed_data.get("projects"): - from models.all_models import Project + from backend.models.all_models import Project # Clear existing projects Project.query.filter_by(user_id=user.id).delete() From 80e4a96c913c515271e96474fad1247b38167b02 Mon Sep 17 00:00:00 2001 From: lifee77 Date: Fri, 4 Jul 2025 22:25:18 -0500 Subject: [PATCH 05/19] updated cursor rules --- .cursor/rules/project-rules.md | 264 ++++++++++++++++++++++++++++----- 1 file changed, 230 insertions(+), 34 deletions(-) diff --git a/.cursor/rules/project-rules.md b/.cursor/rules/project-rules.md index 35d6693d..b2ee529a 100644 --- a/.cursor/rules/project-rules.md +++ b/.cursor/rules/project-rules.md @@ -1,7 +1,9 @@ # InstantApply Project Rules ## Project Overview -InstantApply is a modern job application platform that uses AI to streamline the job application process. The system consists of a React frontend and Flask backend with SQLAlchemy ORM, integrated with Google Gemini AI for intelligent response generation and Playwright for browser automation. +InstantApply is a modern job application platform that uses AI to streamline the job application process. The system consists of a React frontend **served directly from the Flask backend** and Flask backend with SQLAlchemy ORM, integrated with Google Gemini AI for intelligent response generation and Playwright for browser automation. + +**CRITICAL DEPLOYMENT ARCHITECTURE**: This is a **unified Flask application** that serves both the React frontend and API routes. The React app is built and served as static files through Flask, NOT as a separate application. ## Memory Bank System I am Cursor, an expert software engineer with a unique characteristic: my memory resets completely between sessions. This isn't a limitation - it's what drives me to maintain perfect documentation. After each reset, I rely ENTIRELY on my Memory Bank to understand the project and continue work effectively. I MUST read ALL memory bank files at the start of EVERY task - this is not optional. @@ -14,36 +16,77 @@ The Memory Bank is located in `.cursor/memory-bank/` and consists of: - `techContext.md` - Technologies used and development setup - `progress.md` - What works, what's left to build, current status +## Application Architecture & Deployment + +### Unified Flask-React Architecture +- **Single Application**: Flask serves both API routes AND the built React frontend +- **Static File Serving**: React build output is served through Flask's static file handler +- **Production Ready**: Designed for Azure Web Apps deployment with a single entry point +- **Route Handling**: Flask handles 404s by serving React's `index.html` for frontend routing +- **API Prefix**: All backend APIs use `/api/` prefix to distinguish from frontend routes + +### Azure Web Apps Deployment +- **Entry Point**: Root `app.py` handles path setup and WSGI configuration +- **Static Files**: React build files copied to `backend/static/` during deployment +- **Environment**: Production configuration optimized for Azure App Service +- **Scaling**: Designed to work with Azure's auto-scaling features +- **Health Checks**: `/health` endpoint for Azure monitoring +- **File Uploads**: Configured for Azure file system with fallback to temp directories + +### Import Structure & Path Resolution +**CRITICAL**: All imports must use `backend.` prefix when running from project root (production). + +```python +# CORRECT - Works in both dev and production +from backend.models.all_models import User +from backend.services.resume_keyword_service import ResumeKeywordService +from backend.utils.profile_utils import parse_date + +# INCORRECT - Breaks when running from root directory +from models.all_models import User # ❌ Fails in production +from services.resume_keyword_service import ResumeKeywordService # ❌ Fails in production +``` + +**Running the Application**: +- **Production/Azure**: `python app.py` (from project root) +- **Development**: `cd backend && python -m flask run` or `python app.py` (from root) +- **Path Setup**: Root `app.py` handles all path resolution automatically + ## Technology Stack & Patterns -### Frontend (React) +### Frontend (React) - Served from Flask - React 18+ with functional components and hooks +- **Build Process**: Webpack builds to `backend/static/` directory +- **Serving**: Flask serves built React files as static content +- **Routing**: React Router with Flask fallback for SPA routing - Component structure: `/react-frontend/src/components/` - Use JSX syntax, modern ES6+ features - CSS modules or styled-components for styling -- Webpack configuration in place for bundling - Testing with React Testing Library and Jest -### Backend (Flask) -- Flask with SQLAlchemy ORM -- RESTful API design patterns -- Route organization in `/backend/routes/` +### Backend (Flask) - Unified Application +- Flask with SQLAlchemy ORM serving both API and frontend +- RESTful API design patterns with `/api/` prefix +- **Modular Route Organization**: Organized by feature in subfolders - Model definitions in `/backend/models/` - Services layer in `/backend/services/` - Controllers in `/backend/controllers/` - Configuration management via `config.py` +- **Session Management**: Configured for Azure with proper cookie settings ### Database - SQLAlchemy ORM with Flask-Migrate -- SQLite for development, PostgreSQL for production +- **Production**: PostgreSQL on Azure +- **Development**: SQLite with automatic path resolution - Migration files in `/backend/migrations/` - Use Alembic for database versioning ### AI Integration - Google Gemini AI for intelligent response generation -- Multiple API key management system +- **Multi-key Management**: Rotation system for rate limit handling - NLP processing with spaCy for resume parsing - Model configurations in `gemini_models.py` +- **Error Handling**: Graceful degradation when AI services unavailable ### Browser Automation - Playwright for automated job application processing @@ -55,7 +98,26 @@ The Memory Bank is located in `.cursor/memory-bank/` and consists of: ### Coding - Please do not delete existing functionality. If a module is changing entirely, move the old files to archive/ - Do not create unnecessary files if the work can be done without that. -- Make the codebase readable. Limit file length to 500 lines or fewer. +- **Make the codebase readable. Limit file length to 500 lines or fewer.** +- **Use modular organization**: Split large files into focused, maintainable modules + +### Import Guidelines (CRITICAL) +```python +# ✅ ALWAYS use backend. prefix for cross-module imports +from backend.models.db import db +from backend.models.all_models import User, Experience, Project +from backend.utils.profile_utils import calculate_profile_completion +from backend.services.resume_keyword_service import ResumeKeywordService +from backend.routes.profile.main import profile_main_bp + +# ✅ Relative imports only within the same package +from .resume import resume_bp # Only within routes/profile/ package +from .utils import helper_function # Only within same directory + +# ❌ NEVER use bare imports that will break in production +from models.all_models import User # Breaks when running from root +from services.resume_service import ResumeService # Breaks in Azure +``` ### Code Quality - Follow PEP 8 for Python code @@ -78,7 +140,7 @@ The Memory Bank is located in `.cursor/memory-bank/` and consists of: - SQL injection prevention through ORM - CORS configuration for API endpoints - Rate limiting for API calls -- Secure file upload handling +- **Secure file upload handling with Azure-compatible paths** ### Performance - Database query optimization @@ -89,43 +151,114 @@ The Memory Bank is located in `.cursor/memory-bank/` and consists of: ## File Structure Patterns -### Frontend Organization +### Project Root Structure +``` +InstantApply/ +├── app.py # 🚀 Main entry point for production/Azure +├── backend/ # Flask application directory +│ ├── app.py # Flask app factory +│ ├── config.py # Environment configurations +│ ├── routes/ # 📁 Modular route organization +│ │ ├── profile/ # 📁 Profile feature routes +│ │ │ ├── __init__.py # Registration function +│ │ │ ├── main.py # Core profile routes +│ │ │ ├── resume.py # Resume upload routes +│ │ │ └── keywords.py # Keyword management routes +│ │ ├── auth.py # Authentication routes +│ │ ├── jobs.py # Job-related routes +│ │ └── admin.py # Admin routes +│ ├── models/ # Database models +│ ├── services/ # Business logic layer +│ ├── utils/ # Utility functions +│ │ ├── profile_utils.py # Profile data processing +│ │ ├── resume_utils.py # Resume processing +│ │ └── import_utils.py # Path resolution utilities +│ ├── static/ # 🎯 React build output served here +│ └── uploads/ # File upload directory +├── react-frontend/ # React development directory +│ ├── src/ # React source code +│ ├── public/ # Public assets +│ ├── package.json # Node dependencies for building +│ └── webpack.config.js # Build configuration +└── azure-deploy/ # Azure-specific deployment files +``` + +### Modular Route Organization Pattern +**NEW**: Routes are organized by feature in subfolders for better maintainability: + +```python +# backend/routes/profile/__init__.py +def register_profile_routes(app): + """Register all profile-related routes with the Flask app""" + profile_bp = Blueprint('profile', __name__) + + # Register sub-blueprints + profile_bp.register_blueprint(profile_main_bp) + profile_bp.register_blueprint(resume_bp) + profile_bp.register_blueprint(keyword_bp) + + # Register with app + app.register_blueprint(profile_bp, url_prefix='/profile') + app.register_blueprint(profile_bp, url_prefix='/api/profile', name='api_profile') +``` + +### Frontend Organization (Built & Served by Flask) ``` -react-frontend/ -├── src/ -│ ├── components/ # Reusable UI components -│ ├── pages/ # Page-level components -│ ├── hooks/ # Custom React hooks -│ ├── services/ # API service functions -│ ├── utils/ # Utility functions -│ ├── context/ # React context providers -│ └── styles/ # Global styles +react-frontend/src/ +├── components/ # Reusable UI components +├── pages/ # Page-level components +├── hooks/ # Custom React hooks +├── services/ # API service functions (calls to /api/) +├── utils/ # Utility functions +├── context/ # React context providers +└── styles/ # Global styles + +# Build output goes to: +backend/static/ # Served by Flask in production ``` ### Backend Organization ``` backend/ -├── routes/ # API route definitions +├── routes/ # 📁 Feature-based route organization +│ ├── profile/ # 📁 Profile feature module +│ ├── auth.py # Authentication routes +│ └── jobs.py # Job-related routes ├── models/ # SQLAlchemy models ├── services/ # Business logic layer ├── controllers/ # Request/response handling -├── utils/ # Utility functions +├── utils/ # Utility functions & helpers ├── forms/ # Form validation classes └── migrations/ # Database migrations ``` ## API Design Standards +### URL Structure +``` +# Frontend Routes (served by React via Flask) +/ # React app home page +/profile # Profile page (React) +/jobs # Jobs page (React) +/login # Login page (React) + +# API Routes (JSON responses) +/api/profile/ # Profile API endpoints +/api/auth/ # Authentication API +/api/jobs/ # Jobs API +/health # Health check for Azure +``` + ### RESTful Conventions - Use appropriate HTTP methods (GET, POST, PUT, DELETE) -- Consistent URL patterns: `/api/v1/resource` +- **API Prefix**: All APIs use `/api/` prefix: `/api/v1/resource` - JSON request/response format - Standard HTTP status codes - Error responses with consistent structure: ```json { "error": "Error message", - "code": "ERROR_CODE", + "code": "ERROR_CODE", "details": {} } ``` @@ -142,20 +275,35 @@ backend/ ## Component Development -### React Components +### React Components (Served by Flask) - Use functional components with hooks - Props validation with PropTypes or TypeScript - Consistent naming conventions (PascalCase for components) - Component composition over inheritance - Custom hooks for shared logic - Context for global state management +- **API Calls**: Use `/api/` prefix for all backend communication -### Flask Routes +### Flask Routes (Modular Organization) +- **Feature-based blueprints**: Group related routes in subfolders - Blueprint organization for route grouping - Decorator pattern for authentication/authorization - Request validation using Flask-WTF forms - Consistent error handling patterns - Database session management +- **Import Pattern**: Always use `backend.` prefix + +### Route Registration Pattern +```python +# ✅ CORRECT: Modular registration +def register_profile_routes(app): + from .profile import register_profile_routes + register_profile_routes(app) + +# ✅ CORRECT: In app.py +from backend.routes.profile import register_profile_routes +register_profile_routes(app) +``` ## AI Integration Guidelines @@ -164,7 +312,7 @@ backend/ - Handle API failures gracefully - Cache responses when appropriate - Validate AI-generated content -- Multiple API key rotation system +- **Multi-key rotation system for production reliability** - Content filtering and safety checks ### Resume Processing @@ -172,24 +320,59 @@ backend/ - Extract structured data consistently - Error handling for malformed documents - Privacy considerations for uploaded files -- Temporary file cleanup +- **Temporary file cleanup with Azure-compatible paths** ## Deployment & Production +### Azure Web Apps Configuration +- **Entry Point**: `app.py` at project root +- **Static Files**: React build copied to `backend/static/` +- **Environment Variables**: Configured for Azure App Service +- **Database**: PostgreSQL connection string via environment +- **File Uploads**: Azure-compatible with fallback strategies +- **Health Monitoring**: `/health` endpoint for Azure probes + ### Environment Management -- Separate configurations for dev/staging/prod +- **Development**: SQLite database, local file uploads +- **Production**: PostgreSQL on Azure, Azure file system - Environment variable validation -- Docker containerization support +- **Path Resolution**: Automatic based on execution context - Azure deployment configurations in `/azure-deploy/` ### Build Process -- React build optimization -- Static file serving from Flask +- **React Build**: `npm run build` outputs to `backend/static/` +- **Python Dependencies**: `requirements.txt` for Azure - Database migration automation -- Health check endpoints +- **Single Application**: One Flask app serves everything + +### Running the Application +```bash +# 🚀 Production (Azure) - from project root +python app.py + +# 🛠️ Development - from project root +python app.py + +# 🛠️ Development - from backend directory +cd backend && python -m flask run + +# ⚠️ All methods automatically handle path resolution +``` ## Development Workflow +### Import Best Practices +1. **Always use `backend.` prefix** for cross-module imports +2. **Test from root directory** to ensure production compatibility +3. **Use relative imports** only within the same package +4. **Never use bare module names** that will break in production + +### Route Development +1. **Organize by feature** in subfolders under `routes/` +2. **Create registration functions** for each feature module +3. **Limit file size** to 500 lines maximum +4. **Use modular blueprints** for maintainability + ### Git Practices - Feature branch workflow - Meaningful commit messages @@ -211,8 +394,14 @@ Update Memory Bank when: 4. When context needs clarification for future development 5. When adding new integrations or external services 6. When refactoring major components or services +7. **When changing import structure or deployment configuration** +8. **When modifying route organization or Flask serving patterns** ## Project Intelligence Notes +- **Flask-React Unity**: This is NOT a separate frontend/backend - it's a unified Flask application +- **Azure Optimization**: Built specifically for Azure Web Apps deployment +- **Import Criticality**: Wrong imports will break production deployment +- **Route Modularity**: New pattern for maintainable large-scale Flask applications - The project uses a multi-key system for Gemini AI to handle rate limits - Browser automation is critical for the job application process - Resume parsing accuracy is essential for user experience @@ -220,4 +409,11 @@ Update Memory Bank when: - User role management is important for access control - The application serves both individual users and enterprise clients +## Critical Reminders +🚨 **DEPLOYMENT CRITICAL**: +- Always use `backend.` imports for production compatibility +- React is served BY Flask, not alongside it +- Azure expects single entry point (`app.py` at root) +- Test all changes from project root directory + Remember: I begin completely fresh after every memory reset. The Memory Bank is my only link to previous work and must be maintained with precision and clarity. \ No newline at end of file From 4d9c2a596b0baa30de667b5b1fb5df59e4d6794b Mon Sep 17 00:00:00 2001 From: lifee77 Date: Fri, 4 Jul 2025 22:44:27 -0500 Subject: [PATCH 06/19] brand new profile system --- backend/forms/profile.py | 370 ++++++++- backend/routes/profile/__init__.py | 4 +- backend/routes/profile/main.py | 426 ++++++++++- backend/routes/profile/sections.py | 718 ++++++++++++++++++ backend/utils/resume_utils.py | 216 +++++- .../profile/DragDropResumeUpload.jsx | 490 ++++++++++++ .../components/profile/EditableSection.jsx | 456 +++++++++++ .../components/profile/ExperienceSection.jsx | 235 ++++++ .../components/profile/ProjectsSection.jsx | 275 +++++++ react-frontend/src/pages/ProfileV2.jsx | 525 +++++++++++++ react-frontend/src/services/api.js | 111 +++ 11 files changed, 3763 insertions(+), 63 deletions(-) create mode 100644 backend/routes/profile/sections.py create mode 100644 react-frontend/src/components/profile/DragDropResumeUpload.jsx create mode 100644 react-frontend/src/components/profile/EditableSection.jsx create mode 100644 react-frontend/src/components/profile/ExperienceSection.jsx create mode 100644 react-frontend/src/components/profile/ProjectsSection.jsx create mode 100644 react-frontend/src/pages/ProfileV2.jsx diff --git a/backend/forms/profile.py b/backend/forms/profile.py index a9ca28d6..f4d39e09 100644 --- a/backend/forms/profile.py +++ b/backend/forms/profile.py @@ -1,12 +1,376 @@ from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileAllowed -from wtforms import StringField, TextAreaField, SubmitField, BooleanField, SelectField -from wtforms.validators import DataRequired, Email, Optional, URL +from wtforms import StringField, TextAreaField, SubmitField, BooleanField, SelectField, DateField, IntegerField +from wtforms.validators import DataRequired, Email, Optional, URL, Length, ValidationError, Regexp +import re class ProfileForm(FlaskForm): - name = StringField('Name', validators=[DataRequired()]) + # Basic Information + name = StringField('Full Name', validators=[ + DataRequired(message="Name is required"), + Length(min=2, max=100, message="Name must be between 2 and 100 characters") + ]) + + first_name = StringField('First Name', validators=[ + Optional(), + Length(min=1, max=50, message="First name must be between 1 and 50 characters") + ]) + + last_name = StringField('Last Name', validators=[ + Optional(), + Length(min=1, max=50, message="Last name must be between 1 and 50 characters") + ]) + + email = StringField('Email Address', validators=[ + Optional(), + Email(message="Please enter a valid email address"), + Length(max=254, message="Email address is too long") + ]) + + phone_number = StringField('Phone Number', validators=[ + Optional(), + Length(min=10, max=20, message="Phone number must be between 10 and 20 characters"), + Regexp(r'^[\+]?[1-9][\d\s\-\(\)\.]{8,20}$', message="Please enter a valid phone number") + ]) + + location = StringField('Location', validators=[ + Optional(), + Length(max=200, message="Location must be under 200 characters") + ]) + + # Professional Information + professional_summary = TextAreaField('Professional Summary', validators=[ + Optional(), + Length(max=2000, message="Professional summary must be under 2000 characters") + ]) + + # Resume Upload resume = FileField('Resume', validators=[ FileAllowed(['pdf', 'docx', 'doc', 'txt'], 'Only PDF, Word, or text documents are allowed.') ]) + + # Skills and Experience + skills = TextAreaField('Skills', validators=[ + Optional(), + Length(max=2000, message="Skills must be under 2000 characters") + ]) + + years_of_experience = SelectField('Years of Experience', validators=[Optional()], choices=[ + ('', 'Select years of experience'), + ('0-2 years', '0-2 years'), + ('3-5 years', '3-5 years'), + ('6-10 years', '6-10 years'), + ('10+ years', '10+ years') + ]) + + # Education + education_level = SelectField('Education Level', validators=[Optional()], choices=[ + ('', 'Select education level'), + ('High School', 'High School'), + ('Associate\'s', 'Associate\'s Degree'), + ('Bachelor\'s', 'Bachelor\'s Degree'), + ('Master\'s', 'Master\'s Degree'), + ('PhD', 'PhD'), + ('Other', 'Other') + ]) + + graduation_date = DateField('Graduation Date', validators=[Optional()]) + + # Work Preferences + desired_salary_range = SelectField('Desired Salary Range', validators=[Optional()], choices=[ + ('', 'Select salary range'), + ('Under $40,000', 'Under $40,000'), + ('$40,000 - $60,000', '$40,000 - $60,000'), + ('$60,000 - $80,000', '$60,000 - $80,000'), + ('$80,000 - $100,000', '$80,000 - $100,000'), + ('$100,000 - $120,000', '$100,000 - $120,000'), + ('$120,000+', '$120,000+') + ]) + + work_mode_preference = SelectField('Work Mode Preference', validators=[Optional()], choices=[ + ('', 'Select work mode'), + ('Remote', 'Remote'), + ('Hybrid', 'Hybrid'), + ('On-site', 'On-site'), + ('No preference', 'No preference') + ]) + + remote_preference = SelectField('Remote Work Preference', validators=[Optional()], choices=[ + ('', 'Select remote preference'), + ('Remote only', 'Remote only'), + ('Hybrid preferred', 'Hybrid preferred'), + ('On-site preferred', 'On-site preferred'), + ('No preference', 'No preference') + ]) + + # Personal Information + willing_to_relocate = BooleanField('Willing to Relocate') + needs_sponsorship = BooleanField('Needs Visa Sponsorship') + + # Social Links + linkedin_url = StringField('LinkedIn URL', validators=[ + Optional(), + URL(message="Please enter a valid LinkedIn URL"), + Length(max=500, message="LinkedIn URL is too long") + ]) + + github_url = StringField('GitHub URL', validators=[ + Optional(), + URL(message="Please enter a valid GitHub URL"), + Length(max=500, message="GitHub URL is too long") + ]) + + # Career Information + career_goals = TextAreaField('Career Goals', validators=[ + Optional(), + Length(max=2000, message="Career goals must be under 2000 characters") + ]) + + biggest_achievement = TextAreaField('Biggest Professional Achievement', validators=[ + Optional(), + Length(max=2000, message="Achievement description must be under 2000 characters") + ]) + + # Form Controls csrf_token = StringField('CSRF Token') submit = SubmitField('Save Profile') + + def validate_linkedin_url(self, field): + """Custom validator for LinkedIn URL format""" + if field.data: + if not re.match(r'^https?://(www\.)?linkedin\.com/in/[\w-]+/?$', field.data): + raise ValidationError('Please enter a valid LinkedIn profile URL (e.g., https://linkedin.com/in/yourname)') + + def validate_github_url(self, field): + """Custom validator for GitHub URL format""" + if field.data: + if not re.match(r'^https?://(www\.)?github\.com/[\w-]+/?$', field.data): + raise ValidationError('Please enter a valid GitHub profile URL (e.g., https://github.com/yourusername)') + + def validate_skills(self, field): + """Custom validator for skills format""" + if field.data: + # Check if skills are properly formatted (comma-separated) + skills_list = [skill.strip() for skill in field.data.split(',') if skill.strip()] + if len(skills_list) > 50: + raise ValidationError('Please limit skills to 50 items or fewer') + + # Check for extremely long skill names + for skill in skills_list: + if len(skill) > 100: + raise ValidationError('Each skill should be under 100 characters') + + +class ExperienceForm(FlaskForm): + """Form for adding/editing work experience""" + title = StringField('Job Title', validators=[ + DataRequired(message="Job title is required"), + Length(min=1, max=200, message="Job title must be between 1 and 200 characters") + ]) + + company = StringField('Company', validators=[ + DataRequired(message="Company name is required"), + Length(min=1, max=200, message="Company name must be between 1 and 200 characters") + ]) + + location = StringField('Location', validators=[ + Optional(), + Length(max=200, message="Location must be under 200 characters") + ]) + + start_date = DateField('Start Date', validators=[ + Optional() + ]) + + end_date = DateField('End Date', validators=[ + Optional() + ]) + + is_current = BooleanField('This is my current job') + + description = TextAreaField('Description', validators=[ + Optional(), + Length(max=2000, message="Description must be under 2000 characters") + ]) + + submit = SubmitField('Save Experience') + + def validate_end_date(self, field): + """Validate that end date is after start date""" + if field.data and self.start_date.data: + if field.data < self.start_date.data: + raise ValidationError('End date must be after start date') + + +class ProjectForm(FlaskForm): + """Form for adding/editing projects""" + name = StringField('Project Name', validators=[ + DataRequired(message="Project name is required"), + Length(min=1, max=200, message="Project name must be between 1 and 200 characters") + ]) + + description = TextAreaField('Description', validators=[ + Optional(), + Length(max=2000, message="Description must be under 2000 characters") + ]) + + technologies = StringField('Technologies Used', validators=[ + Optional(), + Length(max=500, message="Technologies must be under 500 characters") + ]) + + url = StringField('Project URL', validators=[ + Optional(), + URL(message="Please enter a valid URL"), + Length(max=500, message="URL is too long") + ]) + + role = StringField('Your Role', validators=[ + Optional(), + Length(max=200, message="Role must be under 200 characters") + ]) + + start_date = DateField('Start Date', validators=[ + Optional() + ]) + + end_date = DateField('End Date', validators=[ + Optional() + ]) + + submit = SubmitField('Save Project') + + def validate_end_date(self, field): + """Validate that end date is after start date""" + if field.data and self.start_date.data: + if field.data < self.start_date.data: + raise ValidationError('End date must be after start date') + + +class EducationForm(FlaskForm): + """Form for adding/editing education""" + degree = StringField('Degree', validators=[ + DataRequired(message="Degree is required"), + Length(min=1, max=200, message="Degree must be between 1 and 200 characters") + ]) + + school = StringField('School/University', validators=[ + DataRequired(message="School name is required"), + Length(min=1, max=200, message="School name must be between 1 and 200 characters") + ]) + + location = StringField('Location', validators=[ + Optional(), + Length(max=200, message="Location must be under 200 characters") + ]) + + graduation_date = DateField('Graduation Date', validators=[ + Optional() + ]) + + gpa = StringField('GPA', validators=[ + Optional(), + Length(max=10, message="GPA must be under 10 characters"), + Regexp(r'^[0-4]\.[0-9]{1,2}$|^[0-4]$', message="Please enter a valid GPA (e.g., 3.85)") + ]) + + major = StringField('Major/Field of Study', validators=[ + Optional(), + Length(max=200, message="Major must be under 200 characters") + ]) + + submit = SubmitField('Save Education') + + +class ValidationHelper: + """Helper class for custom validation functions""" + + @staticmethod + def validate_profile_data(data): + """Validate profile data from API requests""" + errors = {} + + # Name validation + if 'name' in data: + if not data['name'] or len(data['name'].strip()) < 2: + errors['name'] = 'Name must be at least 2 characters long' + elif len(data['name']) > 100: + errors['name'] = 'Name must be under 100 characters' + + # Email validation + if 'email' in data and data['email']: + email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + if not re.match(email_pattern, data['email']): + errors['email'] = 'Please enter a valid email address' + + # Phone validation + if 'phone_number' in data and data['phone_number']: + phone_pattern = r'^[\+]?[1-9][\d\s\-\(\)\.]{8,20}$' + if not re.match(phone_pattern, data['phone_number']): + errors['phone_number'] = 'Please enter a valid phone number' + + # LinkedIn URL validation + if 'linkedin_url' in data and data['linkedin_url']: + linkedin_pattern = r'^https?://(www\.)?linkedin\.com/in/[\w-]+/?$' + if not re.match(linkedin_pattern, data['linkedin_url']): + errors['linkedin_url'] = 'Please enter a valid LinkedIn profile URL' + + # GitHub URL validation + if 'github_url' in data and data['github_url']: + github_pattern = r'^https?://(www\.)?github\.com/[\w-]+/?$' + if not re.match(github_pattern, data['github_url']): + errors['github_url'] = 'Please enter a valid GitHub profile URL' + + # Skills validation + if 'skills' in data and data['skills']: + if isinstance(data['skills'], str): + skills_list = [skill.strip() for skill in data['skills'].split(',') if skill.strip()] + if len(skills_list) > 50: + errors['skills'] = 'Please limit skills to 50 items or fewer' + + # Text length validations + text_fields = { + 'professional_summary': 2000, + 'career_goals': 2000, + 'biggest_achievement': 2000, + 'location': 200 + } + + for field, max_length in text_fields.items(): + if field in data and data[field] and len(data[field]) > max_length: + errors[field] = f'{field.replace("_", " ").title()} must be under {max_length} characters' + + return errors + + @staticmethod + def get_user_friendly_error(field_name, error_type): + """Get user-friendly error messages for common validation errors""" + messages = { + 'name': { + 'required': 'Please enter your full name', + 'length': 'Name must be between 2 and 100 characters' + }, + 'email': { + 'invalid': 'Please enter a valid email address', + 'required': 'Email address is required' + }, + 'phone_number': { + 'invalid': 'Please enter a valid phone number (e.g., +1-555-123-4567)', + 'length': 'Phone number must be between 10 and 20 characters' + }, + 'linkedin_url': { + 'invalid': 'Please enter a valid LinkedIn profile URL (e.g., https://linkedin.com/in/yourname)' + }, + 'github_url': { + 'invalid': 'Please enter a valid GitHub profile URL (e.g., https://github.com/yourusername)' + }, + 'skills': { + 'too_many': 'Please limit skills to 50 items or fewer', + 'too_long': 'Each skill should be under 100 characters' + } + } + + if field_name in messages and error_type in messages[field_name]: + return messages[field_name][error_type] + + return f'Please check the {field_name.replace("_", " ")} field' diff --git a/backend/routes/profile/__init__.py b/backend/routes/profile/__init__.py index 8e20ca8e..602c1d19 100644 --- a/backend/routes/profile/__init__.py +++ b/backend/routes/profile/__init__.py @@ -4,6 +4,7 @@ from .main import profile_main_bp from .resume import resume_bp from .keywords import keyword_bp +from .sections import sections_bp from flask import Blueprint @@ -16,6 +17,7 @@ def register_profile_routes(app): profile_bp.register_blueprint(profile_main_bp) profile_bp.register_blueprint(resume_bp) profile_bp.register_blueprint(keyword_bp) + profile_bp.register_blueprint(sections_bp) # Register the main profile blueprint with the app app.register_blueprint(profile_bp, url_prefix='/profile') @@ -25,7 +27,7 @@ def register_profile_routes(app): # Log registration app.logger.info("Profile routes registered successfully") - app.logger.info(f"Profile module routes loaded: main, resume, keywords") + app.logger.info(f"Profile module routes loaded: main, resume, keywords, sections") # For backward compatibility - export the main blueprint diff --git a/backend/routes/profile/main.py b/backend/routes/profile/main.py index 10e31769..5e0e1b50 100644 --- a/backend/routes/profile/main.py +++ b/backend/routes/profile/main.py @@ -9,6 +9,7 @@ from backend.models.db import db from backend.models.all_models import User, Experience, Project +from backend.forms.profile import ValidationHelper from backend.utils.profile_utils import ( update_user_basic_fields, update_user_json_fields, @@ -122,7 +123,7 @@ def api_get_profile(): @profile_main_bp.route('/api', methods=['POST', 'OPTIONS']) @login_required def api_update_profile(): - """API endpoint to update user profile""" + """API endpoint to update user profile with enhanced validation""" # Handle OPTIONS request for CORS preflight if request.method == 'OPTIONS': response = jsonify({'status': 'ok'}) @@ -135,41 +136,149 @@ def api_update_profile(): try: data = request.get_json() if not data: - return jsonify({'success': False, 'error': 'No data provided'}), 400 + return jsonify({ + 'success': False, + 'error': 'No data provided', + 'message': 'Please provide profile data to update' + }), 400 + + # Validate the incoming data + validation_errors = ValidationHelper.validate_profile_data(data) + if validation_errors: + return jsonify({ + 'success': False, + 'error': 'Validation failed', + 'validation_errors': validation_errors, + 'message': 'Please check the highlighted fields and try again' + }), 400 # Re-fetch the current user from the database to ensure it's attached to the session user = User.query.get(current_user.id) if not user: current_app.logger.error(f"Could not find user with ID {current_user.id}") - return jsonify({'success': False, 'error': 'User not found'}), 404 + return jsonify({ + 'success': False, + 'error': 'User not found', + 'message': 'User session expired. Please log in again.' + }), 404 + + # Store original values for potential rollback + original_values = {} + for field in data.keys(): + if hasattr(user, field): + original_values[field] = getattr(user, field) + + try: + # Update different types of fields + update_user_basic_fields(user, data) + update_user_json_fields(user, data) + + # Handle relationship fields + if 'experience' in data: + update_user_relationships(user, data, 'experiences') + if 'projects' in data: + update_user_relationships(user, data, 'projects') + + # Commit changes + db.session.commit() + current_app.logger.info(f"Successfully updated profile for user {user.id}") + + # Calculate new completion percentage + completion_percentage = calculate_profile_completion(user) + + response = jsonify({ + 'success': True, + 'message': 'Profile updated successfully', + 'data': { + 'completion_percentage': completion_percentage, + 'updated_fields': list(data.keys()), + 'last_updated': user.updated_at.isoformat() if user.updated_at else None + } + }) + response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response - # Update different types of fields - update_user_basic_fields(user, data) - update_user_json_fields(user, data) + except Exception as update_error: + # Rollback changes if there's an error during update + db.session.rollback() + current_app.logger.error(f"Error during profile update: {str(update_error)}") + raise update_error + + except Exception as e: + current_app.logger.error(f"Error in api_update_profile: {str(e)}") + current_app.logger.error(traceback.format_exc()) + db.session.rollback() + + # Provide user-friendly error messages + error_message = 'Failed to update profile. Please try again.' + if 'validation' in str(e).lower(): + error_message = 'Please check your input and try again.' + elif 'database' in str(e).lower(): + error_message = 'Database error occurred. Please try again later.' + elif 'network' in str(e).lower(): + error_message = 'Network error occurred. Please check your connection.' - # Handle relationship fields - if 'experience' in data: - update_user_relationships(user, data, 'experiences') - if 'projects' in data: - update_user_relationships(user, data, 'projects') + return jsonify({ + 'success': False, + 'error': f'Failed to update profile: {str(e)}', + 'message': error_message, + 'details': str(e) if current_app.debug else None + }), 500 - # Commit changes - db.session.commit() - current_app.logger.info(f"Successfully updated profile for user {user.id}") +@profile_main_bp.route('/validate', methods=['POST', 'OPTIONS']) +@login_required +def validate_profile_field(): + """API endpoint to validate individual profile fields in real-time""" + # Handle OPTIONS request for CORS preflight + if request.method == 'OPTIONS': + response = jsonify({'status': 'ok'}) + response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') + response.headers['Access-Control-Allow-Headers'] = 'Content-Type,Authorization' + response.headers['Access-Control-Allow-Methods'] = 'POST,OPTIONS' + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response + + try: + data = request.get_json() + if not data or 'field' not in data or 'value' not in data: + return jsonify({ + 'success': False, + 'error': 'Invalid request. Field and value are required.' + }), 400 + + field_name = data['field'] + field_value = data['value'] + + # Validate single field + validation_data = {field_name: field_value} + validation_errors = ValidationHelper.validate_profile_data(validation_data) + + if validation_errors: + return jsonify({ + 'success': False, + 'valid': False, + 'error': validation_errors.get(field_name, 'Validation failed'), + 'field': field_name + }), 200 # Return 200 for field validation responses + response = jsonify({ 'success': True, - 'message': 'Profile updated successfully' + 'valid': True, + 'field': field_name, + 'message': 'Field is valid' }) response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') response.headers['Access-Control-Allow-Credentials'] = 'true' return response except Exception as e: - current_app.logger.error(f"Error in api_update_profile: {str(e)}") - current_app.logger.error(traceback.format_exc()) - db.session.rollback() - return jsonify({'success': False, 'error': f'Failed to update profile: {str(e)}'}), 500 + current_app.logger.error(f"Error in validate_profile_field: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Validation service error' + }), 500 @profile_main_bp.route('/update', methods=['POST', 'OPTIONS']) @@ -194,4 +303,281 @@ def profile_jobs(): return response except Exception as e: current_app.logger.error(f"Error getting profile jobs: {str(e)}") - return jsonify({'success': False, 'error': 'Failed to get jobs'}), 500 \ No newline at end of file + return jsonify({'success': False, 'error': 'Failed to get jobs'}), 500 + + +@profile_main_bp.route('/api/autosave', methods=['POST', 'OPTIONS']) +@login_required +def api_autosave_profile(): + """API endpoint for auto-saving profile changes with conflict resolution""" + # Handle OPTIONS request for CORS preflight + if request.method == 'OPTIONS': + response = jsonify({'status': 'ok'}) + response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') + response.headers['Access-Control-Allow-Headers'] = 'Content-Type,Authorization' + response.headers['Access-Control-Allow-Methods'] = 'POST,OPTIONS' + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response + + try: + data = request.get_json() + if not data: + return jsonify({ + 'success': False, + 'error': 'No data provided', + 'message': 'Please provide profile data to auto-save' + }), 400 + + # Get client timestamp for conflict detection + client_timestamp = data.pop('client_timestamp', None) + + # Validate the incoming data (lighter validation for auto-save) + validation_errors = ValidationHelper.validate_profile_data(data) + if validation_errors: + return jsonify({ + 'success': False, + 'error': 'Validation failed', + 'validation_errors': validation_errors, + 'is_autosave': True # Flag to indicate this is from auto-save + }), 400 + + # Re-fetch the current user from the database + user = User.query.get(current_user.id) + if not user: + current_app.logger.error(f"Could not find user with ID {current_user.id}") + return jsonify({ + 'success': False, + 'error': 'User not found', + 'message': 'User session expired. Please log in again.' + }), 404 + + # Check for conflicts if client timestamp is provided + server_timestamp = user.updated_at.isoformat() if user.updated_at else None + if client_timestamp and server_timestamp: + from datetime import datetime + try: + client_dt = datetime.fromisoformat(client_timestamp.replace('Z', '+00:00')) + server_dt = user.updated_at + + # If server has newer changes (conflict detected) + if server_dt > client_dt: + return jsonify({ + 'success': False, + 'error': 'Conflict detected', + 'message': 'Your profile has been updated from another session. Please refresh to see the latest changes.', + 'conflict': True, + 'server_timestamp': server_timestamp + }), 409 + except Exception as e: + current_app.logger.warning(f"Error parsing timestamps for conflict detection: {str(e)}") + + try: + # Only update fields that have actually changed + changes_made = False + + # Track what fields were updated + updated_fields = [] + + # Update basic fields + for field, value in data.items(): + if hasattr(user, field): + current_value = getattr(user, field) + + # Handle JSON fields + if field in ['experience', 'projects', 'education', 'languages', 'certifications', 'skills', 'desired_job_titles']: + if isinstance(current_value, str): + try: + current_value = json.loads(current_value) if current_value else [] + except: + current_value = [] + + # Compare JSON values + if current_value != value: + setattr(user, field, json.dumps(value) if value else None) + changes_made = True + updated_fields.append(field) + else: + # Compare regular fields + if current_value != value: + setattr(user, field, value) + changes_made = True + updated_fields.append(field) + + if changes_made: + # Update the modified timestamp + from datetime import datetime + user.updated_at = datetime.utcnow() + + # Commit changes + db.session.commit() + current_app.logger.info(f"Auto-saved profile for user {user.id}: {updated_fields}") + + # Calculate new completion percentage + completion_percentage = calculate_profile_completion(user) + + response = jsonify({ + 'success': True, + 'message': 'Profile auto-saved successfully', + 'data': { + 'completion_percentage': completion_percentage, + 'updated_fields': updated_fields, + 'last_updated': user.updated_at.isoformat(), + 'changes_made': len(updated_fields) + }, + 'is_autosave': True + }) + else: + # No changes detected + response = jsonify({ + 'success': True, + 'message': 'No changes detected', + 'data': { + 'completion_percentage': calculate_profile_completion(user), + 'updated_fields': [], + 'last_updated': server_timestamp, + 'changes_made': 0 + }, + 'is_autosave': True + }) + + response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response + + except Exception as update_error: + # Rollback changes if there's an error during update + db.session.rollback() + current_app.logger.error(f"Error during profile auto-save: {str(update_error)}") + raise update_error + + except Exception as e: + current_app.logger.error(f"Error in api_autosave_profile: {str(e)}") + current_app.logger.error(traceback.format_exc()) + db.session.rollback() + + # Provide user-friendly error messages for auto-save + error_message = 'Auto-save failed. Your changes are temporarily stored locally.' + if 'conflict' in str(e).lower(): + error_message = 'Conflict detected. Please refresh to see the latest changes.' + elif 'validation' in str(e).lower(): + error_message = 'Some fields need attention. Please review and save manually.' + + return jsonify({ + 'success': False, + 'error': f'Auto-save failed: {str(e)}', + 'message': error_message, + 'is_autosave': True, + 'details': str(e) if current_app.debug else None + }), 500 + + +@profile_main_bp.route('/api/sync', methods=['POST', 'OPTIONS']) +@login_required +def api_sync_profile(): + """API endpoint to sync local changes with server after conflict resolution""" + # Handle OPTIONS request for CORS preflight + if request.method == 'OPTIONS': + response = jsonify({'status': 'ok'}) + response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') + response.headers['Access-Control-Allow-Headers'] = 'Content-Type,Authorization' + response.headers['Access-Control-Allow-Methods'] = 'POST,OPTIONS' + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response + + try: + data = request.get_json() + if not data: + return jsonify({ + 'success': False, + 'error': 'No data provided', + 'message': 'Please provide profile data to sync' + }), 400 + + # This endpoint forces an update, ignoring timestamps + force_update = data.pop('force_update', False) + + if not force_update: + return jsonify({ + 'success': False, + 'error': 'Force update required for sync', + 'message': 'Please confirm you want to overwrite server changes' + }), 400 + + # Validate the incoming data + validation_errors = ValidationHelper.validate_profile_data(data) + if validation_errors: + return jsonify({ + 'success': False, + 'error': 'Validation failed', + 'validation_errors': validation_errors, + 'message': 'Please fix validation errors before syncing' + }), 400 + + # Re-fetch the current user from the database + user = User.query.get(current_user.id) + if not user: + current_app.logger.error(f"Could not find user with ID {current_user.id}") + return jsonify({ + 'success': False, + 'error': 'User not found', + 'message': 'User session expired. Please log in again.' + }), 404 + + try: + # Force update all provided fields + updated_fields = [] + + # Update different types of fields + update_user_basic_fields(user, data) + update_user_json_fields(user, data) + + # Handle relationship fields + if 'experience' in data: + update_user_relationships(user, data, 'experiences') + updated_fields.append('experience') + if 'projects' in data: + update_user_relationships(user, data, 'projects') + updated_fields.append('projects') + + # Update the modified timestamp + from datetime import datetime + user.updated_at = datetime.utcnow() + + # Commit changes + db.session.commit() + current_app.logger.info(f"Synced profile for user {user.id}: force update") + + # Calculate new completion percentage + completion_percentage = calculate_profile_completion(user) + + response = jsonify({ + 'success': True, + 'message': 'Profile synced successfully', + 'data': { + 'completion_percentage': completion_percentage, + 'updated_fields': list(data.keys()), + 'last_updated': user.updated_at.isoformat(), + 'sync_timestamp': user.updated_at.isoformat() + } + }) + response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response + + except Exception as update_error: + # Rollback changes if there's an error during update + db.session.rollback() + current_app.logger.error(f"Error during profile sync: {str(update_error)}") + raise update_error + + except Exception as e: + current_app.logger.error(f"Error in api_sync_profile: {str(e)}") + current_app.logger.error(traceback.format_exc()) + db.session.rollback() + + return jsonify({ + 'success': False, + 'error': f'Sync failed: {str(e)}', + 'message': 'Failed to sync profile. Please try again.', + 'details': str(e) if current_app.debug else None + }), 500 \ No newline at end of file diff --git a/backend/routes/profile/sections.py b/backend/routes/profile/sections.py new file mode 100644 index 00000000..4ffe05b8 --- /dev/null +++ b/backend/routes/profile/sections.py @@ -0,0 +1,718 @@ +""" +Section APIs for profile sections (experience, projects, education, languages). +Provides add/delete/update operations for better UX. +""" +import json +import time +from flask import Blueprint, request, jsonify, current_app +from flask_login import login_required, current_user +from marshmallow import Schema, fields, validate, ValidationError + +from backend.models.db import db +from backend.models.all_models import User + + +sections_bp = Blueprint('sections', __name__) + + +# Validation schemas +class ExperienceSchema(Schema): + title = fields.Str(required=True, validate=validate.Length(min=1, max=200)) + company = fields.Str(required=True, validate=validate.Length(min=1, max=200)) + location = fields.Str(allow_none=True, validate=validate.Length(max=200)) + start_date = fields.Str(allow_none=True, validate=validate.Length(max=20)) + end_date = fields.Str(allow_none=True, validate=validate.Length(max=20)) + description = fields.Str(allow_none=True, validate=validate.Length(max=2000)) + is_current = fields.Bool(missing=False) + + +class ProjectSchema(Schema): + name = fields.Str(required=True, validate=validate.Length(min=1, max=200)) + description = fields.Str(allow_none=True, validate=validate.Length(max=2000)) + technologies = fields.List(fields.Str(), missing=[]) + url = fields.Str(allow_none=True, validate=validate.Length(max=500)) + role = fields.Str(allow_none=True, validate=validate.Length(max=200)) + start_date = fields.Str(allow_none=True, validate=validate.Length(max=20)) + end_date = fields.Str(allow_none=True, validate=validate.Length(max=20)) + + +class EducationSchema(Schema): + degree = fields.Str(required=True, validate=validate.Length(min=1, max=200)) + school = fields.Str(required=True, validate=validate.Length(min=1, max=200)) + location = fields.Str(allow_none=True, validate=validate.Length(max=200)) + graduation_date = fields.Str(allow_none=True, validate=validate.Length(max=20)) + gpa = fields.Str(allow_none=True, validate=validate.Length(max=10)) + major = fields.Str(allow_none=True, validate=validate.Length(max=200)) + + +class LanguageSchema(Schema): + name = fields.Str(required=True, validate=validate.Length(min=1, max=100)) + proficiency_level = fields.Str(required=True, validate=validate.OneOf([ + "Beginner", "Intermediate", "Advanced", "Native" + ])) + + +class CertificationSchema(Schema): + name = fields.Str(required=True, validate=validate.Length(min=1, max=200)) + issuer = fields.Str(allow_none=True, validate=validate.Length(max=200)) + date = fields.Str(allow_none=True, validate=validate.Length(max=20)) + expiration = fields.Str(allow_none=True, validate=validate.Length(max=20)) + url = fields.Str(allow_none=True, validate=validate.Length(max=500)) + + +# Helper functions +def get_user_section_data(user, section_name): + """Get section data from user profile""" + section_data = getattr(user, section_name, None) + if not section_data: + return [] + + if isinstance(section_data, str): + try: + return json.loads(section_data) + except json.JSONDecodeError: + return [] + + return section_data if isinstance(section_data, list) else [] + + +def update_user_section_data(user, section_name, data): + """Update section data in user profile""" + setattr(user, section_name, json.dumps(data)) + db.session.commit() + + +def generate_section_id(section_data): + """Generate a unique ID for a section entry""" + import uuid + return str(uuid.uuid4()) + + +# Experience APIs +@sections_bp.route('/experience', methods=['GET']) +@login_required +def get_experience(): + """Get all experience entries""" + try: + experience_data = get_user_section_data(current_user, 'experience') + return jsonify({ + 'success': True, + 'data': experience_data + }), 200 + except Exception as e: + current_app.logger.error(f"Error getting experience: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to get experience data' + }), 500 + + +@sections_bp.route('/experience', methods=['POST']) +@login_required +def add_experience(): + """Add a new experience entry""" + try: + schema = ExperienceSchema() + data = schema.load(request.json) + + experience_data = get_user_section_data(current_user, 'experience') + + # Add unique ID and timestamp + data['id'] = generate_section_id(experience_data) + data['created_at'] = json.dumps({"$date": {"$numberLong": str(int(time.time() * 1000))}}) + + experience_data.append(data) + update_user_section_data(current_user, 'experience', experience_data) + + return jsonify({ + 'success': True, + 'message': 'Experience added successfully', + 'data': data + }), 201 + except ValidationError as e: + return jsonify({ + 'success': False, + 'error': 'Validation error', + 'details': e.messages + }), 400 + except Exception as e: + current_app.logger.error(f"Error adding experience: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to add experience' + }), 500 + + +@sections_bp.route('/experience/', methods=['PUT']) +@login_required +def update_experience(experience_id): + """Update an existing experience entry""" + try: + schema = ExperienceSchema() + data = schema.load(request.json) + + experience_data = get_user_section_data(current_user, 'experience') + + # Find and update the experience entry + updated = False + for i, exp in enumerate(experience_data): + if exp.get('id') == experience_id: + data['id'] = experience_id + data['updated_at'] = json.dumps({"$date": {"$numberLong": str(int(time.time() * 1000))}}) + experience_data[i] = data + updated = True + break + + if not updated: + return jsonify({ + 'success': False, + 'error': 'Experience not found' + }), 404 + + update_user_section_data(current_user, 'experience', experience_data) + + return jsonify({ + 'success': True, + 'message': 'Experience updated successfully', + 'data': data + }), 200 + except ValidationError as e: + return jsonify({ + 'success': False, + 'error': 'Validation error', + 'details': e.messages + }), 400 + except Exception as e: + current_app.logger.error(f"Error updating experience: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to update experience' + }), 500 + + +@sections_bp.route('/experience/', methods=['DELETE']) +@login_required +def delete_experience(experience_id): + """Delete an experience entry""" + try: + experience_data = get_user_section_data(current_user, 'experience') + + # Find and remove the experience entry + experience_data = [exp for exp in experience_data if exp.get('id') != experience_id] + + update_user_section_data(current_user, 'experience', experience_data) + + return jsonify({ + 'success': True, + 'message': 'Experience deleted successfully' + }), 200 + except Exception as e: + current_app.logger.error(f"Error deleting experience: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to delete experience' + }), 500 + + +# Project APIs +@sections_bp.route('/projects', methods=['GET']) +@login_required +def get_projects(): + """Get all project entries""" + try: + projects_data = get_user_section_data(current_user, 'projects') + return jsonify({ + 'success': True, + 'data': projects_data + }), 200 + except Exception as e: + current_app.logger.error(f"Error getting projects: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to get projects data' + }), 500 + + +@sections_bp.route('/projects', methods=['POST']) +@login_required +def add_project(): + """Add a new project entry""" + try: + schema = ProjectSchema() + data = schema.load(request.json) + + projects_data = get_user_section_data(current_user, 'projects') + + # Add unique ID and timestamp + data['id'] = generate_section_id(projects_data) + data['created_at'] = json.dumps({"$date": {"$numberLong": str(int(time.time() * 1000))}}) + + projects_data.append(data) + update_user_section_data(current_user, 'projects', projects_data) + + return jsonify({ + 'success': True, + 'message': 'Project added successfully', + 'data': data + }), 201 + except ValidationError as e: + return jsonify({ + 'success': False, + 'error': 'Validation error', + 'details': e.messages + }), 400 + except Exception as e: + current_app.logger.error(f"Error adding project: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to add project' + }), 500 + + +@sections_bp.route('/projects/', methods=['PUT']) +@login_required +def update_project(project_id): + """Update an existing project entry""" + try: + schema = ProjectSchema() + data = schema.load(request.json) + + projects_data = get_user_section_data(current_user, 'projects') + + # Find and update the project entry + updated = False + for i, proj in enumerate(projects_data): + if proj.get('id') == project_id: + data['id'] = project_id + data['updated_at'] = json.dumps({"$date": {"$numberLong": str(int(time.time() * 1000))}}) + projects_data[i] = data + updated = True + break + + if not updated: + return jsonify({ + 'success': False, + 'error': 'Project not found' + }), 404 + + update_user_section_data(current_user, 'projects', projects_data) + + return jsonify({ + 'success': True, + 'message': 'Project updated successfully', + 'data': data + }), 200 + except ValidationError as e: + return jsonify({ + 'success': False, + 'error': 'Validation error', + 'details': e.messages + }), 400 + except Exception as e: + current_app.logger.error(f"Error updating project: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to update project' + }), 500 + + +@sections_bp.route('/projects/', methods=['DELETE']) +@login_required +def delete_project(project_id): + """Delete a project entry""" + try: + projects_data = get_user_section_data(current_user, 'projects') + + # Find and remove the project entry + projects_data = [proj for proj in projects_data if proj.get('id') != project_id] + + update_user_section_data(current_user, 'projects', projects_data) + + return jsonify({ + 'success': True, + 'message': 'Project deleted successfully' + }), 200 + except Exception as e: + current_app.logger.error(f"Error deleting project: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to delete project' + }), 500 + + +# Education APIs +@sections_bp.route('/education', methods=['GET']) +@login_required +def get_education(): + """Get all education entries""" + try: + education_data = get_user_section_data(current_user, 'education') + return jsonify({ + 'success': True, + 'data': education_data + }), 200 + except Exception as e: + current_app.logger.error(f"Error getting education: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to get education data' + }), 500 + + +@sections_bp.route('/education', methods=['POST']) +@login_required +def add_education(): + """Add a new education entry""" + try: + schema = EducationSchema() + data = schema.load(request.json) + + education_data = get_user_section_data(current_user, 'education') + + # Add unique ID and timestamp + data['id'] = generate_section_id(education_data) + data['created_at'] = json.dumps({"$date": {"$numberLong": str(int(time.time() * 1000))}}) + + education_data.append(data) + update_user_section_data(current_user, 'education', education_data) + + return jsonify({ + 'success': True, + 'message': 'Education added successfully', + 'data': data + }), 201 + except ValidationError as e: + return jsonify({ + 'success': False, + 'error': 'Validation error', + 'details': e.messages + }), 400 + except Exception as e: + current_app.logger.error(f"Error adding education: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to add education' + }), 500 + + +@sections_bp.route('/education/', methods=['PUT']) +@login_required +def update_education(education_id): + """Update an existing education entry""" + try: + schema = EducationSchema() + data = schema.load(request.json) + + education_data = get_user_section_data(current_user, 'education') + + # Find and update the education entry + updated = False + for i, edu in enumerate(education_data): + if edu.get('id') == education_id: + data['id'] = education_id + data['updated_at'] = json.dumps({"$date": {"$numberLong": str(int(time.time() * 1000))}}) + education_data[i] = data + updated = True + break + + if not updated: + return jsonify({ + 'success': False, + 'error': 'Education not found' + }), 404 + + update_user_section_data(current_user, 'education', education_data) + + return jsonify({ + 'success': True, + 'message': 'Education updated successfully', + 'data': data + }), 200 + except ValidationError as e: + return jsonify({ + 'success': False, + 'error': 'Validation error', + 'details': e.messages + }), 400 + except Exception as e: + current_app.logger.error(f"Error updating education: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to update education' + }), 500 + + +@sections_bp.route('/education/', methods=['DELETE']) +@login_required +def delete_education(education_id): + """Delete an education entry""" + try: + education_data = get_user_section_data(current_user, 'education') + + # Find and remove the education entry + education_data = [edu for edu in education_data if edu.get('id') != education_id] + + update_user_section_data(current_user, 'education', education_data) + + return jsonify({ + 'success': True, + 'message': 'Education deleted successfully' + }), 200 + except Exception as e: + current_app.logger.error(f"Error deleting education: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to delete education' + }), 500 + + +# Language APIs +@sections_bp.route('/languages', methods=['GET']) +@login_required +def get_languages(): + """Get all language entries""" + try: + languages_data = get_user_section_data(current_user, 'languages') + return jsonify({ + 'success': True, + 'data': languages_data + }), 200 + except Exception as e: + current_app.logger.error(f"Error getting languages: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to get languages data' + }), 500 + + +@sections_bp.route('/languages', methods=['POST']) +@login_required +def add_language(): + """Add a new language entry""" + try: + schema = LanguageSchema() + data = schema.load(request.json) + + languages_data = get_user_section_data(current_user, 'languages') + + # Add unique ID and timestamp + data['id'] = generate_section_id(languages_data) + data['created_at'] = json.dumps({"$date": {"$numberLong": str(int(time.time() * 1000))}}) + + languages_data.append(data) + update_user_section_data(current_user, 'languages', languages_data) + + return jsonify({ + 'success': True, + 'message': 'Language added successfully', + 'data': data + }), 201 + except ValidationError as e: + return jsonify({ + 'success': False, + 'error': 'Validation error', + 'details': e.messages + }), 400 + except Exception as e: + current_app.logger.error(f"Error adding language: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to add language' + }), 500 + + +@sections_bp.route('/languages/', methods=['PUT']) +@login_required +def update_language(language_id): + """Update an existing language entry""" + try: + schema = LanguageSchema() + data = schema.load(request.json) + + languages_data = get_user_section_data(current_user, 'languages') + + # Find and update the language entry + updated = False + for i, lang in enumerate(languages_data): + if lang.get('id') == language_id: + data['id'] = language_id + data['updated_at'] = json.dumps({"$date": {"$numberLong": str(int(time.time() * 1000))}}) + languages_data[i] = data + updated = True + break + + if not updated: + return jsonify({ + 'success': False, + 'error': 'Language not found' + }), 404 + + update_user_section_data(current_user, 'languages', languages_data) + + return jsonify({ + 'success': True, + 'message': 'Language updated successfully', + 'data': data + }), 200 + except ValidationError as e: + return jsonify({ + 'success': False, + 'error': 'Validation error', + 'details': e.messages + }), 400 + except Exception as e: + current_app.logger.error(f"Error updating language: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to update language' + }), 500 + + +@sections_bp.route('/languages/', methods=['DELETE']) +@login_required +def delete_language(language_id): + """Delete a language entry""" + try: + languages_data = get_user_section_data(current_user, 'languages') + + # Find and remove the language entry + languages_data = [lang for lang in languages_data if lang.get('id') != language_id] + + update_user_section_data(current_user, 'languages', languages_data) + + return jsonify({ + 'success': True, + 'message': 'Language deleted successfully' + }), 200 + except Exception as e: + current_app.logger.error(f"Error deleting language: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to delete language' + }), 500 + + +# Certification APIs +@sections_bp.route('/certifications', methods=['GET']) +@login_required +def get_certifications(): + """Get all certification entries""" + try: + certifications_data = get_user_section_data(current_user, 'certifications') + return jsonify({ + 'success': True, + 'data': certifications_data + }), 200 + except Exception as e: + current_app.logger.error(f"Error getting certifications: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to get certifications data' + }), 500 + + +@sections_bp.route('/certifications', methods=['POST']) +@login_required +def add_certification(): + """Add a new certification entry""" + try: + schema = CertificationSchema() + data = schema.load(request.json) + + certifications_data = get_user_section_data(current_user, 'certifications') + + # Add unique ID and timestamp + data['id'] = generate_section_id(certifications_data) + data['created_at'] = json.dumps({"$date": {"$numberLong": str(int(time.time() * 1000))}}) + + certifications_data.append(data) + update_user_section_data(current_user, 'certifications', certifications_data) + + return jsonify({ + 'success': True, + 'message': 'Certification added successfully', + 'data': data + }), 201 + except ValidationError as e: + return jsonify({ + 'success': False, + 'error': 'Validation error', + 'details': e.messages + }), 400 + except Exception as e: + current_app.logger.error(f"Error adding certification: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to add certification' + }), 500 + + +@sections_bp.route('/certifications/', methods=['PUT']) +@login_required +def update_certification(certification_id): + """Update an existing certification entry""" + try: + schema = CertificationSchema() + data = schema.load(request.json) + + certifications_data = get_user_section_data(current_user, 'certifications') + + # Find and update the certification entry + updated = False + for i, cert in enumerate(certifications_data): + if cert.get('id') == certification_id: + data['id'] = certification_id + data['updated_at'] = json.dumps({"$date": {"$numberLong": str(int(time.time() * 1000))}}) + certifications_data[i] = data + updated = True + break + + if not updated: + return jsonify({ + 'success': False, + 'error': 'Certification not found' + }), 404 + + update_user_section_data(current_user, 'certifications', certifications_data) + + return jsonify({ + 'success': True, + 'message': 'Certification updated successfully', + 'data': data + }), 200 + except ValidationError as e: + return jsonify({ + 'success': False, + 'error': 'Validation error', + 'details': e.messages + }), 400 + except Exception as e: + current_app.logger.error(f"Error updating certification: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to update certification' + }), 500 + + +@sections_bp.route('/certifications/', methods=['DELETE']) +@login_required +def delete_certification(certification_id): + """Delete a certification entry""" + try: + certifications_data = get_user_section_data(current_user, 'certifications') + + # Find and remove the certification entry + certifications_data = [cert for cert in certifications_data if cert.get('id') != certification_id] + + update_user_section_data(current_user, 'certifications', certifications_data) + + return jsonify({ + 'success': True, + 'message': 'Certification deleted successfully' + }), 200 + except Exception as e: + current_app.logger.error(f"Error deleting certification: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Failed to delete certification' + }), 500 \ No newline at end of file diff --git a/backend/utils/resume_utils.py b/backend/utils/resume_utils.py index b5a79211..522e7079 100644 --- a/backend/utils/resume_utils.py +++ b/backend/utils/resume_utils.py @@ -144,6 +144,10 @@ def _update_user_from_parsed_data(user, parsed_data, resume_text): if parsed_data.get("github"): user.github_url = parsed_data["github"] + # Enhanced field mapping for better UX + if parsed_data.get("email") and not user.email: + user.email = parsed_data["email"] + # Convert job_titles list to JSON string for database storage if parsed_data.get("job_titles"): user.desired_job_titles = json.dumps(parsed_data["job_titles"]) @@ -151,53 +155,187 @@ def _update_user_from_parsed_data(user, parsed_data, resume_text): # Store the resume content user.resume = resume_text - # Process skills + # Process skills with better structure if parsed_data.get("skills"): skills_list = parsed_data["skills"] if isinstance(skills_list, list): - user.skills = json.dumps(skills_list) + # Clean and deduplicate skills + cleaned_skills = [] + for skill in skills_list: + if isinstance(skill, str): + clean_skill = skill.strip() + if clean_skill and clean_skill not in cleaned_skills: + cleaned_skills.append(clean_skill) + user.skills = json.dumps(cleaned_skills) else: user.skills = json.dumps([skills_list]) - # Process experience + # Enhanced experience processing if parsed_data.get("experience"): - from backend.models.all_models import Experience - - # Clear existing experiences - Experience.query.filter_by(user_id=user.id).delete() - - for exp_data in parsed_data["experience"]: - current_app.logger.info(f"Added experience: {exp_data.get('company')} - {exp_data.get('title')}") - experience = Experience( - user_id=user.id, - job_title=exp_data.get("title", ""), - company=exp_data.get("company", ""), - location=exp_data.get("location", ""), - start_date=parse_date(exp_data.get("start_date")), - end_date=parse_date(exp_data.get("end_date")), - description=exp_data.get("description", "") - ) - db.session.add(experience) - - # Process projects + experience_list = parsed_data["experience"] + if isinstance(experience_list, list): + # Process each experience entry + processed_experiences = [] + for exp in experience_list: + if isinstance(exp, dict): + # Ensure all required fields are present + processed_exp = { + "title": exp.get("title", ""), + "company": exp.get("company", ""), + "location": exp.get("location", ""), + "start_date": exp.get("start_date", ""), + "end_date": exp.get("end_date", ""), + "description": exp.get("description", ""), + "period": exp.get("period", "") + } + processed_experiences.append(processed_exp) + user.experience = json.dumps(processed_experiences) + else: + user.experience = json.dumps([{"description": str(experience_list)}]) + + # Enhanced project processing if parsed_data.get("projects"): - from backend.models.all_models import Project - - # Clear existing projects - Project.query.filter_by(user_id=user.id).delete() - - for proj_data in parsed_data["projects"]: - current_app.logger.info(f"Added project: {proj_data.get('name')}") - project = Project( - user_id=user.id, - name=proj_data.get("name", ""), - description=proj_data.get("description", ""), - technologies=",".join(proj_data.get("technologies", [])) if isinstance(proj_data.get("technologies"), list) else proj_data.get("technologies", ""), - url=proj_data.get("url"), - start_date=parse_date(proj_data.get("start_date")), - end_date=parse_date(proj_data.get("end_date")) - ) - db.session.add(project) + projects_list = parsed_data["projects"] + if isinstance(projects_list, list): + processed_projects = [] + for proj in projects_list: + if isinstance(proj, dict): + processed_proj = { + "name": proj.get("name", ""), + "description": proj.get("description", ""), + "technologies": proj.get("technologies", []), + "url": proj.get("url", ""), + "role": proj.get("role", ""), + "period": proj.get("period", "") + } + processed_projects.append(processed_proj) + user.projects = json.dumps(processed_projects) + + # Enhanced education processing + if parsed_data.get("education"): + education_list = parsed_data["education"] + if isinstance(education_list, list): + processed_education = [] + for edu in education_list: + if isinstance(edu, dict): + processed_edu = { + "degree": edu.get("degree", ""), + "school": edu.get("school", ""), + "location": edu.get("location", ""), + "graduation_date": edu.get("graduation_date", ""), + "gpa": edu.get("gpa", ""), + "major": edu.get("major", "") + } + processed_education.append(processed_edu) + else: + processed_education.append({"degree": str(edu)}) + user.education = json.dumps(processed_education) + + # Enhanced language processing + if parsed_data.get("languages"): + languages_list = parsed_data["languages"] + if isinstance(languages_list, list): + processed_languages = [] + for lang in languages_list: + if isinstance(lang, dict): + processed_lang = { + "name": lang.get("name", ""), + "proficiency_level": lang.get("proficiency_level", "Intermediate") + } + processed_languages.append(processed_lang) + else: + processed_languages.append({ + "name": str(lang), + "proficiency_level": "Intermediate" + }) + user.languages = json.dumps(processed_languages) + + # Enhanced certifications processing + if parsed_data.get("certifications"): + cert_list = parsed_data["certifications"] + if isinstance(cert_list, list): + processed_certs = [] + for cert in cert_list: + if isinstance(cert, dict): + processed_cert = { + "name": cert.get("name", ""), + "issuer": cert.get("issuer", ""), + "date": cert.get("date", ""), + "expiration": cert.get("expiration", ""), + "url": cert.get("url", "") + } + processed_certs.append(processed_cert) + else: + processed_certs.append({"name": str(cert)}) + user.certifications = json.dumps(processed_certs) + + # Map additional fields for better UX + if parsed_data.get("work_mode_preference"): + user.work_mode_preference = parsed_data["work_mode_preference"] + + if parsed_data.get("career_goals"): + user.career_goals = parsed_data["career_goals"] + + if parsed_data.get("biggest_achievement"): + user.biggest_achievement = parsed_data["biggest_achievement"] + + if parsed_data.get("work_style"): + user.work_style = parsed_data["work_style"] + + if parsed_data.get("industry_attraction"): + user.industry_attraction = parsed_data["industry_attraction"] + + if parsed_data.get("values"): + user.values = json.dumps(parsed_data["values"]) if isinstance(parsed_data["values"], list) else parsed_data["values"] + + # Auto-fill education level if not set + if not user.education_level and parsed_data.get("education"): + education_data = parsed_data["education"] + if isinstance(education_data, list) and education_data: + # Try to determine education level from degree + first_edu = education_data[0] + if isinstance(first_edu, dict): + degree = first_edu.get("degree", "").lower() + if any(term in degree for term in ["phd", "doctorate", "ph.d"]): + user.education_level = "PhD" + elif any(term in degree for term in ["master", "mba", "ms", "ma"]): + user.education_level = "Master's" + elif any(term in degree for term in ["bachelor", "ba", "bs", "bsc"]): + user.education_level = "Bachelor's" + elif any(term in degree for term in ["associate", "aa", "as"]): + user.education_level = "Associate's" + + # Auto-fill years of experience if not set + if not user.years_of_experience and parsed_data.get("experience"): + experience_data = parsed_data["experience"] + if isinstance(experience_data, list) and experience_data: + # Try to calculate years of experience from periods + total_years = 0 + for exp in experience_data: + if isinstance(exp, dict) and exp.get("period"): + period = exp["period"].lower() + # Simple heuristic: look for year ranges + import re + year_matches = re.findall(r'\b(20\d{2})\b', period) + if len(year_matches) >= 2: + try: + start_year = int(year_matches[0]) + end_year = int(year_matches[-1]) + total_years += max(0, end_year - start_year) + except: + pass + + if total_years > 0: + if total_years < 2: + user.years_of_experience = "0-2 years" + elif total_years < 5: + user.years_of_experience = "3-5 years" + elif total_years < 10: + user.years_of_experience = "6-10 years" + else: + user.years_of_experience = "10+ years" + + current_app.logger.info(f"Updated user profile with parsed resume data: {len(parsed_data)} fields processed") def extract_keywords_from_resume(user_id, resume_text): diff --git a/react-frontend/src/components/profile/DragDropResumeUpload.jsx b/react-frontend/src/components/profile/DragDropResumeUpload.jsx new file mode 100644 index 00000000..717efa88 --- /dev/null +++ b/react-frontend/src/components/profile/DragDropResumeUpload.jsx @@ -0,0 +1,490 @@ +import React, { useState, useRef, useCallback } from 'react'; +import styled from 'styled-components'; +import { useFlash } from '../../context/FlashContext'; +import { profileAPI } from '../../services/api'; + +const UploadContainer = styled.div` + position: relative; + margin-bottom: 2rem; +`; + +const DropZone = styled.div` + border: 2px dashed var(--color-border-primary); + border-radius: 12px; + padding: 3rem 2rem; + text-align: center; + background: var(--color-bg-secondary); + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; + + &.dragover { + border-color: var(--color-primary); + background: rgba(var(--color-primary-rgb), 0.1); + transform: scale(1.02); + } + + &.uploading { + pointer-events: none; + opacity: 0.8; + } + + &:hover:not(.uploading) { + border-color: var(--color-primary); + background: rgba(var(--color-primary-rgb), 0.05); + } + + .upload-icon { + font-size: 3rem; + margin-bottom: 1rem; + opacity: 0.6; + transition: all 0.3s ease; + } + + &.dragover .upload-icon, + &:hover:not(.uploading) .upload-icon { + opacity: 1; + transform: scale(1.1); + } + + .upload-text { + margin-bottom: 0.5rem; + color: var(--color-text-primary); + font-size: 1.1rem; + font-weight: 500; + } + + .upload-subtext { + color: var(--color-text-secondary); + font-size: 0.875rem; + margin-bottom: 1rem; + } + + .file-types { + font-size: 0.75rem; + color: var(--color-text-secondary); + margin-bottom: 1rem; + } +`; + +const BrowseButton = styled.button` + background: var(--color-primary); + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 6px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + background: var(--color-primary-dark); + transform: translateY(-1px); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +`; + +const ProgressContainer = styled.div` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + backdrop-filter: blur(2px); +`; + +const ProgressBar = styled.div` + width: 60%; + height: 6px; + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; + overflow: hidden; + margin-bottom: 1rem; + + .progress-fill { + height: 100%; + background: var(--color-primary); + transition: width 0.3s ease; + border-radius: 3px; + } +`; + +const ProgressText = styled.div` + color: var(--color-text-primary); + font-weight: 500; + margin-bottom: 0.5rem; +`; + +const ProgressSubtext = styled.div` + color: var(--color-text-secondary); + font-size: 0.875rem; +`; + +const FilePreview = styled.div` + margin-top: 2rem; + padding: 1.5rem; + background: var(--color-bg-primary); + border-radius: 8px; + border: 1px solid var(--color-border-primary); + display: flex; + align-items: center; + gap: 1rem; + + .file-icon { + font-size: 2rem; + color: var(--color-primary); + } + + .file-info { + flex: 1; + + .file-name { + font-weight: 500; + color: var(--color-text-primary); + margin-bottom: 0.25rem; + } + + .file-size { + color: var(--color-text-secondary); + font-size: 0.875rem; + } + } + + .file-actions { + display: flex; + gap: 0.5rem; + } +`; + +const ActionButton = styled.button` + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s ease; + + &.remove { + background: #dc3545; + color: white; + + &:hover { + background: #c82333; + } + } + + &.upload { + background: var(--color-primary); + color: white; + + &:hover { + background: var(--color-primary-dark); + } + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +`; + +const ErrorMessage = styled.div` + color: #dc3545; + background: rgba(220, 53, 69, 0.1); + border: 1px solid rgba(220, 53, 69, 0.3); + padding: 1rem; + border-radius: 6px; + margin-top: 1rem; + font-size: 0.875rem; +`; + +const SuccessMessage = styled.div` + color: #28a745; + background: rgba(40, 167, 69, 0.1); + border: 1px solid rgba(40, 167, 69, 0.3); + padding: 1rem; + border-radius: 6px; + margin-top: 1rem; + font-size: 0.875rem; + display: flex; + align-items: center; + gap: 0.5rem; + + .success-icon { + font-size: 1.2rem; + } +`; + +const DragDropResumeUpload = ({ onUploadSuccess, currentFileName }) => { + const [isDragging, setIsDragging] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [selectedFile, setSelectedFile] = useState(null); + const [uploadError, setUploadError] = useState(null); + const [uploadSuccess, setUploadSuccess] = useState(false); + const fileInputRef = useRef(null); + const { addFlashMessage } = useFlash(); + + // Supported file types and max size + const SUPPORTED_TYPES = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/plain']; + const MAX_SIZE = 16 * 1024 * 1024; // 16MB + + const validateFile = (file) => { + if (!file) return 'No file selected'; + + if (!SUPPORTED_TYPES.includes(file.type)) { + return 'File type not supported. Please use PDF, DOC, DOCX, or TXT files.'; + } + + if (file.size > MAX_SIZE) { + return `File is too large. Maximum size is ${MAX_SIZE / (1024 * 1024)}MB. Your file is ${(file.size / (1024 * 1024)).toFixed(1)}MB.`; + } + + return null; + }; + + const formatFileSize = (bytes) => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + const getFileIcon = (file) => { + if (file.type === 'application/pdf') return '📄'; + if (file.type.includes('word')) return '📝'; + if (file.type === 'text/plain') return '📄'; + return '📎'; + }; + + const handleDragEnter = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }, []); + + const handleDragLeave = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + if (e.currentTarget.contains(e.relatedTarget)) return; + setIsDragging(false); + }, []); + + const handleDragOver = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDrop = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + const files = e.dataTransfer.files; + if (files.length > 0) { + handleFileSelection(files[0]); + } + }, []); + + const handleFileSelection = (file) => { + setUploadError(null); + setUploadSuccess(false); + + const error = validateFile(file); + if (error) { + setUploadError(error); + return; + } + + setSelectedFile(file); + }; + + const handleFileInputChange = (e) => { + const file = e.target.files[0]; + if (file) { + handleFileSelection(file); + } + }; + + const handleUpload = async () => { + if (!selectedFile) return; + + setIsUploading(true); + setUploadProgress(0); + setUploadError(null); + + try { + // Create form data + const formData = new FormData(); + formData.append('resume_file', selectedFile); + + // Simulate progress for better UX + const progressInterval = setInterval(() => { + setUploadProgress(prev => { + if (prev >= 90) { + clearInterval(progressInterval); + return prev; + } + return prev + Math.random() * 10; + }); + }, 200); + + const response = await profileAPI.uploadResume(formData); + + clearInterval(progressInterval); + setUploadProgress(100); + + if (response.data.success) { + setUploadSuccess(true); + setSelectedFile(null); + setTimeout(() => { + setUploadSuccess(false); + setUploadProgress(0); + }, 3000); + + addFlashMessage('success', 'Resume uploaded and parsed successfully!'); + + if (onUploadSuccess) { + onUploadSuccess(response.data); + } + } else { + throw new Error(response.data.message || 'Upload failed'); + } + } catch (error) { + console.error('Upload error:', error); + setUploadError(error.response?.data?.message || error.message || 'Failed to upload resume'); + addFlashMessage('danger', 'Failed to upload resume. Please try again.'); + } finally { + setIsUploading(false); + } + }; + + const handleRemoveFile = () => { + setSelectedFile(null); + setUploadError(null); + setUploadSuccess(false); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleBrowseClick = () => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }; + + return ( + + + {isUploading && ( + + Uploading Resume... + +
+ + + {uploadProgress < 90 ? 'Uploading file...' : 'Processing and parsing resume...'} + + + )} + + {!isUploading && !selectedFile && ( + <> +
☁️
+
+ {isDragging ? 'Drop your resume here' : 'Drag & Drop your resume here'} +
+
or
+ + Browse Files + +
+ Supported formats: PDF, DOC, DOCX, TXT (Max 16MB) +
+ + )} + + {!isUploading && selectedFile && ( + <> +
📁
+
Ready to upload
+
Click upload to continue
+ + )} + + + + + {selectedFile && !isUploading && ( + +
{getFileIcon(selectedFile)}
+
+
{selectedFile.name}
+
{formatFileSize(selectedFile.size)}
+
+
+ + Remove + + + Upload Resume + +
+
+ )} + + {uploadError && ( + {uploadError} + )} + + {uploadSuccess && ( + +
+
Resume uploaded successfully! Your profile has been updated with the parsed information.
+
+ )} + + {currentFileName && !selectedFile && !isUploading && ( + +
📄
+
+
Current Resume: {currentFileName}
+
Previously uploaded
+
+
+ + Replace Resume + +
+
+ )} + + ); +}; + +export default DragDropResumeUpload; \ No newline at end of file diff --git a/react-frontend/src/components/profile/EditableSection.jsx b/react-frontend/src/components/profile/EditableSection.jsx new file mode 100644 index 00000000..e57b9919 --- /dev/null +++ b/react-frontend/src/components/profile/EditableSection.jsx @@ -0,0 +1,456 @@ +import React, { useState, useEffect } from 'react'; +import styled from 'styled-components'; +import { useFlash } from '../../context/FlashContext'; +import { profileAPI } from '../../services/api'; + +const SectionContainer = styled.div` + margin-bottom: 2rem; + background: var(--color-bg-secondary); + border-radius: 12px; + border: 1px solid var(--color-border-primary); + overflow: hidden; + transition: all 0.3s ease; + + &:hover { + border-color: var(--color-border-hover); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + } +`; + +const SectionHeader = styled.div` + padding: 1.5rem 2rem; + background: var(--color-bg-primary); + color: white; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--color-border-primary); + + h3 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + } +`; + +const SectionBody = styled.div` + padding: 2rem; +`; + +const ItemContainer = styled.div` + margin-bottom: 1.5rem; + padding: 1.5rem; + background: var(--color-bg-primary); + border-radius: 8px; + border: 1px solid var(--color-border-primary); + position: relative; + + &:last-child { + margin-bottom: 0; + } + + &.editing { + border-color: var(--color-primary); + box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), 0.2); + } +`; + +const ItemHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; + + h4 { + margin: 0; + color: var(--color-text-hover); + font-size: 1.1rem; + } + + .subtitle { + color: var(--color-text-secondary); + font-size: 0.9rem; + margin-top: 0.25rem; + } +`; + +const ItemActions = styled.div` + display: flex; + gap: 0.5rem; + opacity: 0.7; + transition: opacity 0.2s ease; + + ${ItemContainer}:hover & { + opacity: 1; + } +`; + +const Button = styled.button` + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 0.5rem; + + &.primary { + background: var(--color-primary); + color: white; + + &:hover { + background: var(--color-primary-dark); + } + } + + &.secondary { + background: var(--color-border-primary); + color: var(--color-text-primary); + + &:hover { + background: var(--color-border-hover); + } + } + + &.danger { + background: #dc3545; + color: white; + + &:hover { + background: #c82333; + } + } + + &.success { + background: #28a745; + color: white; + + &:hover { + background: #218838; + } + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +`; + +const AddButton = styled(Button)` + width: 100%; + justify-content: center; + padding: 1rem; + margin-top: 1rem; + border: 2px dashed var(--color-border-primary); + background: transparent; + color: var(--color-text-primary); + + &:hover { + border-color: var(--color-primary); + color: var(--color-primary); + background: rgba(var(--color-primary-rgb), 0.1); + } +`; + +const FormContainer = styled.div` + margin-top: 1rem; + padding: 1.5rem; + background: var(--color-bg-tertiary); + border-radius: 8px; + border: 1px solid var(--color-border-primary); +`; + +const FormRow = styled.div` + display: flex; + gap: 1rem; + margin-bottom: 1rem; + + &.full-width { + flex-direction: column; + } + + @media (max-width: 768px) { + flex-direction: column; + gap: 0.5rem; + } +`; + +const FormField = styled.div` + flex: 1; + + label { + display: block; + margin-bottom: 0.5rem; + color: var(--color-text-hover); + font-weight: 500; + font-size: 0.875rem; + } + + input, textarea, select { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--color-border-primary); + border-radius: 6px; + background: var(--color-bg-secondary); + color: var(--color-text-primary); + font-size: 0.875rem; + transition: border-color 0.2s ease; + + &:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), 0.2); + } + + &.error { + border-color: #dc3545; + } + } + + textarea { + resize: vertical; + min-height: 100px; + } + + .error-message { + color: #dc3545; + font-size: 0.75rem; + margin-top: 0.25rem; + } +`; + +const FormActions = styled.div` + display: flex; + gap: 1rem; + margin-top: 1.5rem; + justify-content: flex-end; +`; + +const EmptyState = styled.div` + text-align: center; + padding: 3rem 2rem; + color: var(--color-text-secondary); + + .icon { + font-size: 2rem; + margin-bottom: 1rem; + opacity: 0.5; + } + + h4 { + margin-bottom: 0.5rem; + color: var(--color-text-primary); + } + + p { + margin-bottom: 1.5rem; + font-size: 0.875rem; + } +`; + +const EditableSection = ({ + title, + items = [], + onAdd, + onEdit, + onDelete, + renderItem, + renderForm, + emptyStateText = "No items added yet", + emptyStateIcon = "📝", + isLoading = false +}) => { + const [editingItem, setEditingItem] = useState(null); + const [isAdding, setIsAdding] = useState(false); + const [formData, setFormData] = useState({}); + const [formErrors, setFormErrors] = useState({}); + const [isSaving, setIsSaving] = useState(false); + const { addFlashMessage } = useFlash(); + + const handleAdd = () => { + setIsAdding(true); + setEditingItem(null); + setFormData({}); + setFormErrors({}); + }; + + const handleEdit = (item) => { + setEditingItem(item); + setIsAdding(false); + setFormData(item); + setFormErrors({}); + }; + + const handleCancel = () => { + setEditingItem(null); + setIsAdding(false); + setFormData({}); + setFormErrors({}); + }; + + const handleSave = async () => { + try { + setIsSaving(true); + setFormErrors({}); + + if (editingItem) { + // Update existing item + await onEdit(editingItem.id, formData); + addFlashMessage('success', `${title} updated successfully`); + } else { + // Add new item + await onAdd(formData); + addFlashMessage('success', `${title} added successfully`); + } + + handleCancel(); + } catch (error) { + console.error('Error saving item:', error); + + // Handle validation errors + if (error.response?.data?.validation_errors) { + setFormErrors(error.response.data.validation_errors); + addFlashMessage('danger', 'Please check the form for errors'); + } else { + addFlashMessage('danger', `Failed to save ${title.toLowerCase()}`); + } + } finally { + setIsSaving(false); + } + }; + + const handleDelete = async (item) => { + if (window.confirm(`Are you sure you want to delete this ${title.toLowerCase()}?`)) { + try { + await onDelete(item.id); + addFlashMessage('success', `${title} deleted successfully`); + } catch (error) { + console.error('Error deleting item:', error); + addFlashMessage('danger', `Failed to delete ${title.toLowerCase()}`); + } + } + }; + + const handleInputChange = (field, value) => { + setFormData(prev => ({ + ...prev, + [field]: value + })); + + // Clear error when user starts typing + if (formErrors[field]) { + setFormErrors(prev => ({ + ...prev, + [field]: undefined + })); + } + }; + + return ( + + +

{title}

+ {!isAdding && !editingItem && ( + + )} +
+ + + {items.length === 0 && !isAdding && !editingItem ? ( + +
{emptyStateIcon}
+

No {title} Added

+

{emptyStateText}

+ +
+ ) : ( + <> + {items.map((item) => ( + + +
+ {renderItem(item)} +
+ + + + +
+ + {editingItem?.id === item.id && ( + + {renderForm(formData, handleInputChange, formErrors)} + + + + + + )} +
+ ))} + + {isAdding && ( + +

Add New {title}

+ {renderForm(formData, handleInputChange, formErrors)} + + + + +
+ )} + + )} +
+
+ ); +}; + +export default EditableSection; +export { FormRow, FormField, FormActions, Button }; \ No newline at end of file diff --git a/react-frontend/src/components/profile/ExperienceSection.jsx b/react-frontend/src/components/profile/ExperienceSection.jsx new file mode 100644 index 00000000..3b185a15 --- /dev/null +++ b/react-frontend/src/components/profile/ExperienceSection.jsx @@ -0,0 +1,235 @@ +import React from 'react'; +import EditableSection, { FormRow, FormField } from './EditableSection'; +import { profileAPI } from '../../services/api'; + +const ExperienceSection = ({ experiences = [], onDataChange }) => { + const handleAdd = async (data) => { + try { + const response = await profileAPI.addExperience(data); + if (response.data.success) { + onDataChange(); + return response.data.data; + } + throw new Error(response.data.error || 'Failed to add experience'); + } catch (error) { + console.error('Error adding experience:', error); + throw error; + } + }; + + const handleEdit = async (id, data) => { + try { + const response = await profileAPI.updateExperience(id, data); + if (response.data.success) { + onDataChange(); + return response.data.data; + } + throw new Error(response.data.error || 'Failed to update experience'); + } catch (error) { + console.error('Error updating experience:', error); + throw error; + } + }; + + const handleDelete = async (id) => { + try { + const response = await profileAPI.deleteExperience(id); + if (response.data.success) { + onDataChange(); + } else { + throw new Error(response.data.error || 'Failed to delete experience'); + } + } catch (error) { + console.error('Error deleting experience:', error); + throw error; + } + }; + + const renderItem = (experience) => ( +
+

{experience.title || 'Untitled Position'}

+
+ {experience.company && ( + {experience.company} + )} + {experience.location && ( + • {experience.location} + )} + {(experience.start_date || experience.end_date || experience.is_current) && ( + • {formatDateRange(experience)} + )} +
+ {experience.description && ( +

+ {experience.description.length > 150 + ? `${experience.description.substring(0, 150)}...` + : experience.description + } +

+ )} +
+ ); + + const renderForm = (formData, handleInputChange, formErrors) => ( + <> + + + + handleInputChange('title', e.target.value)} + placeholder="e.g., Software Engineer" + className={formErrors.title ? 'error' : ''} + /> + {formErrors.title && ( +
{formErrors.title}
+ )} +
+ + + handleInputChange('company', e.target.value)} + placeholder="e.g., Google, Inc." + className={formErrors.company ? 'error' : ''} + /> + {formErrors.company && ( +
{formErrors.company}
+ )} +
+
+ + + + + handleInputChange('location', e.target.value)} + placeholder="e.g., San Francisco, CA" + className={formErrors.location ? 'error' : ''} + /> + {formErrors.location && ( +
{formErrors.location}
+ )} +
+
+ + + + + handleInputChange('start_date', e.target.value)} + className={formErrors.start_date ? 'error' : ''} + /> + {formErrors.start_date && ( +
{formErrors.start_date}
+ )} +
+ + + handleInputChange('end_date', e.target.value)} + disabled={formData.is_current} + className={formErrors.end_date ? 'error' : ''} + /> + {formErrors.end_date && ( +
{formErrors.end_date}
+ )} +
+
+ + + + + + + + + + +