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
diff --git a/.env.example b/.env.example
index 68d758d2..1afb14ed 100644
--- a/.env.example
+++ b/.env.example
@@ -1,7 +1,29 @@
+# Flask Configuration
FLASK_APP=backend/app.py
FLASK_ENV=development
-GEMINI_API_KEY=your_gemini_api_key
-SECRET_KEY=your_secret_key_here
+FLASK_RUN_PORT=5000
+SECRET_KEY=your-secret-key-here
+WTF_CSRF_SECRET_KEY=your-csrf-secret-key
-FLASK_RUN_PORT = 5000
-#set it the same as the env file in react-frontend
\ No newline at end of file
+# Database Configuration
+DATABASE_URL=postgresql://postgres:password@localhost:5432/instantapply_dev
+
+# Optional: Separate test database (only needed if running automated tests)
+# TEST_DATABASE_URL=postgresql://postgres:password@localhost:5432/instantapply_test
+
+# Database Connection Pool Settings (optional)
+DB_POOL_SIZE=5
+DB_MAX_OVERFLOW=10
+DB_POOL_TIMEOUT=30
+DB_POOL_RECYCLE=1800
+
+# Frontend URL
+FRONTEND_URL=http://127.0.0.1:8080
+
+# Email Configuration
+MAIL_SERVER=smtp.gmail.com
+MAIL_PORT=587
+MAIL_USE_TLS=True
+MAIL_USERNAME=your-email@gmail.com
+MAIL_PASSWORD=your-app-password
+MAIL_DEFAULT_SENDER=your-email@gmail.com
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 5194b0f8..a1be00ab 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 && \
@@ -89,7 +92,7 @@ ENV PYTHONPATH=/app
ENV FLASK_APP=app.py
# Set a default SQLite database URL that explicitly uses the instance directory
-ENV DATABASE_URL="sqlite:////app/instance/instant_apply.db"
+ENV DATABASE_URL="postgresql://postgres:password@localhost:5432/instantapply_dev"
# Expose the port the app runs on
EXPOSE 8000
diff --git a/app.py b/app.py
index 474a4ff9..e029ef68 100644
--- a/app.py
+++ b/app.py
@@ -1,55 +1,64 @@
#!/usr/bin/env python3
"""
-Root-level app.py to help Azure Web App find and run the Flask application
-Properly handles path setup for both development and production environments
+InstantApply - Main application entry point
+Run this from the project root directory
"""
import os
import sys
+from pathlib import Path
-def setup_paths():
- """Setup proper paths for the application"""
- # Get the absolute path to the root directory
- ROOT_DIR = os.path.abspath(os.path.dirname(__file__))
-
- # Ensure we're working from the correct root directory
- os.chdir(ROOT_DIR)
-
- # Add paths to Python path
- sys.path.insert(0, ROOT_DIR)
- sys.path.insert(0, os.path.join(ROOT_DIR, 'backend'))
-
- return ROOT_DIR
+# Get the current directory (project root)
+project_root = Path(__file__).parent.absolute()
+backend_dir = project_root / "backend"
+
+# Add both directories to Python path
+sys.path.insert(0, str(project_root))
+sys.path.insert(0, str(backend_dir))
+
+# Set working directory to backend for relative imports
+os.chdir(backend_dir)
def create_flask_app():
- """Create and configure the Flask application"""
- ROOT_DIR = setup_paths()
+ """Create the Flask application with proper path setup"""
+ try:
+ # Now import from backend.app
+ from backend.app import create_app
+ return create_app()
+ except ImportError as e:
+ print(f"❌ Import error: {e}")
+ print(f"Current working directory: {os.getcwd()}")
+ print(f"Python path: {sys.path[:3]}")
+ raise
+
+def main():
+ """Main entry point"""
+ print(f"🚀 Starting InstantApply from: {project_root}")
+ print(f"📁 Backend directory: {backend_dir}")
+ print(f"💼 Working directory: {os.getcwd()}")
+
+ # Ensure we have required environment variables
+ from dotenv import load_dotenv
+ load_dotenv()
- # Set environment variable to ensure proper database path
if not os.environ.get('DATABASE_URL'):
- os.environ['DATABASE_URL'] = f'sqlite:///{ROOT_DIR}/backend/instant_apply.db'
+ print("⚠️ No DATABASE_URL found in environment, using default PostgreSQL")
+ os.environ['DATABASE_URL'] = 'postgresql://postgres:password@localhost:5432/instantapply_dev'
- # Change to backend directory for relative imports to work
- backend_dir = os.path.join(ROOT_DIR, 'backend')
- os.chdir(backend_dir)
+ print(f"🗄️ Database URL: {os.environ.get('DATABASE_URL')}")
- # Import and create the app
- from backend.app import create_app
- return create_app()
+ # Create and run the app
+ try:
+ app = create_flask_app()
+ port = int(os.environ.get('FLASK_RUN_PORT', 8080))
+ print(f"✅ Flask app created successfully!")
+ print(f"🌐 Starting server on http://0.0.0.0:{port}")
+ app.run(debug=True, host='0.0.0.0', port=port)
+ except Exception as e:
+ print(f"❌ Failed to start application: {e}")
+ import traceback
+ traceback.print_exc()
+ sys.exit(1)
-# Create the app instance for WSGI servers (like Azure, Gunicorn, etc.)
-app = create_flask_app()
-
-if __name__ == "__main__":
- """Direct execution - for development"""
- print(f"Working directory: {os.getcwd()}")
- print(f"Python path (first 3): {sys.path[:3]}")
- print("✅ Flask app created successfully!")
- print("🚀 Starting Flask server...")
-
- # Run the Flask app
- app.run(
- host="0.0.0.0",
- port=int(os.environ.get('FLASK_RUN_PORT', 5000)),
- debug=os.environ.get('FLASK_ENV') == 'development'
- )
\ No newline at end of file
+if __name__ == '__main__':
+ main()
\ No newline at end of file
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/API.py b/backend/API.py
index c441a996..b957df97 100644
--- a/backend/API.py
+++ b/backend/API.py
@@ -12,8 +12,8 @@
}
headers = {
- "x-rapidapi-key": "6fcedf620cmsh832cb5be381cb42p1d03dbjsnc9e6c6d456f8",
- "x-rapidapi-host": "jsearch.p.rapidapi.com"
+ "x-rapidapi-key": "6fcedf620cmsh832cb5be381cb42p1d03dbjsnc9e6c6d456f8",
+ "x-rapidapi-host": "jsearch.p.rapidapi.com"
}
response = requests.get(url, headers=headers, params=querystring)
diff --git a/backend/GEMINI_MULTI_KEY_SETUP.md b/backend/GEMINI_MULTI_KEY_SETUP.md
deleted file mode 100644
index f202f331..00000000
--- a/backend/GEMINI_MULTI_KEY_SETUP.md
+++ /dev/null
@@ -1,143 +0,0 @@
-# Multi-Key Gemini API Setup
-
-This document explains the new multi-key Gemini API system implemented to avoid rate limiting issues.
-
-## Overview
-
-The application now supports multiple Gemini API keys to distribute load and avoid hitting rate limits. Keys are automatically rotated when errors occur, ensuring better reliability.
-
-## Environment Variable Setup
-
-Set your Gemini API keys in the environment variable `GEMINI_API_KEY` separated by commas:
-
-```bash
-# Single key (old format - still supported)
-GEMINI_API_KEY=your_api_key_here
-
-# Multiple keys (new format)
-GEMINI_API_KEY=key1,key2,key3,key4
-```
-
-## How It Works
-
-1. **Key Loading**: The system automatically splits the comma-separated keys and loads them into memory
-2. **Random Selection**: A random key is selected for initial use
-3. **Automatic Rotation**: When an API error occurs, the system automatically rotates to a different key
-4. **Fallback**: If all keys fail, the system falls back to non-AI functionality
-
-## Key Features
-
-- **Automatic Load Distribution**: Keys are randomly selected to distribute load
-- **Error Recovery**: Automatic key rotation on API failures
-- **Backward Compatibility**: Single key setup still works
-- **Centralized Management**: All key management is handled in one place
-- **Latest Model Support**: Uses `gemini-2.0-flash` for optimal performance
-
-## Files Modified
-
-### New Files
-- `backend/utils/gemini_api_manager.py` - Central API key management
-
-### Updated Files
-- `backend/utils/gemini_caller.py` - Updated to use multi-key system and gemini-2.0-flash
-- `backend/gemini_models.py` - Updated to use multi-key system
-- `backend/utils/document_parser.py` - Updated to use multi-key system
-- `backend/utils/job_recommenders/simple.py` - Updated with error handling and key rotation
-- `backend/utils/job_recommenders/advanced.py` - Updated with error handling and key rotation
-- `backend/utils/application_filler/response_generator.py` - Updated to use multi-key system and gemini-2.0-flash
-- `backend/utils/application_filler/__init__.py` - Updated to use multi-key system and gemini-2.0-flash
-
-## Usage Examples
-
-### Basic Usage
-```python
-from utils.gemini_api_manager import configure_gemini_api, has_gemini_api_keys
-
-# Check if keys are available
-if has_gemini_api_keys():
- # Configure the API
- configure_gemini_api()
-
- # Use genai normally with the latest model
- import google.generativeai as genai
- model = genai.GenerativeModel("gemini-2.0-flash")
- response = model.generate_content("Hello, world!")
-```
-
-### Error Handling with Key Rotation
-```python
-from utils.gemini_api_manager import rotate_api_key
-
-try:
- # Make API call
- response = model.generate_content(prompt)
-except Exception as e:
- # Rotate to a different key for next request
- rotate_api_key()
- # Handle the error or retry
-```
-
-## Testing
-
-Run the test script to verify the setup:
-
-```bash
-cd backend
-python test_gemini_keys.py
-```
-
-This will test:
-- Key loading and counting
-- API configuration
-- Key rotation (if multiple keys available)
-- Integration with existing modules
-
-## Benefits
-
-1. **Increased Reliability**: Multiple keys reduce the chance of hitting rate limits
-2. **Better Performance**: Load is distributed across multiple API keys
-3. **Automatic Recovery**: System automatically handles key failures
-4. **Easy Setup**: Just add more keys to the environment variable
-5. **Monitoring**: Logs show which keys are being used and when rotation occurs
-6. **Latest Technology**: Uses Gemini 2.0 Flash for best performance and capabilities
-
-## Monitoring
-
-The system logs important events:
-- Number of keys loaded at startup
-- Key rotation events
-- API configuration success/failure
-- Fallback to non-AI functionality
-
-Check your application logs for messages like:
-- `"Loaded X Gemini API key(s)"`
-- `"Rotated to new API key: xxxxxxxx..."`
-- `"Using simple match scoring (Gemini API not available)"`
-
-## Troubleshooting
-
-### No Keys Loaded
-- Check that `GEMINI_API_KEY` environment variable is set
-- Ensure keys are separated by commas with no extra spaces
-- Verify keys are valid Gemini API keys
-
-### Keys Not Rotating
-- Rotation only occurs when there are multiple keys
-- Rotation happens automatically on API errors
-- Check logs for rotation events
-
-### API Still Failing
-- Verify all keys are valid and active
-- Check Google Cloud Console for API quotas
-- Ensure billing is enabled for all API keys
-- Make sure you're using the correct model name (`gemini-2.0-flash`)
-
-## Model Information
-
-The system now uses `gemini-2.0-flash` which offers:
-- Enhanced multimodal capabilities (text, images, audio, video)
-- Better performance and accuracy
-- Support for longer context windows
-- Improved function calling capabilities
-
-For more details, see the [Gemini 2.0 Flash documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/2-0-flash).
\ No newline at end of file
diff --git a/backend/app.py b/backend/app.py
index 522f608b..9dc472c6 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -1,25 +1,23 @@
#!/usr/bin/env python3
"""
-InstantApply - Main application entry point
+InstantApply - Backend application
"""
import os
import sys
import logging
-# COMPLETELY disable all logging except CRITICAL
-logging.basicConfig(level=logging.CRITICAL) # Set root logger to CRITICAL
-logging.disable(logging.DEBUG) # Disable all DEBUG logging
+# Add current directory to path for relative imports
+current_dir = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, current_dir)
+
+# Ensure config.py is imported directly, not the config module
+if 'config' in sys.modules:
+ del sys.modules['config'] # Clear any cached config module
-# Explicitly disable all database-related loggers
-for logger_name in ['aiosqlite', 'sqlalchemy', 'sqlalchemy.engine', 'sqlalchemy.pool',
- 'sqlalchemy.orm', 'sqlalchemy.dialects', 'sqlalchemy.engine.Engine',
- 'sqlalchemy.engine.base', 'sqlalchemy.dialects.sqlite']:
- logger = logging.getLogger(logger_name)
- logger.setLevel(logging.CRITICAL)
- logger.propagate = False # Prevent propagation to parent loggers
+# COMPLETELY disable all logging except CRITICAL
+logging.basicConfig(level=logging.CRITICAL)
+logging.disable(logging.DEBUG)
-# Add the project root directory to Python path to fix imports
-sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
from datetime import datetime, timedelta
import json
import requests
@@ -35,12 +33,11 @@
import asyncio
from flask_mail import Mail
-# Load environment variables
+# Load environment variables first
load_dotenv()
-# Import configuration
-from config import Config, get_config
-from config.logging_config import configure_logging
+# Import configuration directly from config.py file
+import config
# Import email utilities
from utils.email_utils import mail, send_waitlist_confirmation
@@ -49,55 +46,47 @@
# Import database and non-model modules only
from models.db import db
-def create_app(config_class=Config):
+def create_app(config_name=None):
"""Create and configure the Flask application"""
# Get absolute path to backend directory
backend_dir = os.path.dirname(os.path.abspath(__file__))
instance_path = os.path.join(backend_dir, 'instance')
- # Configure logging
- logging.basicConfig(level=logging.INFO)
- logger = logging.getLogger(__name__)
-
- # Debug: Print environment variables and paths
- logger.info("Environment variables:")
- logger.info(f"DATABASE_URL: {os.environ.get('DATABASE_URL')}")
- logger.info(f"FLASK_ENV: {os.environ.get('FLASK_ENV')}")
- logger.info(f"Current working directory: {os.getcwd()}")
- logger.info(f"Backend directory: {backend_dir}")
- logger.info(f"Instance directory: {instance_path}")
-
- # Check if instance directory exists and is writable
- try:
- if not os.path.exists(instance_path):
- logger.info(f"Creating instance directory at: {instance_path}")
- os.makedirs(instance_path, exist_ok=True)
- logger.info(f"Instance directory exists: {os.path.exists(instance_path)}")
- logger.info(f"Instance directory permissions: {oct(os.stat(instance_path).st_mode)[-3:]}")
- except Exception as e:
- logger.error(f"Error checking instance directory: {e}")
-
- # Create Flask app with explicit instance path and React build directory
- # Point Flask to serve React build files directly
+ # Create Flask app with explicit paths
app = Flask(__name__,
instance_relative_config=True,
instance_path=instance_path,
- static_folder='static', # Changed to use the correct static folder
+ static_folder='static',
static_url_path='')
# Load configuration
+ if config_name is None:
+ config_name = os.environ.get('FLASK_ENV', 'development')
+
+ # Get the appropriate config class - FIXED to use config module properly
+ config_class = config.config.get(config_name, config.config['default'])
app.config.from_object(config_class)
- # Log database configuration
- db_uri = app.config['SQLALCHEMY_DATABASE_URI']
- if db_uri.startswith('sqlite:///'):
- db_path = db_uri.replace('sqlite:///', '')
- logging.info(f"Using SQLite database at: {db_path}")
- logging.info(f"Database directory exists: {os.path.exists(os.path.dirname(db_path))}")
- if os.path.exists(os.path.dirname(db_path)):
- logging.info(f"Database directory permissions: {oct(os.stat(os.path.dirname(db_path)).st_mode)[-3:]}")
- else:
- logging.info(f"Using database at: {db_uri}")
+ # Verify database configuration exists
+ if not app.config.get('SQLALCHEMY_DATABASE_URI'):
+ # Fallback to environment variable
+ db_url = os.environ.get('DATABASE_URL', 'postgresql://postgres:password@localhost:5432/instantapply_dev')
+ app.config['SQLALCHEMY_DATABASE_URI'] = db_url
+ print(f"⚠️ Using fallback database URL: {db_url}")
+
+ db_uri = app.config.get('SQLALCHEMY_DATABASE_URI', '')
+ if 'postgresql' not in db_uri.lower():
+ raise ValueError(f"Expected PostgreSQL database URI, got: {db_uri}")
+
+ print(f"✅ Using database: {db_uri}")
+
+ # Ensure instance directory exists
+ try:
+ if not os.path.exists(instance_path):
+ os.makedirs(instance_path, exist_ok=True)
+ print(f"✅ Instance directory: {instance_path}")
+ except Exception as e:
+ print(f"❌ Error with instance directory: {e}")
# Initialize extensions
migrate = Migrate()
@@ -107,26 +96,24 @@ def create_app(config_class=Config):
# Initialize extensions with app
try:
db.init_app(app)
- logging.info("Initialized SQLAlchemy")
+ print("✅ Initialized SQLAlchemy")
migrate.init_app(app, db)
- logging.info("Initialized Flask-Migrate")
+ print("✅ Initialized Flask-Migrate")
login_manager.init_app(app)
- logging.info("Initialized LoginManager")
+ print("✅ Initialized LoginManager")
except Exception as e:
- logging.error(f"Failed to initialize extensions: {e}")
+ print(f"❌ Failed to initialize extensions: {e}")
raise
# Configure CORS with proper settings
cors.init_app(app,
resources={r"/*": {
- "origins": app.config['CORS_ORIGINS'],
- "methods": app.config['CORS_METHODS'],
- "allow_headers": app.config['CORS_ALLOW_HEADERS'],
- "expose_headers": app.config['CORS_EXPOSE_HEADERS'],
- "supports_credentials": app.config['CORS_SUPPORTS_CREDENTIALS'],
- "max_age": app.config['CORS_MAX_AGE']
+ "origins": app.config.get('CORS_ORIGINS', ["http://localhost:3000"]),
+ "methods": app.config.get('CORS_METHODS', ["GET", "POST", "PUT", "DELETE", "OPTIONS"]),
+ "allow_headers": app.config.get('CORS_ALLOW_HEADERS', ["Content-Type", "Authorization"]),
+ "supports_credentials": True,
}},
supports_credentials=True,
automatic_options=True
@@ -134,10 +121,6 @@ def create_app(config_class=Config):
mail.init_app(app)
- # Initialize database health checks
- from utils.db_utils import init_db_health_check
- init_db_health_check(app)
-
# Configure LoginManager
login_manager.login_view = 'auth.login'
login_manager.login_message_category = 'info'
@@ -146,224 +129,115 @@ def create_app(config_class=Config):
@login_manager.user_loader
def load_user(user_id):
try:
- # Import User model inside the function to avoid import order issues
from models.all_models import User
return User.query.get(int(user_id))
except Exception as e:
- logging.error(f"User loader error: {str(e)}")
+ print(f"User loader error: {str(e)}")
return None
# Initialize our custom email service
init_email_service(app)
- # Create database tables and bind models to app context
+ # Ensure upload directories exist
+ upload_folder = app.config.get('UPLOAD_FOLDER', os.path.join(backend_dir, 'uploads'))
+ try:
+ os.makedirs(upload_folder, exist_ok=True)
+ print(f"✅ Upload folder: {upload_folder}")
+ except Exception as e:
+ print(f"⚠️ Could not create upload folder: {e}")
+
+ # Create database tables
with app.app_context():
try:
- # Ensure all models are properly registered
from models.registry import registry
registry.finalize_registration()
- logging.info("Model registry finalized")
-
- # Bind models to application context
registry.bind_to_app(app)
- logging.info("Models bound to application context")
-
- # Create all tables
db.create_all()
- logging.info("Database tables created/verified successfully")
+ print("✅ Database tables created/verified")
except Exception as e:
- logging.error(f"Database initialization error: {str(e)}")
+ print(f"❌ Database initialization error: {str(e)}")
raise
# Register blueprints
- from routes.api import api_bp
- from routes.auth import auth_bp
- from routes.profile import profile_bp
- from routes.jobs import jobs_bp
- from routes.admin import admin_bp
- from routes.moderator import moderator_bp
- from routes.session import session_bp
- from routes.admin_audit import admin_audit_bp
- from routes.moderator_assignment import moderator_assignment_bp
- from routes.checkout import checkout_bp
- from routes.content_preview import content_preview_bp
- from routes.job_search import job_search_bp
- from routes.admin_job_search import admin_job_search_bp
- from routes.simple_signup import simple_signup_bp
- from routes.simple_auth import simple_auth_bp
-
- app.register_blueprint(api_bp, url_prefix='/api')
- app.register_blueprint(jobs_bp, url_prefix='/jobs')
- app.register_blueprint(jobs_bp, url_prefix='/api/jobs', name='api_jobs')
- app.register_blueprint(admin_job_search_bp, url_prefix='/admin')
- app.register_blueprint(admin_bp, url_prefix='/api/admin')
- app.register_blueprint(moderator_bp, url_prefix='/api/moderator')
- 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')
- app.register_blueprint(content_preview_bp, url_prefix='/content')
- app.register_blueprint(session_bp, url_prefix='/api/session', name='api_session')
-
- # Only register debug routes in debug mode (remove duplicate registration)
- if app.debug:
- from routes.debug import debug_bp
- app.register_blueprint(debug_bp, url_prefix='/debug')
+ try:
+ from routes.api import api_bp
+ from routes.auth import auth_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
+ from routes.session import session_bp
+ from routes.admin_audit import admin_audit_bp
+ from routes.moderator_assignment import moderator_assignment_bp
+ from routes.checkout import checkout_bp
+ from routes.content_preview import content_preview_bp
+ from routes.job_search import job_search_bp
+ from routes.admin_job_search import admin_job_search_bp
+ from routes.simple_signup import simple_signup_bp
+ from routes.simple_auth import simple_auth_bp
+
+ app.register_blueprint(api_bp, url_prefix='/api')
+ app.register_blueprint(jobs_bp, url_prefix='/api/jobs')
+ app.register_blueprint(admin_job_search_bp, url_prefix='/api/admin/job-search')
+ app.register_blueprint(admin_bp, url_prefix='/api/admin')
+ app.register_blueprint(moderator_bp, url_prefix='/api/moderator')
+ 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')
+
+ 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')
+
+ if app.debug:
+ from routes.debug import debug_bp
+ app.register_blueprint(debug_bp, url_prefix='/debug')
+
+ print("✅ Registered all blueprints")
+ except Exception as e:
+ print(f"❌ Error registering blueprints: {e}")
+ raise
- # Define routes AFTER all blueprints to ensure proper order
+ # Define routes
@app.route('/favicon.ico')
def favicon():
- """Serve the favicon.ico file from the static folder"""
return app.send_static_file('favicon.ico')
-
-
@app.route('/health')
def health_check():
- """Enhanced health check endpoint that checks various system components"""
- health_status = {
- "status": "healthy",
- "timestamp": datetime.utcnow().isoformat(),
- "components": {
- "app": "healthy",
- "database": "unknown"
- }
- }
-
- # Check database connection
- try:
- from utils.db_utils import check_db_connection
- db_healthy = check_db_connection()
- health_status["components"]["database"] = "healthy" if db_healthy else "unhealthy"
- if not db_healthy:
- health_status["status"] = "unhealthy"
- except Exception as e:
- app.logger.error(f"Database health check failed: {str(e)}")
- health_status["components"]["database"] = "unhealthy"
- health_status["status"] = "unhealthy"
- health_status["database_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']}")
-
- return health_status, status_code
-
- # Note: Content is now served through React components (JSX pages)
- # Legacy content API endpoints are deprecated but kept for backward compatibility
+ return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()}
@login_manager.unauthorized_handler
def unauthorized_handler():
- # Allow public access to content preview endpoints
if request.path.startswith('/api/content/previews'):
return None
if request.path.startswith('/api/'):
return jsonify({'error': 'Authentication required'}), 401
return redirect(url_for('auth.login'))
-
- # Add global error handler to log all exceptions
- @app.errorhandler(Exception)
- def handle_exception(e):
- # Don't handle HTTP exceptions - let Flask handle them normally
- from werkzeug.exceptions import HTTPException
- if isinstance(e, HTTPException):
- return e
-
- # Only handle non-HTTP exceptions
- import traceback
- print("=" * 50)
- print("EXCEPTION CAUGHT:")
- print(f"Error type: {type(e).__name__}")
- print(f"Error message: {str(e)}")
- print("Full traceback:")
- print(traceback.format_exc())
- print("=" * 50)
- return {"error": "Internal server error"}, 500
-
- # Also add a before_request handler to log all requests
- @app.before_request
- def log_request_info():
- print(f"Request: {request.method} {request.url}")
- if request.method == 'POST':
- print(f"Request data: {request.get_json(silent=True)}")
-
- # Add a teardown_appcontext handler to catch any unhandled errors
- @app.teardown_appcontext
- def teardown_db(error):
- if error:
- print(f"Teardown error: {error}")
- import traceback
- print(traceback.format_exc())
-
- # 404 error handler to serve React app for frontend routes
+
@app.errorhandler(404)
def handle_404(error):
- print(f"🔍 DEBUG: 404 handler called for: {request.path}")
-
- # Skip API routes and profile API routes - let them return proper 404s
- if request.path.startswith('/api/') or (request.path.startswith('/profile/') and request.path != '/profile'):
- print(f"🔍 DEBUG: Skipping API/profile route: {request.path}")
- return {'error': 'Not Found'}, 404
-
- # Skip profile API routes (but not the bare /profile route)
- if request.path.startswith('/profile/') and request.path != '/profile/':
- print(f"🔍 DEBUG: Skipping profile API route: {request.path}")
- return {'error': 'Not Found'}, 404
+ if request.path.startswith('/api/'):
+ return {'error': 'API endpoint not found'}, 404
+ if request.path.startswith('/static/'):
+ return {'error': 'File not found'}, 404
+ if request.path != '/' and '.' in request.path.split('/')[-1]:
+ return {'error': 'File not found'}, 404
- # For all other routes, serve the React app
try:
- # Check if it's a static file request first
- if request.path.startswith('/static/'):
- print(f"🔍 DEBUG: 404 for static file: {request.path}")
- return {'error': 'File not found'}, 404
- elif request.path != '/' and '.' in request.path.split('/')[-1]:
- # File with extension that's not found
- print(f"🔍 DEBUG: 404 for file with extension: {request.path}")
- return {'error': 'File not found'}, 404
- else:
- # All frontend routes serve React app (including /profile)
- print(f"🔍 DEBUG: Serving React app for frontend route: {request.path}")
- return app.send_static_file('index.html')
+ return app.send_static_file('index.html')
except Exception as e:
- app.logger.error(f"Error in 404 handler: {e}")
- print(f"🔍 DEBUG: Error in 404 handler: {e}")
return f"Error serving application: {e}", 500
-
-
return app
-# Create the app instance for deployment (needed for gunicorn/Azure)
-# Only create app instance if not running as main module to avoid import issues
-if __name__ != '__main__':
- app = create_app()
-
-# This allows you to run the app directly with `python app.py`
+# For running directly from backend directory
if __name__ == '__main__':
- # Setup proper paths when running from backend directory
- backend_dir = os.path.dirname(os.path.abspath(__file__))
- root_dir = os.path.dirname(backend_dir)
-
- # Add paths to Python path
- sys.path.insert(0, root_dir)
- sys.path.insert(0, backend_dir)
-
- # Set environment variable to ensure proper database path when running from backend
- if not os.environ.get('DATABASE_URL'):
- db_path = os.path.join(backend_dir, 'instant_apply.db')
- os.environ['DATABASE_URL'] = f'sqlite:///{db_path}'
-
- print(f"Working directory: {os.getcwd()}")
- print(f"Backend directory: {backend_dir}")
- print(f"Root directory: {root_dir}")
+ print(f"Running from backend directory: {os.getcwd()}")
print(f"Database URL: {os.environ.get('DATABASE_URL')}")
- # Only create app when running directly to avoid SQLAlchemy registration issues
app = create_app()
- port = int(os.getenv('FLASK_RUN_PORT', '8080')) # Default to 8080 to avoid port conflicts
- print(f"✅ Flask app created successfully!")
+ port = int(os.environ.get('FLASK_RUN_PORT', 8080))
print(f"🚀 Starting Flask server on port {port}...")
app.run(debug=True, host='0.0.0.0', port=port)
diff --git a/backend/apply_profile_fields_migration.py b/backend/apply_profile_fields_migration.py
deleted file mode 100644
index d1031ebc..00000000
--- a/backend/apply_profile_fields_migration.py
+++ /dev/null
@@ -1,93 +0,0 @@
-#!/usr/bin/env python3
-"""
-Script to apply profile fields migration to the database.
-This ensures all required profile fields exist in the users table.
-"""
-
-import sqlite3
-import os
-from pathlib import Path
-
-def apply_profile_fields_migration():
- """Apply the profile fields migration to the database."""
-
- # Get the database path
- db_path = Path(__file__).parent / "instance" / "instant_apply.db"
-
- if not db_path.exists():
- print(f"Database not found at {db_path}")
- return False
-
- print(f"Applying profile fields migration to {db_path}")
-
- # Connect to the database
- conn = sqlite3.connect(db_path)
- cursor = conn.cursor()
-
- # List of fields to add with their SQL definitions
- fields_to_add = [
- ("willing_to_relocate", "BOOLEAN"),
- ("authorization_status", "VARCHAR(100)"),
- ("visa_status", "VARCHAR(100)"),
- ("race_ethnicity", "VARCHAR(100)"),
- ("years_of_experience", "INTEGER"),
- ("education_level", "VARCHAR(100)"),
- ("industry_preference", "VARCHAR(200)"),
- ("career_goals", "TEXT"),
- ("biggest_achievement", "TEXT"),
- ("work_style", "VARCHAR(200)"),
- ("industry_attraction", "VARCHAR(200)"),
- ("desired_salary_range", "VARCHAR(100)"),
- ("available_start_date", "DATE"),
- ("preferred_company_type", "VARCHAR(100)"),
- ("graduation_date", "DATE"),
- ("needs_sponsorship", "BOOLEAN"),
- ("company_size_preference", "VARCHAR(50)"),
- ("remote_preference", "VARCHAR(50)")
- ]
-
- # Check which fields already exist
- cursor.execute("PRAGMA table_info(users)")
- existing_columns = {row[1] for row in cursor.fetchall()}
-
- added_fields = []
- skipped_fields = []
-
- for field_name, field_type in fields_to_add:
- if field_name in existing_columns:
- skipped_fields.append(field_name)
- print(f" ✓ {field_name} already exists")
- else:
- try:
- sql = f"ALTER TABLE users ADD COLUMN {field_name} {field_type}"
- cursor.execute(sql)
- added_fields.append(field_name)
- print(f" + Added {field_name} ({field_type})")
- except sqlite3.OperationalError as e:
- if "duplicate column name" in str(e).lower():
- skipped_fields.append(field_name)
- print(f" ✓ {field_name} already exists (caught by SQLite)")
- else:
- print(f" ✗ Error adding {field_name}: {e}")
- return False
-
- # Commit the changes
- conn.commit()
- conn.close()
-
- print(f"\nMigration completed:")
- print(f" Added: {len(added_fields)} fields")
- print(f" Skipped: {len(skipped_fields)} fields (already existed)")
-
- if added_fields:
- print(f"\nAdded fields: {', '.join(added_fields)}")
-
- return True
-
-if __name__ == "__main__":
- success = apply_profile_fields_migration()
- if success:
- print("\n✅ Profile fields migration completed successfully!")
- else:
- print("\n❌ Profile fields migration failed!")
- exit(1)
\ No newline at end of file
diff --git a/backend/change_password.py b/backend/change_password.py
index 3b0ad08a..929d4406 100644
--- a/backend/change_password.py
+++ b/backend/change_password.py
@@ -3,8 +3,20 @@
import sys
from app import create_app
-from backend.models.all_models import User
-from backend.models.db import db
+try:
+ from models.all_models import User
+except ImportError:
+ try:
+ from models.all_models import User
+ except ImportError:
+ from backend.models.all_models import User
+try:
+ from models.db import db
+except ImportError:
+ try:
+ from models.db import db
+ except ImportError:
+ from backend.models.db import db
def change_password(email, new_password):
app = create_app()
diff --git a/backend/check_jobs.py b/backend/check_jobs.py
deleted file mode 100644
index 545b29f1..00000000
--- a/backend/check_jobs.py
+++ /dev/null
@@ -1,69 +0,0 @@
-from flask import Flask
-from backend.models.all_models import db, JobPosting, Company
-import os
-from dotenv import load_dotenv
-import logging
-
-# Configure logging
-logging.basicConfig(level=logging.INFO)
-logger = logging.getLogger(__name__)
-
-# Load environment variables
-load_dotenv()
-
-# Create Flask app
-app = Flask(__name__)
-
-# Get database configuration from environment variables
-db_url = os.environ.get('DATABASE_URL')
-db_path = None # Initialize db_path
-
-if db_url:
- app.config['SQLALCHEMY_DATABASE_URI'] = db_url
- # Extract path from DATABASE_URL if it's SQLite
- if db_url.startswith('sqlite:///'):
- db_path = db_url.replace('sqlite:///', '')
- if not os.path.isabs(db_path):
- db_path = os.path.join(os.path.dirname(__file__), db_path)
-else:
- # Fallback to SQLite in backend/instance
- db_name = os.environ.get('DATABASE_NAME', 'instant_apply.db')
- db_path = os.path.join(os.path.dirname(__file__), 'instance', db_name)
- app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
-
-app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
-
-# Initialize database
-db.init_app(app)
-
-try:
- with app.app_context():
- # Create instance directory if it doesn't exist
- if db_path and (not db_url or db_url.startswith('sqlite:///')):
- os.makedirs(os.path.dirname(db_path), exist_ok=True)
-
- # Get total count
- total_jobs = JobPosting.query.count()
- print(f"\nTotal jobs in database: {total_jobs}")
-
- if total_jobs > 0:
- # Get some sample jobs
- print("\nSample jobs:")
- jobs = JobPosting.query.limit(5).all()
- for job in jobs:
- company = Company.query.get(job.company_id)
- print(f"\nJob ID: {job.id}")
- print(f"Title: {job.title}")
- print(f"Company: {company.name if company else 'Unknown'}")
- print(f"Location: {job.location}")
- print(f"Posted at: {job.posted_at}")
- print(f"Source: {job.source}")
- print("-" * 50)
- else:
- print("\nNo jobs found in the database.")
-
-except Exception as e:
- logger.error(f"Error accessing database: {str(e)}")
- print(f"\nError: Could not access database. Details: {str(e)}")
- print(f"Database path: {db_path if db_path else 'N/A'}")
- print(f"Database URI: {app.config['SQLALCHEMY_DATABASE_URI']}")
\ No newline at end of file
diff --git a/backend/check_user.py b/backend/check_user.py
index 07ead188..9b4a6e89 100644
--- a/backend/check_user.py
+++ b/backend/check_user.py
@@ -1,7 +1,13 @@
import os
import sys
from flask import Flask
-from backend.models.all_models import User, db
+try:
+ from models.all_models import User, db
+except ImportError:
+ try:
+ from models.all_models import User, db
+ except ImportError:
+ from backend.models.all_models import User, db
from app import create_app
# Add debug logging
diff --git a/backend/config.py b/backend/config.py
index 6cb719ba..11b2a284 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -13,7 +13,7 @@
PROJECT_ROOT = os.path.dirname(BACKEND_DIR)
INSTANCE_DIR = os.path.join(BACKEND_DIR, 'instance')
-# Ensure instance directory exists
+# Ensure instance directory exists (for uploads and logs)
try:
os.makedirs(INSTANCE_DIR, exist_ok=True)
logger.info(f"Ensured instance directory exists at: {INSTANCE_DIR}")
@@ -27,26 +27,25 @@
load_dotenv(dotenv_path=dotenv_path)
def get_db_uri():
- """Get the database URI, ensuring SQLite paths are absolute"""
+ """Get the database URI, ensuring proper PostgreSQL formatting"""
+ # Try DATABASE_URL first (your environment variable name)
db_url = os.environ.get('DATABASE_URL')
- if db_url and db_url.startswith('sqlite:///'):
- # Convert relative path to absolute if needed
- db_path = db_url.replace('sqlite:///', '')
- if not os.path.isabs(db_path):
- # Make path absolute relative to project root
- db_path = os.path.join(PROJECT_ROOT, db_path)
- return f'sqlite:///{db_path}'
- elif db_url and db_url.startswith('sqlite+aiosqlite:///'):
- # Convert aiosqlite to regular sqlite for Flask
- db_path = db_url.replace('sqlite+aiosqlite:///', '')
- if not os.path.isabs(db_path):
- db_path = os.path.join(PROJECT_ROOT, db_path)
- return f'sqlite:///{db_path}'
- elif db_url:
- return db_url
- else:
- # Default to absolute path in instance directory
- return f'sqlite+aiosqlite:///{os.path.join(INSTANCE_DIR, "instant_apply.db")}'
+ if not db_url:
+ # Default to local PostgreSQL
+ return 'postgresql://postgres:password@localhost:5432/instantapply_dev'
+
+ # Clean up any SQLite references that might be lingering
+ if 'sqlite' in db_url.lower():
+ logger.warning(f"SQLite URL detected, switching to PostgreSQL: {db_url}")
+ return 'postgresql://postgres:password@localhost:5432/instantapply_dev'
+
+ # For PostgreSQL, ensure proper format
+ if db_url.startswith('postgres://'):
+ # Convert postgres:// to postgresql:// for SQLAlchemy compatibility
+ db_url = db_url.replace('postgres://', 'postgresql://', 1)
+
+ logger.info(f"Using database URI: {db_url}")
+ return db_url
class Config:
"""Base configuration class"""
@@ -55,35 +54,11 @@ class Config:
DEBUG = False
TESTING = False
- # Database settings
- INSTANCE_PATH = INSTANCE_DIR # Set Flask instance path
+ # Database settings - Use get_db_uri() to get the proper URL
+ INSTANCE_PATH = INSTANCE_DIR
SQLALCHEMY_DATABASE_URI = get_db_uri()
SQLALCHEMY_TRACK_MODIFICATIONS = False
- # Base SQLAlchemy engine options - conditional based on database type
- @property
- def SQLALCHEMY_ENGINE_OPTIONS(self):
- db_uri = self.SQLALCHEMY_DATABASE_URI
- if db_uri and 'sqlite' in db_uri.lower():
- # SQLite-specific options
- return {
- 'echo': os.environ.get('SQL_ECHO', 'false').lower() == 'true',
- 'connect_args': {
- 'timeout': 30,
- 'check_same_thread': False
- }
- }
- else:
- # PostgreSQL and other database options
- return {
- 'pool_size': int(os.environ.get('DB_POOL_SIZE', 10)),
- 'max_overflow': int(os.environ.get('DB_MAX_OVERFLOW', 20)),
- 'pool_timeout': int(os.environ.get('DB_POOL_TIMEOUT', 30)),
- 'pool_recycle': int(os.environ.get('DB_POOL_RECYCLE', 1800)),
- 'pool_pre_ping': True,
- 'echo': os.environ.get('SQL_ECHO', 'false').lower() == 'true'
- }
-
# Database retry settings
DB_RETRY_ATTEMPTS = int(os.environ.get('DB_RETRY_ATTEMPTS', 5))
DB_RETRY_DELAY = int(os.environ.get('DB_RETRY_DELAY', 2))
@@ -93,6 +68,7 @@ def SQLALCHEMY_ENGINE_OPTIONS(self):
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
+ SESSION_COOKIE_NAME = 'instantapply_session'
# Security settings
WTF_CSRF_ENABLED = True
@@ -110,11 +86,13 @@ def SQLALCHEMY_ENGINE_OPTIONS(self):
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://127.0.0.1:8080')
# CORS settings
- CORS_ORIGINS = [
+ CORS_ORIGINS = [
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:5000",
"http://127.0.0.1:5000",
+ "http://localhost:8080",
+ "http://127.0.0.1:8080",
"https://instantapply.tech",
"https://www.instantapply.tech"
]
@@ -129,13 +107,23 @@ def SQLALCHEMY_ENGINE_OPTIONS(self):
UPLOAD_FOLDER = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'uploads')
ALLOWED_EXTENSIONS = {'pdf', 'doc', 'docx', 'txt'}
+ # PostgreSQL database engine options - FIXED: Not a property anymore
+ SQLALCHEMY_ENGINE_OPTIONS = {
+ 'pool_size': int(os.environ.get('DB_POOL_SIZE', 10)),
+ 'max_overflow': int(os.environ.get('DB_MAX_OVERFLOW', 20)),
+ 'pool_timeout': int(os.environ.get('DB_POOL_TIMEOUT', 30)),
+ 'pool_recycle': int(os.environ.get('DB_POOL_RECYCLE', 1800)),
+ 'pool_pre_ping': True,
+ 'echo': False
+ }
+
# Logging configuration
LOGGING_CONFIG = {
'version': 1,
- 'disable_existing_loggers': True, # Disable all existing loggers
+ 'disable_existing_loggers': True,
'formatters': {
'default': {
- 'format': '%(levelname)s: %(message)s' # Even simpler format
+ 'format': '%(levelname)s: %(message)s'
}
},
'handlers': {
@@ -148,17 +136,17 @@ def SQLALCHEMY_ENGINE_OPTIONS(self):
'loggers': {
'sqlalchemy.engine': {
'handlers': ['console'],
- 'level': 'ERROR', # Only show SQL errors
+ 'level': 'ERROR',
'propagate': False
},
'werkzeug': {
'handlers': ['console'],
- 'level': 'ERROR', # Only show errors
+ 'level': 'ERROR',
'propagate': False
},
'flask_cors': {
'handlers': ['console'],
- 'level': 'ERROR', # Only show errors
+ 'level': 'ERROR',
'propagate': False
},
'job_pipeline': {
@@ -166,7 +154,7 @@ def SQLALCHEMY_ENGINE_OPTIONS(self):
'level': 'INFO',
'propagate': False
},
- 'backend': { # Add specific logger for our backend code
+ 'backend': {
'handlers': ['console'],
'level': 'INFO',
'propagate': False
@@ -174,7 +162,7 @@ def SQLALCHEMY_ENGINE_OPTIONS(self):
},
'root': {
'handlers': ['console'],
- 'level': 'WARNING' # Only show warnings and errors for unknown loggers
+ 'level': 'WARNING'
}
}
@@ -182,126 +170,84 @@ class DevelopmentConfig(Config):
"""Development configuration"""
DEBUG = True
SQLALCHEMY_TRACK_MODIFICATIONS = True
- SESSION_COOKIE_SECURE = False # Must be False for HTTP in development
- SESSION_COOKIE_DOMAIN = None # Allow cookies to work on localhost/127.0.0.1
- SESSION_COOKIE_SAMESITE = 'Lax' # Allow cross-tab sharing
+ SESSION_COOKIE_SECURE = False
+ SESSION_COOKIE_DOMAIN = None
+ SESSION_COOKIE_SAMESITE = 'Lax'
- # Use the same database path as base config
- SQLALCHEMY_DATABASE_URI = Config.SQLALCHEMY_DATABASE_URI
+ # Use local PostgreSQL for development - also check DATABASE_URL
+ SQLALCHEMY_DATABASE_URI = get_db_uri()
# Frontend URL for development
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://127.0.0.1:8080')
# Development-specific CORS settings
- CORS_ORIGINS = ["http://localhost:3000"]
- CORS_SUPPORTS_CREDENTIALS = True
- CORS_METHODS = ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"]
- CORS_ALLOW_HEADERS = ["Content-Type", "Authorization", "X-Requested-With", "Accept"]
- CORS_EXPOSE_HEADERS = ["Content-Type", "Authorization"]
- CORS_MAX_AGE = 3600
+ CORS_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:8080", "http://127.0.0.1:8080"]
- # Logging configuration
- LOGGING_CONFIG = {
- 'version': 1,
- 'disable_existing_loggers': False,
- 'formatters': {
- 'default': {
- 'format': '%(levelname)s - %(message)s' # Simplified format
- }
- },
- 'handlers': {
- 'console': {
- 'class': 'logging.StreamHandler',
- 'formatter': 'default',
- 'level': 'INFO'
- }
- },
- 'loggers': {
- 'sqlalchemy.engine': {
- 'handlers': ['console'],
- 'level': 'ERROR', # Only show SQL errors
- 'propagate': False
- },
- 'werkzeug': {
- 'handlers': ['console'],
- 'level': 'WARNING', # Only show warnings and errors
- 'propagate': False
- },
- 'flask_cors': {
- 'handlers': ['console'],
- 'level': 'WARNING', # Only show warnings and errors
- 'propagate': False
- },
- 'job_pipeline': { # Add specific logger for job pipeline
- 'handlers': ['console'],
- 'level': 'INFO',
- 'propagate': False
- }
- },
- 'root': {
- 'handlers': ['console'],
- 'level': 'INFO'
- }
+ # Development PostgreSQL settings - FIXED: Not a property anymore
+ SQLALCHEMY_ENGINE_OPTIONS = {
+ 'pool_size': 5,
+ 'max_overflow': 10,
+ 'pool_timeout': 30,
+ 'pool_recycle': 1800,
+ 'pool_pre_ping': True,
+ 'echo': False
}
class TestingConfig(Config):
"""Testing configuration"""
TESTING = True
DEBUG = True
- SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
+ SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or get_db_uri()
WTF_CSRF_ENABLED = False
SESSION_COOKIE_SECURE = False
+
+ # Testing PostgreSQL settings - FIXED: Not a property anymore
+ SQLALCHEMY_ENGINE_OPTIONS = {
+ 'pool_size': 2,
+ 'max_overflow': 5,
+ 'pool_timeout': 30,
+ 'pool_recycle': 300,
+ 'pool_pre_ping': True,
+ 'echo': False
+ }
class ProductionConfig(Config):
"""Production configuration"""
DEBUG = False
TESTING = False
- # Production-specific settings
- SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
+ SQLALCHEMY_DATABASE_URI = get_db_uri()
SECRET_KEY = os.environ.get('SECRET_KEY')
WTF_CSRF_SECRET_KEY = os.environ.get('WTF_CSRF_SECRET_KEY')
- # Frontend URL for production
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'https://instantapply.tech')
- @property
- def SQLALCHEMY_ENGINE_OPTIONS(self):
- """Production-specific database settings"""
- db_uri = self.SQLALCHEMY_DATABASE_URI or ''
-
- if 'sqlite' in db_uri.lower():
- # SQLite-specific options for production
- return {
- 'echo': False,
- 'connect_args': {
- 'timeout': 30,
- 'check_same_thread': False
- }
- }
- else:
- # PostgreSQL and other database options for production
- options = {
- 'pool_size': int(os.environ.get('DB_POOL_SIZE', 20)),
- 'max_overflow': int(os.environ.get('DB_MAX_OVERFLOW', 30)),
- 'pool_timeout': int(os.environ.get('DB_POOL_TIMEOUT', 30)),
- 'pool_recycle': int(os.environ.get('DB_POOL_RECYCLE', 1800)),
- 'pool_pre_ping': True,
- 'echo': False,
- }
-
- # SSL settings for production PostgreSQL database
- if db_uri.startswith('postgresql'):
- options['connect_args'] = {
- 'sslmode': 'require'
- }
-
- return options
+ # Production PostgreSQL settings - FIXED: Not a property anymore
+ SQLALCHEMY_ENGINE_OPTIONS = {
+ 'pool_size': int(os.environ.get('DB_POOL_SIZE', 20)),
+ 'max_overflow': int(os.environ.get('DB_MAX_OVERFLOW', 30)),
+ 'pool_timeout': int(os.environ.get('DB_POOL_TIMEOUT', 30)),
+ 'pool_recycle': int(os.environ.get('DB_POOL_RECYCLE', 1800)),
+ 'pool_pre_ping': True,
+ 'echo': False,
+ 'connect_args': {
+ 'sslmode': 'require'
+ } if get_db_uri().startswith('postgresql') else {}
+ }
- # Ensure secure settings in production
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
+ SESSION_COOKIE_NAME = 'instantapply_session'
+ SESSION_COOKIE_DOMAIN = '.instantapply.tech'
+
+ MAX_CONTENT_LENGTH = 32 * 1024 * 1024
+ UPLOAD_FOLDER = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'uploads')
+
+ try:
+ os.makedirs(UPLOAD_FOLDER, exist_ok=True)
+ except Exception:
+ pass
# Configuration dictionary
config = {
@@ -313,8 +259,5 @@ def SQLALCHEMY_ENGINE_OPTIONS(self):
def get_config():
"""Get configuration based on environment"""
- env = os.environ.get('FLASK_ENV', 'default')
+ env = os.environ.get('FLASK_ENV', 'development')
return config.get(env, config['default'])
-
-# Remove duplicate settings at module level
-# These are now handled in the Config classes
diff --git a/backend/config/__init__.py b/backend/config/__init__.py
deleted file mode 100644
index 2b4f6d4d..00000000
--- a/backend/config/__init__.py
+++ /dev/null
@@ -1,241 +0,0 @@
-"""
-Configuration package for InstantApply backend
-"""
-import os
-import logging
-from datetime import timedelta
-from dotenv import load_dotenv
-from pathlib import Path
-
-# Configure logging
-logging.basicConfig(level=logging.INFO)
-logger = logging.getLogger(__name__)
-
-# Get absolute paths
-BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-PROJECT_ROOT = os.path.dirname(BACKEND_DIR)
-INSTANCE_DIR = os.path.join(BACKEND_DIR, 'instance')
-
-# Ensure instance directory exists
-try:
- os.makedirs(INSTANCE_DIR, exist_ok=True)
- logger.info(f"Ensured instance directory exists at: {INSTANCE_DIR}")
-except Exception as e:
- logger.error(f"Failed to create instance directory: {e}")
- raise
-
-# Load .env file from project root
-dotenv_path = os.path.join(PROJECT_ROOT, '.env')
-logger.info(f"Loading .env file from: {dotenv_path}")
-load_dotenv(dotenv_path=dotenv_path)
-
-def get_db_uri():
- """Get the database URI, ensuring SQLite paths are absolute"""
- db_url = os.environ.get('DATABASE_URL')
- if db_url and db_url.startswith('sqlite:///'):
- # Convert relative path to absolute if needed
- db_path = db_url.replace('sqlite:///', '')
- if not os.path.isabs(db_path):
- # Make path absolute relative to project root
- db_path = os.path.join(PROJECT_ROOT, db_path)
- return f'sqlite:///{db_path}'
- elif db_url:
- return db_url
- else:
- # Default to absolute path in instance directory
- return f'sqlite:///{os.path.join(INSTANCE_DIR, "instant_apply.db")}'
-
-class Config:
- """Base configuration class"""
- # Flask settings
- SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key'
- DEBUG = False
- TESTING = False
-
- # Database settings
- INSTANCE_PATH = INSTANCE_DIR # Set Flask instance path
- SQLALCHEMY_DATABASE_URI = get_db_uri()
- SQLALCHEMY_TRACK_MODIFICATIONS = False
-
- # Database connection pool settings
- SQLALCHEMY_ENGINE_OPTIONS = {
- 'pool_size': int(os.environ.get('DB_POOL_SIZE', 10)),
- 'max_overflow': int(os.environ.get('DB_MAX_OVERFLOW', 20)),
- 'pool_timeout': int(os.environ.get('DB_POOL_TIMEOUT', 30)),
- 'pool_recycle': int(os.environ.get('DB_POOL_RECYCLE', 1800)),
- 'pool_pre_ping': True,
- 'echo': os.environ.get('SQL_ECHO', 'false').lower() == 'true', # Use SQL_ECHO env var
- 'connect_args': {
- 'timeout': 30,
- 'check_same_thread': False
- } if 'sqlite' in (os.environ.get('DATABASE_URL') or '').lower() else {}
- }
-
- # Database retry settings
- DB_RETRY_ATTEMPTS = int(os.environ.get('DB_RETRY_ATTEMPTS', 5))
- DB_RETRY_DELAY = int(os.environ.get('DB_RETRY_DELAY', 2))
-
- # Session settings
- PERMANENT_SESSION_LIFETIME = timedelta(days=7)
- SESSION_COOKIE_SECURE = True
- SESSION_COOKIE_HTTPONLY = True
- SESSION_COOKIE_SAMESITE = 'Lax'
-
- # Security settings
- WTF_CSRF_ENABLED = True
- WTF_CSRF_SECRET_KEY = os.environ.get('WTF_CSRF_SECRET_KEY') or 'csrf-secret-key'
-
- # Mail settings
- MAIL_SERVER = os.environ.get('MAIL_SERVER', 'smtp.gmail.com')
- MAIL_PORT = int(os.environ.get('MAIL_PORT', 587))
- MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', True)
- MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
- MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
- MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER')
-
- # CORS settings
- CORS_ORIGINS = ["http://localhost:3000"] # Your React app's origin
- CORS_SUPPORTS_CREDENTIALS = True
- CORS_METHODS = ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"]
- CORS_ALLOW_HEADERS = ["Content-Type", "Authorization", "X-Requested-With", "Accept"]
- CORS_EXPOSE_HEADERS = ["Content-Type", "Authorization"]
- CORS_MAX_AGE = 3600
-
- # File upload settings
- MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size
- UPLOAD_FOLDER = os.path.join(BACKEND_DIR, 'uploads')
- ALLOWED_EXTENSIONS = {'pdf', 'doc', 'docx', 'txt'}
-
- # Logging configuration
- LOGGING_CONFIG = {
- 'version': 1,
- 'disable_existing_loggers': True, # Disable all existing loggers
- 'formatters': {
- 'default': {
- 'format': '%(levelname)s: %(message)s' # Even simpler format
- }
- },
- 'handlers': {
- 'console': {
- 'class': 'logging.StreamHandler',
- 'formatter': 'default',
- 'level': 'INFO'
- }
- },
- 'loggers': {
- 'sqlalchemy.engine': {
- 'handlers': ['console'],
- 'level': 'ERROR', # Only show SQL errors
- 'propagate': False
- },
- 'werkzeug': {
- 'handlers': ['console'],
- 'level': 'ERROR', # Only show errors
- 'propagate': False
- },
- 'flask_cors': {
- 'handlers': ['console'],
- 'level': 'ERROR', # Only show errors
- 'propagate': False
- },
- 'job_pipeline': {
- 'handlers': ['console'],
- 'level': 'INFO',
- 'propagate': False
- },
- 'backend': { # Add specific logger for our backend code
- 'handlers': ['console'],
- 'level': 'INFO',
- 'propagate': False
- }
- },
- 'root': {
- 'handlers': ['console'],
- 'level': 'WARNING' # Only show warnings and errors for unknown loggers
- }
- }
-
-class DevelopmentConfig(Config):
- """Development configuration"""
- DEBUG = True
- SQLALCHEMY_TRACK_MODIFICATIONS = True
- SESSION_COOKIE_SECURE = False
-
- # Development-specific database settings
- SQLALCHEMY_ENGINE_OPTIONS = {
- **Config.SQLALCHEMY_ENGINE_OPTIONS, # Inherit base config including SQL_ECHO setting
- 'pool_size': 5,
- 'max_overflow': 10,
- }
-
- # Use the same database path as base config
- SQLALCHEMY_DATABASE_URI = Config.SQLALCHEMY_DATABASE_URI
-
- # Development-specific CORS settings
- CORS_ORIGINS = ["http://localhost:3000"]
- CORS_SUPPORTS_CREDENTIALS = True
- CORS_METHODS = ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"]
- CORS_ALLOW_HEADERS = ["Content-Type", "Authorization", "X-Requested-With", "Accept"]
- CORS_EXPOSE_HEADERS = ["Content-Type", "Authorization"]
- CORS_MAX_AGE = 3600
-
-class TestingConfig(Config):
- """Testing configuration"""
- TESTING = True
- DEBUG = True
- SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
- WTF_CSRF_ENABLED = False
- SESSION_COOKIE_SECURE = False
-
- # SQLite in-memory doesn't need pool settings
- SQLALCHEMY_ENGINE_OPTIONS = {
- 'echo': False,
- 'connect_args': {
- 'timeout': 30,
- 'check_same_thread': False
- }
- }
-
-class ProductionConfig(Config):
- """Production configuration"""
- DEBUG = False
- TESTING = False
-
- # Production-specific settings
- SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
- SECRET_KEY = os.environ.get('SECRET_KEY')
- WTF_CSRF_SECRET_KEY = os.environ.get('WTF_CSRF_SECRET_KEY')
-
- # Production-specific database settings
- SQLALCHEMY_ENGINE_OPTIONS = {
- **Config.SQLALCHEMY_ENGINE_OPTIONS,
- 'pool_size': int(os.environ.get('DB_POOL_SIZE', 20)),
- 'max_overflow': int(os.environ.get('DB_MAX_OVERFLOW', 30)),
- 'pool_timeout': int(os.environ.get('DB_POOL_TIMEOUT', 30)),
- 'pool_recycle': int(os.environ.get('DB_POOL_RECYCLE', 1800)),
- 'pool_pre_ping': True,
- 'echo': False,
- }
-
- # SSL settings for production database
- if SQLALCHEMY_DATABASE_URI and SQLALCHEMY_DATABASE_URI.startswith('postgresql'):
- SQLALCHEMY_ENGINE_OPTIONS['connect_args'] = {
- 'sslmode': 'require'
- }
-
- # Ensure secure settings in production
- SESSION_COOKIE_SECURE = True
- SESSION_COOKIE_HTTPONLY = True
- SESSION_COOKIE_SAMESITE = 'Lax'
-
-def get_config():
- """Get the appropriate configuration class based on environment"""
- env = os.environ.get('FLASK_ENV', 'development')
- if env == 'production':
- return ProductionConfig
- elif env == 'testing':
- return TestingConfig
- else:
- return DevelopmentConfig
-
-__all__ = ['Config', 'DevelopmentConfig', 'TestingConfig', 'ProductionConfig', 'get_config']
\ No newline at end of file
diff --git a/backend/config/logging_config.py b/backend/config/logging_config.py
deleted file mode 100644
index 5cbad8ae..00000000
--- a/backend/config/logging_config.py
+++ /dev/null
@@ -1,33 +0,0 @@
-import logging
-import os
-
-def configure_logging():
- """Configure logging levels for different modules"""
- # Disable all logging except CRITICAL
- logging.disable(logging.DEBUG)
- logging.getLogger().setLevel(logging.CRITICAL)
-
- # Explicitly disable all database-related loggers
- for logger_name in ['aiosqlite', 'sqlalchemy', 'sqlalchemy.engine', 'sqlalchemy.pool',
- 'sqlalchemy.orm', 'sqlalchemy.dialects', 'sqlalchemy.engine.Engine',
- 'sqlalchemy.engine.base', 'sqlalchemy.dialects.sqlite']:
- logger = logging.getLogger(logger_name)
- logger.setLevel(logging.CRITICAL)
- logger.propagate = False
-
- # Set our application logs to CRITICAL only
- for logger_name in ['services', 'utils', 'models']:
- logger = logging.getLogger(logger_name)
- logger.setLevel(logging.CRITICAL)
-
- # Remove all existing handlers
- root_logger = logging.getLogger()
- for handler in root_logger.handlers[:]:
- root_logger.removeHandler(handler)
-
- # Only add a handler if we want to see critical errors
- if os.getenv('SHOW_CRITICAL_LOGS', 'false').lower() == 'true':
- console_handler = logging.StreamHandler()
- console_handler.setLevel(logging.CRITICAL)
- console_handler.setFormatter(logging.Formatter('%(levelname)s - %(message)s'))
- root_logger.addHandler(console_handler)
\ No newline at end of file
diff --git a/backend/create_user.py b/backend/create_user.py
index bd00fe12..5a74246e 100644
--- a/backend/create_user.py
+++ b/backend/create_user.py
@@ -1,47 +1,36 @@
-#!/usr/bin/env python3
-"""Quick script to create users directly."""
+"""
+User creation utility script
+"""
+import sys
+import os
-def create_user(email, password, name="Test User"):
- from app import create_app
- from backend.models.db import db
- from backend.models.all_models import User
-
- app = create_app()
-
- with app.app_context():
- # Check if user exists
- existing_user = User.query.filter_by(email=email).first()
- if existing_user:
- print(f"User {email} already exists!")
- return False
-
- # Create new user
- name_parts = name.split(' ', 1)
- new_user = User(
- first_name=name_parts[0],
- last_name=name_parts[1] if len(name_parts) > 1 else "",
- email=email,
- role="user"
- )
- new_user.set_password(password)
-
- try:
- db.session.add(new_user)
- db.session.commit()
- print(f"✅ Created user: {email}")
- return True
- except Exception as e:
- print(f"❌ Failed: {e}")
- db.session.rollback()
- return False
+try:
+ from models.all_models import User, db
+except ImportError:
+ try:
+ from backend.models.all_models import User, db
+ except ImportError:
+ print("Could not import User model")
+ sys.exit(1)
+
+def create_user(name, email, password):
+ """Create a new user"""
+ try:
+ user = User(name=name, email=email)
+ user.set_password(password)
+ db.session.add(user)
+ db.session.commit()
+ print(f"User {name} created successfully")
+ return user
+ except Exception as e:
+ print(f"Error creating user: {e}")
+ db.session.rollback()
+ return None
if __name__ == "__main__":
- # Create a few test users
- users = [
- ("test@example.com", "TestPass123!", "Test User"),
- ("jane@example.com", "Password123!", "Jane Doe"),
- ("admin@example.com", "Admin123!", "Admin User")
- ]
+ if len(sys.argv) != 4:
+ print("Usage: python create_user.py ")
+ sys.exit(1)
- for email, password, name in users:
- create_user(email, password, name)
\ No newline at end of file
+ name, email, password = sys.argv[1:4]
+ create_user(name, email, password)
diff --git a/backend/database.py b/backend/database.py
index 8327d151..379c59ae 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -1,65 +1,43 @@
+"""Database configuration and utilities"""
import os
+from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import create_engine
-from sqlalchemy.ext.asyncio import create_async_engine
+from sqlalchemy.orm import sessionmaker
from dotenv import load_dotenv
import pathlib
-print("BEFORE load_dotenv - DATABASE_URL:", os.environ.get("DATABASE_URL"))
+# Initialize SQLAlchemy
+db = SQLAlchemy()
# Load environment variables from project root
project_root = pathlib.Path(__file__).parent.parent
load_dotenv(dotenv_path=project_root / ".env")
-print("AFTER load_dotenv - DATABASE_URL:", os.environ.get("DATABASE_URL"))
-print("Project root:", project_root)
-print(".env file exists:", (project_root / ".env").exists())
-
-def get_db_url(testing=False):
- """
- Get the database URL based on the environment.
-
- Args:
- testing (bool): If True, returns a test database URL (SQLite in-memory)
+def get_database_url():
+ """Get the database URL, ensuring it's PostgreSQL"""
+ db_url = os.environ.get('DATABASE_URL', 'postgresql://postgres:password@localhost:5432/instantapply_dev')
- Returns:
- str: The database URL
- """
- if testing:
- return "sqlite+aiosqlite:///:memory:"
-
- # Get database URL from environment variable
- db_url = os.getenv("DATABASE_URL")
- if not db_url:
- # Default to SQLite if no DATABASE_URL is set
- db_url = "sqlite+aiosqlite:///backend/instance/instant_apply.db"
+ # Ensure we're using PostgreSQL
+ if not db_url.startswith('postgresql'):
+ raise ValueError(f"Expected PostgreSQL URL, got: {db_url}")
return db_url
-def get_engine(testing=False):
- """
- Get a SQLAlchemy engine instance.
-
- Args:
- testing (bool): If True, returns a test database engine
-
- Returns:
- Engine: A SQLAlchemy engine instance
- """
- url = get_db_url(testing)
- return create_engine(url)
+def create_database_engine():
+ """Create database engine with proper PostgreSQL settings"""
+ db_url = get_database_url()
+
+ engine = create_engine(
+ db_url,
+ pool_size=10,
+ max_overflow=20,
+ pool_timeout=30,
+ pool_recycle=1800,
+ pool_pre_ping=True,
+ echo=False
+ )
+
+ return engine
-def get_async_engine(testing=False):
- """
- Get an async SQLAlchemy engine instance.
-
- Args:
- testing (bool): If True, returns a test database engine
-
- Returns:
- AsyncEngine: An async SQLAlchemy engine instance
- """
- url = get_db_url(testing)
- return create_async_engine(
- url,
- echo=os.getenv("SQL_ECHO", "false").lower() == "true"
- )
\ No newline at end of file
+# Create session factory
+SessionLocal = sessionmaker(autocommit=False, autoflush=False)
\ No newline at end of file
diff --git a/backend/forms/profile.py b/backend/forms/profile.py
index a9ca28d6..49e21cc3 100644
--- a/backend/forms/profile.py
+++ b/backend/forms/profile.py
@@ -1,12 +1,375 @@
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,
+ 'linkedin_url': 500,
+ 'github_url': 500,
+ 'portfolio_url': 500
+ }
+
+ 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/migrations/env.py b/backend/migrations/env.py
index 215b636d..f14dbca0 100644
--- a/backend/migrations/env.py
+++ b/backend/migrations/env.py
@@ -1,6 +1,7 @@
+from __future__ import with_statement
+
import logging
from logging.config import fileConfig
-import sqlite3
from flask import current_app
@@ -15,79 +16,21 @@
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
-
-def get_engine():
- try:
- # this works with Flask-SQLAlchemy<3 and Alchemical
- return current_app.extensions['migrate'].db.get_engine()
- except (TypeError, AttributeError):
- # this works with Flask-SQLAlchemy>=3
- return current_app.extensions['migrate'].db.engine
-
-
-def get_engine_url():
- try:
- return get_engine().url.render_as_string(hide_password=False).replace(
- '%', '%%')
- except AttributeError:
- return str(get_engine().url).replace('%', '%%')
-
-
-def handle_fts_tables(connection):
- """Handle FTS tables before migration starts."""
- if isinstance(connection.connection, sqlite3.Connection):
- cursor = connection.connection.cursor()
- try:
- # Get list of existing FTS tables
- cursor.execute("""
- SELECT name FROM sqlite_master
- WHERE type='table' AND name LIKE 'jobs_fts%'
- """)
- fts_tables = [row[0] for row in cursor.fetchall()]
-
- # Drop each FTS table if it exists
- for table in fts_tables:
- try:
- cursor.execute(f"DROP TABLE IF EXISTS {table}")
- except sqlite3.DatabaseError:
- try:
- cursor.execute(f"DROP VIRTUAL TABLE IF EXISTS {table}")
- except sqlite3.DatabaseError as e:
- logger.warning(f"Could not drop FTS table {table}: {e}")
-
- connection.connection.commit()
- except Exception as e:
- connection.connection.rollback()
- logger.error(f"Error handling FTS tables: {e}")
- raise
-
-
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
-config.set_main_option('sqlalchemy.url', get_engine_url())
-target_db = current_app.extensions['migrate'].db
+config.set_main_option(
+ 'sqlalchemy.url',
+ str(current_app.extensions['migrate'].db.get_engine().url).replace(
+ '%', '%%'))
+target_metadata = current_app.extensions['migrate'].db.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
-
-def get_metadata():
- if hasattr(target_db, 'metadatas'):
- return target_db.metadatas[None]
- return target_db.metadata
-
-
-def include_object(object, name, type_, reflected, compare_to):
- # Exclude FTS tables from Alembic's inspection
- if type_ == "table" and name.startswith("jobs_fts"):
- return False
- return True
-
-
def run_migrations_offline():
"""Run migrations in 'offline' mode.
@@ -102,16 +45,12 @@ def run_migrations_offline():
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
- url=url,
- target_metadata=get_metadata(),
- literal_binds=True,
- include_object=include_object
+ url=url, target_metadata=target_metadata, literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
-
def run_migrations_online():
"""Run migrations in 'online' mode.
@@ -130,27 +69,19 @@ def process_revision_directives(context, revision, directives):
directives[:] = []
logger.info('No changes in schema detected.')
- conf_args = current_app.extensions['migrate'].configure_args
- if conf_args.get("process_revision_directives") is None:
- conf_args["process_revision_directives"] = process_revision_directives
-
- connectable = get_engine()
+ connectable = current_app.extensions['migrate'].db.get_engine()
with connectable.connect() as connection:
- # Handle FTS tables before starting migration
- handle_fts_tables(connection)
-
context.configure(
connection=connection,
- target_metadata=get_metadata(),
- include_object=include_object,
- **conf_args
+ target_metadata=target_metadata,
+ process_revision_directives=process_revision_directives,
+ **current_app.extensions['migrate'].configure_args
)
with context.begin_transaction():
context.run_migrations()
-
if context.is_offline_mode():
run_migrations_offline()
else:
diff --git a/backend/migrations/versions/add_email_verification_fields.py b/backend/migrations/versions/add_email_verification_fields.py
index a3ee8d5b..87760617 100644
--- a/backend/migrations/versions/add_email_verification_fields.py
+++ b/backend/migrations/versions/add_email_verification_fields.py
@@ -8,14 +8,12 @@
from alembic import op
import sqlalchemy as sa
-
# revision identifiers, used by Alembic.
revision = 'add_email_verification_fields'
down_revision = 'c02b37a1ccb6'
branch_labels = None
depends_on = None
-
def upgrade():
# Add email verification and password reset fields to users table
with op.batch_alter_table('users', schema=None) as batch_op:
@@ -24,7 +22,6 @@ def upgrade():
batch_op.add_column(sa.Column('reset_token', sa.String(length=100), nullable=True))
batch_op.add_column(sa.Column('reset_token_expires', sa.DateTime(), nullable=True))
-
def downgrade():
# Remove email verification and password reset fields from users table
with op.batch_alter_table('users', schema=None) as batch_op:
diff --git a/backend/migrations/versions/b59d0790fcac_add_moderator_action_constants_and_.py b/backend/migrations/versions/b59d0790fcac_add_moderator_action_constants_and_.py
index 67c90026..cf0cc005 100644
--- a/backend/migrations/versions/b59d0790fcac_add_moderator_action_constants_and_.py
+++ b/backend/migrations/versions/b59d0790fcac_add_moderator_action_constants_and_.py
@@ -8,14 +8,12 @@
from alembic import op
import sqlalchemy as sa
-
# revision identifiers, used by Alembic.
revision = 'b59d0790fcac'
down_revision = 'c02b37a1ccb6'
branch_labels = None
depends_on = None
-
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('job_keywords_association', schema=None) as batch_op:
@@ -144,7 +142,6 @@ def upgrade():
# ### end Alembic commands ###
-
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('waitlist', schema=None) as batch_op:
diff --git a/backend/migrations/versions/c02b37a1ccb6_merge_heads.py b/backend/migrations/versions/c02b37a1ccb6_merge_heads.py
index 5c09f2ea..c6c6adac 100644
--- a/backend/migrations/versions/c02b37a1ccb6_merge_heads.py
+++ b/backend/migrations/versions/c02b37a1ccb6_merge_heads.py
@@ -8,17 +8,14 @@
from alembic import op
import sqlalchemy as sa
-
# revision identifiers, used by Alembic.
revision = 'c02b37a1ccb6'
down_revision = ('add_jobs_fts', 'add_subscription_and_recommendations')
branch_labels = None
depends_on = None
-
def upgrade():
pass
-
def downgrade():
pass
diff --git a/backend/migrations/versions/ec7abfcbf940_merge_multiple_migration_heads.py b/backend/migrations/versions/ec7abfcbf940_merge_multiple_migration_heads.py
index 29acfa29..dfe6c16c 100644
--- a/backend/migrations/versions/ec7abfcbf940_merge_multiple_migration_heads.py
+++ b/backend/migrations/versions/ec7abfcbf940_merge_multiple_migration_heads.py
@@ -8,17 +8,14 @@
from alembic import op
import sqlalchemy as sa
-
# revision identifiers, used by Alembic.
revision = 'ec7abfcbf940'
down_revision = ('add_email_verification_fields', 'add_missing_profile_fields', 'add_missing_profile_fields_v2', 'create_keyword_tables', 'update_desired_salary_range_to_json')
branch_labels = None
depends_on = None
-
def upgrade():
pass
-
def downgrade():
pass
diff --git a/backend/migrations/versions/remove_work_style_field.py b/backend/migrations/versions/remove_work_style_field.py
index 38d94533..e7b54907 100644
--- a/backend/migrations/versions/remove_work_style_field.py
+++ b/backend/migrations/versions/remove_work_style_field.py
@@ -8,20 +8,17 @@
from alembic import op
import sqlalchemy as sa
-
# revision identifiers, used by Alembic.
revision = 'remove_work_style_field'
down_revision = 'add_comprehensive_profile_fields'
branch_labels = None
depends_on = None
-
def upgrade():
"""Remove work_style column from users table"""
# Remove the work_style column
op.drop_column('users', 'work_style')
-
def downgrade():
"""Add back work_style column to users table"""
# Add the work_style column back
diff --git a/backend/migrations/versions_backup_20250705_131016/README_PROFILE_FIELDS.md b/backend/migrations/versions_backup_20250705_131016/README_PROFILE_FIELDS.md
new file mode 100644
index 00000000..9d840ef7
--- /dev/null
+++ b/backend/migrations/versions_backup_20250705_131016/README_PROFILE_FIELDS.md
@@ -0,0 +1,69 @@
+# Profile Fields Migration Documentation
+
+## Overview
+This migration adds comprehensive profile fields to the `users` table to support all the profile features expected by the frontend.
+
+## Migration File
+- **File**: `add_comprehensive_profile_fields.py`
+- **Revision ID**: `add_comprehensive_profile_fields`
+- **Previous Revision**: `b59d0790fcac`
+
+## Fields Added
+
+### Basic Profile Fields
+- `willing_to_relocate` (Boolean) - Whether user is willing to relocate
+- `authorization_status` (String(100)) - Work authorization status
+- `visa_status` (String(100)) - Current visa status
+- `race_ethnicity` (String(100)) - Race/ethnicity information
+- `years_of_experience` (Integer) - Years of professional experience
+- `education_level` (String(100)) - Highest education level
+- `industry_preference` (String(200)) - Preferred industry
+
+### Career and Work Preference Fields
+- `career_goals` (Text) - User's career goals
+- `biggest_achievement` (Text) - User's biggest professional achievement
+- `work_style` (String(200)) - User's preferred work style
+- `industry_attraction` (String(200)) - What attracts user to specific industries
+
+### Salary and Availability Fields
+- `desired_salary_range` (String(100)) - Desired salary range
+- `available_start_date` (Date) - When user can start
+- `preferred_company_type` (String(100)) - Preferred company type
+
+### Education and Sponsorship Fields
+- `graduation_date` (Date) - Graduation date
+- `needs_sponsorship` (Boolean) - Whether user needs sponsorship
+
+### Additional Preference Fields
+- `company_size_preference` (String(50)) - Preferred company size
+- `remote_preference` (String(50)) - Remote work preference
+
+## Frontend Integration
+These fields are used by the profile page (`react-frontend/src/pages/Profile.jsx`) for:
+- Dropdown selections (authorization_status, visa_status, etc.)
+- Text inputs (career_goals, biggest_achievement, etc.)
+- Toggle switches (willing_to_relocate, needs_sponsorship)
+- Date inputs (available_start_date, graduation_date)
+
+## Backend Support
+- All fields are included in the User model (`backend/models/all_models.py`)
+- Fields are serialized in the `to_dict()` method
+- Profile API routes handle updates to these fields
+- Profile completion calculation includes these fields
+
+## Payment/Subscription Tracking
+The system properly tracks paying vs non-paying customers through:
+- `subscription_history` table - Tracks subscription changes
+- `orders` table - Tracks payment transactions
+- `subscription_plans` table - Defines available plans
+
+## Usage
+To apply this migration on a new machine:
+1. Ensure the User model includes all these fields
+2. Run the migration: `alembic upgrade head`
+3. Verify the fields exist in the database schema
+
+## Notes
+- All fields are nullable to maintain backward compatibility
+- The migration is idempotent and can be run multiple times safely
+- The downgrade function removes all added fields if needed
\ No newline at end of file
diff --git a/backend/migrations/versions/a36768dbe68e_.py b/backend/migrations/versions_backup_20250705_131016/a36768dbe68e_.py
similarity index 99%
rename from backend/migrations/versions/a36768dbe68e_.py
rename to backend/migrations/versions_backup_20250705_131016/a36768dbe68e_.py
index 051bef26..57e798a1 100644
--- a/backend/migrations/versions/a36768dbe68e_.py
+++ b/backend/migrations/versions_backup_20250705_131016/a36768dbe68e_.py
@@ -10,14 +10,12 @@
import sqlite3
from alembic.context import get_context
-
# revision identifiers, used by Alembic.
revision = 'a36768dbe68e'
down_revision = None
branch_labels = None
depends_on = None
-
def upgrade():
# Handle FTS tables using raw SQLite
context = get_context()
@@ -65,7 +63,6 @@ def upgrade():
connection.rollback()
raise Exception(f"Failed to drop tables: {str(e)}")
-
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('job_postings',
diff --git a/backend/migrations/versions_backup_20250705_131016/add_comprehensive_profile_fields.py b/backend/migrations/versions_backup_20250705_131016/add_comprehensive_profile_fields.py
new file mode 100644
index 00000000..beaba5d3
--- /dev/null
+++ b/backend/migrations/versions_backup_20250705_131016/add_comprehensive_profile_fields.py
@@ -0,0 +1,77 @@
+"""Add comprehensive profile fields to users table
+
+Revision ID: add_comprehensive_profile_fields
+Revises: b59d0790fcac
+Create Date: 2024-12-23 21:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = 'add_comprehensive_profile_fields'
+down_revision = 'b59d0790fcac'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ """Add all missing profile fields to users table"""
+ with op.batch_alter_table('users', schema=None) as batch_op:
+ # Basic profile fields that were missing
+ batch_op.add_column(sa.Column('willing_to_relocate', sa.Boolean(), nullable=True))
+ batch_op.add_column(sa.Column('authorization_status', sa.String(100), nullable=True))
+ batch_op.add_column(sa.Column('visa_status', sa.String(100), nullable=True))
+ batch_op.add_column(sa.Column('race_ethnicity', sa.String(100), nullable=True))
+ batch_op.add_column(sa.Column('years_of_experience', sa.Integer(), nullable=True))
+ batch_op.add_column(sa.Column('education_level', sa.String(100), nullable=True))
+ batch_op.add_column(sa.Column('industry_preference', sa.String(200), nullable=True))
+
+ # Career and work preference fields
+ batch_op.add_column(sa.Column('career_goals', sa.Text(), nullable=True))
+ batch_op.add_column(sa.Column('biggest_achievement', sa.Text(), nullable=True))
+ batch_op.add_column(sa.Column('work_style', sa.String(200), nullable=True))
+ batch_op.add_column(sa.Column('industry_attraction', sa.String(200), nullable=True))
+
+ # Salary and availability fields
+ batch_op.add_column(sa.Column('desired_salary_range', sa.String(100), nullable=True))
+ batch_op.add_column(sa.Column('available_start_date', sa.Date(), nullable=True))
+ batch_op.add_column(sa.Column('preferred_company_type', sa.String(100), nullable=True))
+
+ # Education and sponsorship fields
+ batch_op.add_column(sa.Column('graduation_date', sa.Date(), nullable=True))
+ batch_op.add_column(sa.Column('needs_sponsorship', sa.Boolean(), nullable=True))
+
+ # Additional preference fields
+ batch_op.add_column(sa.Column('company_size_preference', sa.String(50), nullable=True))
+ batch_op.add_column(sa.Column('remote_preference', sa.String(50), nullable=True))
+
+def downgrade():
+ """Remove all added profile fields from users table"""
+ with op.batch_alter_table('users', schema=None) as batch_op:
+ # Remove basic profile fields
+ batch_op.drop_column('willing_to_relocate')
+ batch_op.drop_column('authorization_status')
+ batch_op.drop_column('visa_status')
+ batch_op.drop_column('race_ethnicity')
+ batch_op.drop_column('years_of_experience')
+ batch_op.drop_column('education_level')
+ batch_op.drop_column('industry_preference')
+
+ # Remove career and work preference fields
+ batch_op.drop_column('career_goals')
+ batch_op.drop_column('biggest_achievement')
+ batch_op.drop_column('work_style')
+ batch_op.drop_column('industry_attraction')
+
+ # Remove salary and availability fields
+ batch_op.drop_column('desired_salary_range')
+ batch_op.drop_column('available_start_date')
+ batch_op.drop_column('preferred_company_type')
+
+ # Remove education and sponsorship fields
+ batch_op.drop_column('graduation_date')
+ batch_op.drop_column('needs_sponsorship')
+
+ # Remove additional preference fields
+ batch_op.drop_column('company_size_preference')
+ batch_op.drop_column('remote_preference')
\ No newline at end of file
diff --git a/backend/migrations/versions_backup_20250705_131016/add_email_verification_fields.py b/backend/migrations/versions_backup_20250705_131016/add_email_verification_fields.py
new file mode 100644
index 00000000..87760617
--- /dev/null
+++ b/backend/migrations/versions_backup_20250705_131016/add_email_verification_fields.py
@@ -0,0 +1,31 @@
+"""add email verification fields
+
+Revision ID: add_email_verification_fields
+Revises: c02b37a1ccb6
+Create Date: 2024-01-01 12:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = 'add_email_verification_fields'
+down_revision = 'c02b37a1ccb6'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ # Add email verification and password reset fields to users table
+ with op.batch_alter_table('users', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('verification_token', sa.String(length=100), nullable=True))
+ batch_op.add_column(sa.Column('verification_token_expires', sa.DateTime(), nullable=True))
+ batch_op.add_column(sa.Column('reset_token', sa.String(length=100), nullable=True))
+ batch_op.add_column(sa.Column('reset_token_expires', sa.DateTime(), nullable=True))
+
+def downgrade():
+ # Remove email verification and password reset fields from users table
+ with op.batch_alter_table('users', schema=None) as batch_op:
+ batch_op.drop_column('reset_token_expires')
+ batch_op.drop_column('reset_token')
+ batch_op.drop_column('verification_token_expires')
+ batch_op.drop_column('verification_token')
\ No newline at end of file
diff --git a/backend/migrations/versions_backup_20250705_131016/add_job_keywords.py b/backend/migrations/versions_backup_20250705_131016/add_job_keywords.py
new file mode 100644
index 00000000..f9962540
--- /dev/null
+++ b/backend/migrations/versions_backup_20250705_131016/add_job_keywords.py
@@ -0,0 +1,71 @@
+"""Add job and resume keywords
+
+Revision ID: add_job_keywords
+Revises: a36768dbe68e
+Create Date: 2024-03-21 10:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from datetime import datetime
+
+# revision identifiers, used by Alembic.
+revision = 'add_job_keywords'
+down_revision = 'a36768dbe68e'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ # Create all the keyword tables using SQLAlchemy
+ op.create_table(
+ 'keywords',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('keyword', sa.String(100), nullable=False),
+ sa.Column('category', sa.String(50)), # e.g., 'skill', 'technology', 'domain', 'soft_skill'
+ sa.Column('type', sa.String(20), nullable=False), # 'job' or 'resume'
+ sa.Column('created_at', sa.DateTime(), default=datetime.utcnow),
+ sa.Column('updated_at', sa.DateTime(), default=datetime.utcnow, onupdate=datetime.utcnow),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('keyword', 'type', name='uq_keyword_type')
+ )
+
+ # Create the job_keywords association table
+ op.create_table(
+ 'job_keywords_association',
+ sa.Column('job_id', sa.Integer(), sa.ForeignKey('job_postings.id', ondelete='CASCADE')),
+ sa.Column('keyword_id', sa.Integer(), sa.ForeignKey('keywords.id', ondelete='CASCADE')),
+ sa.Column('relevance_score', sa.Float()), # Score indicating how relevant the keyword is to the job
+ sa.Column('source', sa.String(50)), # How the keyword was derived (e.g., 'title', 'description', 'requirements')
+ sa.Column('created_at', sa.DateTime(), default=datetime.utcnow),
+ sa.PrimaryKeyConstraint('job_id', 'keyword_id')
+ )
+
+ # Create the resume_keywords association table
+ op.create_table(
+ 'resume_keywords_association',
+ sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE')),
+ sa.Column('keyword_id', sa.Integer(), sa.ForeignKey('keywords.id', ondelete='CASCADE')),
+ sa.Column('proficiency_level', sa.String(20)), # e.g., 'beginner', 'intermediate', 'expert'
+ sa.Column('years_experience', sa.Float()), # Years of experience with this skill
+ sa.Column('source', sa.String(50)), # How the keyword was derived (e.g., 'resume', 'profile', 'assessment')
+ sa.Column('last_used', sa.DateTime()), # When this skill was last used
+ sa.Column('created_at', sa.DateTime(), default=datetime.utcnow),
+ sa.Column('updated_at', sa.DateTime(), default=datetime.utcnow, onupdate=datetime.utcnow),
+ sa.PrimaryKeyConstraint('user_id', 'keyword_id')
+ )
+
+ # Create indexes for better performance
+ op.create_index('ix_keywords_keyword', 'keywords', ['keyword'])
+ op.create_index('ix_keywords_category', 'keywords', ['category'])
+ op.create_index('ix_keywords_type', 'keywords', ['type'])
+ op.create_index('ix_job_keywords_association_job_id', 'job_keywords_association', ['job_id'])
+ op.create_index('ix_job_keywords_association_keyword_id', 'job_keywords_association', ['keyword_id'])
+ op.create_index('ix_resume_keywords_association_user_id', 'resume_keywords_association', ['user_id'])
+ op.create_index('ix_resume_keywords_association_keyword_id', 'resume_keywords_association', ['keyword_id'])
+ op.create_index('ix_resume_keywords_association_proficiency', 'resume_keywords_association', ['proficiency_level'])
+
+def downgrade():
+ # Drop the keyword tables
+ op.drop_table('resume_keywords_association')
+ op.drop_table('job_keywords_association')
+ op.drop_table('keywords')
\ No newline at end of file
diff --git a/backend/migrations/versions/add_jobs_fts.py b/backend/migrations/versions_backup_20250705_131016/add_jobs_fts.py
similarity index 100%
rename from backend/migrations/versions/add_jobs_fts.py
rename to backend/migrations/versions_backup_20250705_131016/add_jobs_fts.py
diff --git a/backend/migrations/versions_backup_20250705_131016/add_missing_profile_fields.py b/backend/migrations/versions_backup_20250705_131016/add_missing_profile_fields.py
new file mode 100644
index 00000000..f86819fa
--- /dev/null
+++ b/backend/migrations/versions_backup_20250705_131016/add_missing_profile_fields.py
@@ -0,0 +1,44 @@
+"""Add missing profile fields to users table
+
+Revision ID: add_missing_profile_fields
+Revises: b59d0790fcac
+Create Date: 2024-12-23 20:30:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = 'add_missing_profile_fields'
+down_revision = 'b59d0790fcac'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ # Add missing profile fields to users table
+ with op.batch_alter_table('users', schema=None) as batch_op:
+ # Basic profile fields
+ batch_op.add_column(sa.Column('willing_to_relocate', sa.Boolean(), nullable=True))
+ batch_op.add_column(sa.Column('authorization_status', sa.String(100), nullable=True))
+ batch_op.add_column(sa.Column('visa_status', sa.String(100), nullable=True))
+ batch_op.add_column(sa.Column('race_ethnicity', sa.String(100), nullable=True))
+
+ # Additional profile fields that might be used
+ batch_op.add_column(sa.Column('years_of_experience', sa.Integer(), nullable=True))
+ batch_op.add_column(sa.Column('education_level', sa.String(100), nullable=True))
+ batch_op.add_column(sa.Column('industry_preference', sa.String(200), nullable=True))
+ batch_op.add_column(sa.Column('company_size_preference', sa.String(50), nullable=True))
+ batch_op.add_column(sa.Column('remote_preference', sa.String(50), nullable=True))
+
+def downgrade():
+ # Remove the added columns
+ with op.batch_alter_table('users', schema=None) as batch_op:
+ batch_op.drop_column('willing_to_relocate')
+ batch_op.drop_column('authorization_status')
+ batch_op.drop_column('visa_status')
+ batch_op.drop_column('race_ethnicity')
+ batch_op.drop_column('years_of_experience')
+ batch_op.drop_column('education_level')
+ batch_op.drop_column('industry_preference')
+ batch_op.drop_column('company_size_preference')
+ batch_op.drop_column('remote_preference')
\ No newline at end of file
diff --git a/backend/migrations/versions_backup_20250705_131016/add_missing_profile_fields_v2.py b/backend/migrations/versions_backup_20250705_131016/add_missing_profile_fields_v2.py
new file mode 100644
index 00000000..3407f447
--- /dev/null
+++ b/backend/migrations/versions_backup_20250705_131016/add_missing_profile_fields_v2.py
@@ -0,0 +1,52 @@
+"""Add missing profile fields to users table - Version 2
+
+Revision ID: add_missing_profile_fields_v2
+Revises: b59d0790fcac
+Create Date: 2024-12-23 20:45:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = 'add_missing_profile_fields_v2'
+down_revision = 'b59d0790fcac'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ # Add missing profile fields to users table
+ with op.batch_alter_table('users', schema=None) as batch_op:
+ # Salary and availability fields
+ batch_op.add_column(sa.Column('desired_salary_range', sa.String(100), nullable=True))
+ batch_op.add_column(sa.Column('available_start_date', sa.Date(), nullable=True))
+ batch_op.add_column(sa.Column('preferred_company_type', sa.String(100), nullable=True))
+
+ # Career and work preference fields
+ batch_op.add_column(sa.Column('career_goals', sa.Text(), nullable=True))
+ batch_op.add_column(sa.Column('biggest_achievement', sa.Text(), nullable=True))
+ batch_op.add_column(sa.Column('work_style', sa.String(200), nullable=True))
+ batch_op.add_column(sa.Column('industry_attraction', sa.String(200), nullable=True))
+
+ # Education and sponsorship fields
+ batch_op.add_column(sa.Column('graduation_date', sa.Date(), nullable=True))
+ batch_op.add_column(sa.Column('needs_sponsorship', sa.Boolean(), nullable=True))
+
+ # Additional fields that might be used
+ batch_op.add_column(sa.Column('company_size_preference', sa.String(50), nullable=True))
+ batch_op.add_column(sa.Column('remote_preference', sa.String(50), nullable=True))
+
+def downgrade():
+ # Remove the added columns
+ with op.batch_alter_table('users', schema=None) as batch_op:
+ batch_op.drop_column('desired_salary_range')
+ batch_op.drop_column('available_start_date')
+ batch_op.drop_column('preferred_company_type')
+ batch_op.drop_column('career_goals')
+ batch_op.drop_column('biggest_achievement')
+ batch_op.drop_column('work_style')
+ batch_op.drop_column('industry_attraction')
+ batch_op.drop_column('graduation_date')
+ batch_op.drop_column('needs_sponsorship')
+ batch_op.drop_column('company_size_preference')
+ batch_op.drop_column('remote_preference')
\ No newline at end of file
diff --git a/backend/migrations/versions_backup_20250705_131016/add_subscription_and_enhance_recommendations.py b/backend/migrations/versions_backup_20250705_131016/add_subscription_and_enhance_recommendations.py
new file mode 100644
index 00000000..8e25279d
--- /dev/null
+++ b/backend/migrations/versions_backup_20250705_131016/add_subscription_and_enhance_recommendations.py
@@ -0,0 +1,121 @@
+"""Add subscription tracking and enhance job recommendations
+
+Revision ID: add_subscription_and_recommendations
+Revises: add_job_keywords
+Create Date: 2024-03-21 11:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from datetime import datetime
+
+# revision identifiers, used by Alembic.
+revision = 'add_subscription_and_recommendations'
+down_revision = 'add_job_keywords'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ # Add subscription tracking fields to users table
+ op.add_column('users', sa.Column('subscription_status', sa.String(50), nullable=True))
+ op.add_column('users', sa.Column('subscription_plan', sa.String(50), nullable=True))
+ op.add_column('users', sa.Column('applications_paid_for', sa.Integer, default=0))
+ op.add_column('users', sa.Column('applications_remaining', sa.Integer, default=0))
+ op.add_column('users', sa.Column('total_paid', sa.Float, default=0.0))
+ op.add_column('users', sa.Column('subscription_start_date', sa.DateTime))
+ op.add_column('users', sa.Column('subscription_end_date', sa.DateTime))
+ op.add_column('users', sa.Column('last_payment_date', sa.DateTime))
+
+ # Create subscription_plans table
+ op.create_table(
+ 'subscription_plans',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('name', sa.String(50), nullable=False),
+ sa.Column('description', sa.Text),
+ sa.Column('price', sa.Float, nullable=False),
+ sa.Column('applications_included', sa.Integer, nullable=False),
+ sa.Column('duration_days', sa.Integer, nullable=False),
+ sa.Column('is_active', sa.Boolean, default=True),
+ sa.Column('created_at', sa.DateTime, default=datetime.utcnow),
+ sa.Column('updated_at', sa.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('name')
+ )
+
+ # Create subscription_history table
+ op.create_table(
+ 'subscription_history',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE')),
+ sa.Column('plan_id', sa.Integer(), sa.ForeignKey('subscription_plans.id')),
+ sa.Column('order_id', sa.String(16), sa.ForeignKey('orders.order_id')),
+ sa.Column('start_date', sa.DateTime, nullable=False),
+ sa.Column('end_date', sa.DateTime, nullable=False),
+ sa.Column('status', sa.String(50), nullable=False), # active, expired, cancelled
+ sa.Column('applications_used', sa.Integer, default=0),
+ sa.Column('created_at', sa.DateTime, default=datetime.utcnow),
+ sa.Column('updated_at', sa.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow),
+ sa.PrimaryKeyConstraint('id')
+ )
+
+ # Enhance job recommendations table
+ op.add_column('user_job_recommendations', sa.Column('is_viewed', sa.Boolean, default=False))
+ op.add_column('user_job_recommendations', sa.Column('viewed_at', sa.DateTime))
+ op.add_column('user_job_recommendations', sa.Column('is_saved', sa.Boolean, default=False))
+ op.add_column('user_job_recommendations', sa.Column('saved_at', sa.DateTime))
+ op.add_column('user_job_recommendations', sa.Column('is_applied', sa.Boolean, default=False))
+ op.add_column('user_job_recommendations', sa.Column('applied_at', sa.DateTime))
+ op.add_column('user_job_recommendations', sa.Column('match_details', sa.JSON)) # Store detailed match information
+ op.add_column('user_job_recommendations', sa.Column('last_updated', sa.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow))
+
+ # Create indexes for better performance
+ op.create_index('ix_user_job_recommendations_user_id', 'user_job_recommendations', ['user_id'])
+ op.create_index('ix_user_job_recommendations_job_id', 'user_job_recommendations', ['job_id'])
+ op.create_index('ix_user_job_recommendations_match_score', 'user_job_recommendations', ['match_score'])
+ op.create_index('ix_user_job_recommendations_recommended_at', 'user_job_recommendations', ['recommended_at'])
+ op.create_index('ix_subscription_history_user_id', 'subscription_history', ['user_id'])
+ op.create_index('ix_subscription_history_status', 'subscription_history', ['status'])
+
+ # Insert default subscription plans
+ op.execute("""
+ INSERT INTO subscription_plans (name, description, price, applications_included, duration_days, is_active)
+ VALUES
+ ('Basic', '5 applications per month', 9.99, 5, 30, true),
+ ('Standard', '15 applications per month', 24.99, 15, 30, true),
+ ('Premium', 'Unlimited applications', 49.99, 999999, 30, true)
+ """)
+
+def downgrade():
+ # Drop subscription history table
+ op.drop_table('subscription_history')
+
+ # Drop subscription plans table
+ op.drop_table('subscription_plans')
+
+ # Remove subscription tracking fields from users table
+ op.drop_column('users', 'subscription_status')
+ op.drop_column('users', 'subscription_plan')
+ op.drop_column('users', 'applications_paid_for')
+ op.drop_column('users', 'applications_remaining')
+ op.drop_column('users', 'total_paid')
+ op.drop_column('users', 'subscription_start_date')
+ op.drop_column('users', 'subscription_end_date')
+ op.drop_column('users', 'last_payment_date')
+
+ # Remove enhanced fields from job recommendations
+ op.drop_column('user_job_recommendations', 'is_viewed')
+ op.drop_column('user_job_recommendations', 'viewed_at')
+ op.drop_column('user_job_recommendations', 'is_saved')
+ op.drop_column('user_job_recommendations', 'saved_at')
+ op.drop_column('user_job_recommendations', 'is_applied')
+ op.drop_column('user_job_recommendations', 'applied_at')
+ op.drop_column('user_job_recommendations', 'match_details')
+ op.drop_column('user_job_recommendations', 'last_updated')
+
+ # Drop indexes
+ op.drop_index('ix_user_job_recommendations_user_id')
+ op.drop_index('ix_user_job_recommendations_job_id')
+ op.drop_index('ix_user_job_recommendations_match_score')
+ op.drop_index('ix_user_job_recommendations_recommended_at')
+ op.drop_index('ix_subscription_history_user_id')
+ op.drop_index('ix_subscription_history_status')
\ No newline at end of file
diff --git a/backend/migrations/versions_backup_20250705_131016/b59d0790fcac_add_moderator_action_constants_and_.py b/backend/migrations/versions_backup_20250705_131016/b59d0790fcac_add_moderator_action_constants_and_.py
new file mode 100644
index 00000000..cf0cc005
--- /dev/null
+++ b/backend/migrations/versions_backup_20250705_131016/b59d0790fcac_add_moderator_action_constants_and_.py
@@ -0,0 +1,301 @@
+"""add moderator action constants and action categories
+
+Revision ID: b59d0790fcac
+Revises: c02b37a1ccb6
+Create Date: 2025-06-09 14:52:12.950163
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = 'b59d0790fcac'
+down_revision = 'c02b37a1ccb6'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('job_keywords_association', schema=None) as batch_op:
+ batch_op.drop_index('ix_job_keywords_association_job_id')
+ batch_op.drop_index('ix_job_keywords_association_keyword_id')
+
+ op.drop_table('job_keywords_association')
+ with op.batch_alter_table('keywords', schema=None) as batch_op:
+ batch_op.drop_index('ix_keywords_category')
+ batch_op.drop_index('ix_keywords_keyword')
+ batch_op.drop_index('ix_keywords_type')
+
+ op.drop_table('keywords')
+ with op.batch_alter_table('resume_keywords_association', schema=None) as batch_op:
+ batch_op.drop_index('ix_resume_keywords_association_keyword_id')
+ batch_op.drop_index('ix_resume_keywords_association_proficiency')
+ batch_op.drop_index('ix_resume_keywords_association_user_id')
+
+ op.drop_table('resume_keywords_association')
+ with op.batch_alter_table('applicant_values', schema=None) as batch_op:
+ batch_op.create_unique_constraint('uq_applicant_value_user_category_value', ['user_id', 'category', 'value'])
+
+ with op.batch_alter_table('application_statuses', schema=None) as batch_op:
+ batch_op.drop_constraint('uq_application_statuses_name', type_='unique')
+ batch_op.create_unique_constraint('uq_application_status_name', ['name'])
+
+ with op.batch_alter_table('applications', schema=None) as batch_op:
+ batch_op.create_unique_constraint('uq_user_job_application', ['user_id', 'job_id'])
+
+ with op.batch_alter_table('audit_log_entries', schema=None) as batch_op:
+ batch_op.alter_column('updated_at',
+ existing_type=sa.DATETIME(),
+ nullable=False)
+
+ with op.batch_alter_table('certifications', schema=None) as batch_op:
+ batch_op.drop_constraint('uq_certifications_name', type_='unique')
+ batch_op.create_unique_constraint('uq_certification_name', ['name'])
+
+ with op.batch_alter_table('companies', schema=None) as batch_op:
+ batch_op.drop_constraint('uq_companies_name', type_='unique')
+ batch_op.create_unique_constraint('uq_company_name', ['name'])
+
+ with op.batch_alter_table('demographics', schema=None) as batch_op:
+ batch_op.create_unique_constraint('uq_demographic_user', ['user_id'])
+
+ with op.batch_alter_table('desired_job_titles', schema=None) as batch_op:
+ batch_op.create_unique_constraint('uq_desired_job_title_user_title', ['user_id', 'title'])
+
+ with op.batch_alter_table('job_postings', schema=None) as batch_op:
+ batch_op.drop_constraint('uq_job_postings_external_id', type_='unique')
+ batch_op.create_unique_constraint('uq_job_posting_external_id', ['external_id'])
+
+ with op.batch_alter_table('job_recommendation_skills', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('match_score', sa.Integer(), nullable=True))
+ batch_op.alter_column('recommendation_id',
+ existing_type=sa.INTEGER(),
+ nullable=True)
+ batch_op.alter_column('skill_id',
+ existing_type=sa.INTEGER(),
+ nullable=True)
+ batch_op.alter_column('created_at',
+ existing_type=sa.DATETIME(),
+ nullable=True)
+ batch_op.alter_column('updated_at',
+ existing_type=sa.DATETIME(),
+ nullable=True)
+ batch_op.drop_constraint('fk_job_recommendation_skills_recommendation_id_user_job_recommendations', type_='foreignkey')
+ batch_op.create_foreign_key(batch_op.f('fk_job_recommendation_skills_recommendation_id_user_job_recommendations'), 'user_job_recommendations', ['recommendation_id'], ['id'], ondelete='CASCADE')
+ batch_op.drop_column('matched')
+ batch_op.drop_column('importance')
+
+ with op.batch_alter_table('languages', schema=None) as batch_op:
+ batch_op.drop_constraint('uq_languages_code', type_='unique')
+ batch_op.drop_constraint('uq_languages_name', type_='unique')
+ batch_op.create_unique_constraint('uq_language_code', ['code'])
+ batch_op.create_unique_constraint('uq_language_name', ['name'])
+
+ with op.batch_alter_table('orders', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('created_at', sa.DateTime(), nullable=True))
+ batch_op.add_column(sa.Column('updated_at', sa.DateTime(), nullable=True))
+ batch_op.drop_constraint('uq_orders_order_id', type_='unique')
+ batch_op.create_unique_constraint('uq_order_id', ['order_id'])
+ batch_op.drop_constraint('fk_orders_user_id_users', type_='foreignkey')
+ batch_op.create_foreign_key(batch_op.f('fk_orders_user_id_users'), 'users', ['user_id'], ['id'], ondelete='CASCADE')
+
+ with op.batch_alter_table('portfolio_links', schema=None) as batch_op:
+ batch_op.create_unique_constraint('uq_portfolio_link_user_platform', ['user_id', 'platform'])
+
+ with op.batch_alter_table('selected_jobs', schema=None) as batch_op:
+ batch_op.alter_column('recommendation_id',
+ existing_type=sa.INTEGER(),
+ nullable=True)
+ batch_op.alter_column('created_at',
+ existing_type=sa.DATETIME(),
+ nullable=True)
+ batch_op.alter_column('updated_at',
+ existing_type=sa.DATETIME(),
+ nullable=True)
+ batch_op.drop_constraint('fk_selected_jobs_user_id_users', type_='foreignkey')
+ batch_op.drop_constraint('fk_selected_jobs_recommendation_id_user_job_recommendations', type_='foreignkey')
+ batch_op.create_foreign_key(batch_op.f('fk_selected_jobs_recommendation_id_user_job_recommendations'), 'user_job_recommendations', ['recommendation_id'], ['id'], ondelete='CASCADE')
+ batch_op.drop_column('user_id')
+ batch_op.drop_column('notes')
+
+ with op.batch_alter_table('subscription_history', schema=None) as batch_op:
+ batch_op.drop_constraint('fk_subscription_history_order_id_orders', type_='foreignkey')
+ batch_op.create_foreign_key(batch_op.f('fk_subscription_history_order_id_orders'), 'orders', ['order_id'], ['order_id'], ondelete='SET NULL')
+
+ with op.batch_alter_table('subscription_plans', schema=None) as batch_op:
+ batch_op.drop_constraint('uq_subscription_plans_name', type_='unique')
+ batch_op.create_unique_constraint('uq_subscription_plan_name', ['name'])
+
+ with op.batch_alter_table('users', schema=None) as batch_op:
+ batch_op.create_unique_constraint('uq_user_email', ['email'])
+ batch_op.drop_column('subscription_end_date')
+ batch_op.drop_column('subscription_status')
+ batch_op.drop_column('subscription_plan')
+ batch_op.drop_column('applications_paid_for')
+ batch_op.drop_column('subscription_start_date')
+ batch_op.drop_column('last_payment_date')
+ batch_op.drop_column('total_paid')
+ batch_op.drop_column('applications_remaining')
+
+ with op.batch_alter_table('waitlist', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('updated_at', sa.DateTime(), nullable=False))
+
+ # ### end Alembic commands ###
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('waitlist', schema=None) as batch_op:
+ batch_op.drop_column('updated_at')
+
+ with op.batch_alter_table('users', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('applications_remaining', sa.INTEGER(), nullable=True))
+ batch_op.add_column(sa.Column('total_paid', sa.FLOAT(), nullable=True))
+ batch_op.add_column(sa.Column('last_payment_date', sa.DATETIME(), nullable=True))
+ batch_op.add_column(sa.Column('subscription_start_date', sa.DATETIME(), nullable=True))
+ batch_op.add_column(sa.Column('applications_paid_for', sa.INTEGER(), nullable=True))
+ batch_op.add_column(sa.Column('subscription_plan', sa.VARCHAR(length=50), nullable=True))
+ batch_op.add_column(sa.Column('subscription_status', sa.VARCHAR(length=50), nullable=True))
+ batch_op.add_column(sa.Column('subscription_end_date', sa.DATETIME(), nullable=True))
+ batch_op.drop_constraint('uq_user_email', type_='unique')
+
+ with op.batch_alter_table('subscription_plans', schema=None) as batch_op:
+ batch_op.drop_constraint('uq_subscription_plan_name', type_='unique')
+ batch_op.create_unique_constraint('uq_subscription_plans_name', ['name'])
+
+ with op.batch_alter_table('subscription_history', schema=None) as batch_op:
+ batch_op.drop_constraint(batch_op.f('fk_subscription_history_order_id_orders'), type_='foreignkey')
+ batch_op.create_foreign_key('fk_subscription_history_order_id_orders', 'orders', ['order_id'], ['order_id'])
+
+ with op.batch_alter_table('selected_jobs', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('notes', sa.TEXT(), nullable=True))
+ batch_op.add_column(sa.Column('user_id', sa.INTEGER(), nullable=False))
+ batch_op.drop_constraint(batch_op.f('fk_selected_jobs_recommendation_id_user_job_recommendations'), type_='foreignkey')
+ batch_op.create_foreign_key('fk_selected_jobs_recommendation_id_user_job_recommendations', 'user_job_recommendations', ['recommendation_id'], ['id'])
+ batch_op.create_foreign_key('fk_selected_jobs_user_id_users', 'users', ['user_id'], ['id'])
+ batch_op.alter_column('updated_at',
+ existing_type=sa.DATETIME(),
+ nullable=False)
+ batch_op.alter_column('created_at',
+ existing_type=sa.DATETIME(),
+ nullable=False)
+ batch_op.alter_column('recommendation_id',
+ existing_type=sa.INTEGER(),
+ nullable=False)
+
+ with op.batch_alter_table('portfolio_links', schema=None) as batch_op:
+ batch_op.drop_constraint('uq_portfolio_link_user_platform', type_='unique')
+
+ with op.batch_alter_table('orders', schema=None) as batch_op:
+ batch_op.drop_constraint(batch_op.f('fk_orders_user_id_users'), type_='foreignkey')
+ batch_op.create_foreign_key('fk_orders_user_id_users', 'users', ['user_id'], ['id'])
+ batch_op.drop_constraint('uq_order_id', type_='unique')
+ batch_op.create_unique_constraint('uq_orders_order_id', ['order_id'])
+ batch_op.drop_column('updated_at')
+ batch_op.drop_column('created_at')
+
+ with op.batch_alter_table('languages', schema=None) as batch_op:
+ batch_op.drop_constraint('uq_language_name', type_='unique')
+ batch_op.drop_constraint('uq_language_code', type_='unique')
+ batch_op.create_unique_constraint('uq_languages_name', ['name'])
+ batch_op.create_unique_constraint('uq_languages_code', ['code'])
+
+ with op.batch_alter_table('job_recommendation_skills', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('importance', sa.INTEGER(), nullable=True))
+ batch_op.add_column(sa.Column('matched', sa.BOOLEAN(), nullable=True))
+ batch_op.drop_constraint(batch_op.f('fk_job_recommendation_skills_recommendation_id_user_job_recommendations'), type_='foreignkey')
+ batch_op.create_foreign_key('fk_job_recommendation_skills_recommendation_id_user_job_recommendations', 'user_job_recommendations', ['recommendation_id'], ['id'])
+ batch_op.alter_column('updated_at',
+ existing_type=sa.DATETIME(),
+ nullable=False)
+ batch_op.alter_column('created_at',
+ existing_type=sa.DATETIME(),
+ nullable=False)
+ batch_op.alter_column('skill_id',
+ existing_type=sa.INTEGER(),
+ nullable=False)
+ batch_op.alter_column('recommendation_id',
+ existing_type=sa.INTEGER(),
+ nullable=False)
+ batch_op.drop_column('match_score')
+
+ with op.batch_alter_table('job_postings', schema=None) as batch_op:
+ batch_op.drop_constraint('uq_job_posting_external_id', type_='unique')
+ batch_op.create_unique_constraint('uq_job_postings_external_id', ['external_id'])
+
+ with op.batch_alter_table('desired_job_titles', schema=None) as batch_op:
+ batch_op.drop_constraint('uq_desired_job_title_user_title', type_='unique')
+
+ with op.batch_alter_table('demographics', schema=None) as batch_op:
+ batch_op.drop_constraint('uq_demographic_user', type_='unique')
+
+ with op.batch_alter_table('companies', schema=None) as batch_op:
+ batch_op.drop_constraint('uq_company_name', type_='unique')
+ batch_op.create_unique_constraint('uq_companies_name', ['name'])
+
+ with op.batch_alter_table('certifications', schema=None) as batch_op:
+ batch_op.drop_constraint('uq_certification_name', type_='unique')
+ batch_op.create_unique_constraint('uq_certifications_name', ['name'])
+
+ with op.batch_alter_table('audit_log_entries', schema=None) as batch_op:
+ batch_op.alter_column('updated_at',
+ existing_type=sa.DATETIME(),
+ nullable=True)
+
+ with op.batch_alter_table('applications', schema=None) as batch_op:
+ batch_op.drop_constraint('uq_user_job_application', type_='unique')
+
+ with op.batch_alter_table('application_statuses', schema=None) as batch_op:
+ batch_op.drop_constraint('uq_application_status_name', type_='unique')
+ batch_op.create_unique_constraint('uq_application_statuses_name', ['name'])
+
+ with op.batch_alter_table('applicant_values', schema=None) as batch_op:
+ batch_op.drop_constraint('uq_applicant_value_user_category_value', type_='unique')
+
+ op.create_table('resume_keywords_association',
+ sa.Column('user_id', sa.INTEGER(), nullable=False),
+ sa.Column('keyword_id', sa.INTEGER(), nullable=False),
+ sa.Column('proficiency_level', sa.VARCHAR(length=20), nullable=True),
+ sa.Column('years_experience', sa.FLOAT(), nullable=True),
+ sa.Column('source', sa.VARCHAR(length=50), nullable=True),
+ sa.Column('last_used', sa.DATETIME(), nullable=True),
+ sa.Column('created_at', sa.DATETIME(), nullable=True),
+ sa.Column('updated_at', sa.DATETIME(), nullable=True),
+ sa.ForeignKeyConstraint(['keyword_id'], ['keywords.id'], name='fk_resume_keywords_association_keyword_id_keywords', ondelete='CASCADE'),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='fk_resume_keywords_association_user_id_users', ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('user_id', 'keyword_id', name='pk_resume_keywords_association')
+ )
+ with op.batch_alter_table('resume_keywords_association', schema=None) as batch_op:
+ batch_op.create_index('ix_resume_keywords_association_user_id', ['user_id'], unique=False)
+ batch_op.create_index('ix_resume_keywords_association_proficiency', ['proficiency_level'], unique=False)
+ batch_op.create_index('ix_resume_keywords_association_keyword_id', ['keyword_id'], unique=False)
+
+ op.create_table('keywords',
+ sa.Column('id', sa.INTEGER(), nullable=False),
+ sa.Column('keyword', sa.VARCHAR(length=100), nullable=False),
+ sa.Column('category', sa.VARCHAR(length=50), nullable=True),
+ sa.Column('type', sa.VARCHAR(length=20), nullable=False),
+ sa.Column('created_at', sa.DATETIME(), nullable=True),
+ sa.Column('updated_at', sa.DATETIME(), nullable=True),
+ sa.PrimaryKeyConstraint('id', name='pk_keywords'),
+ sa.UniqueConstraint('keyword', 'type', name='uq_keyword_type')
+ )
+ with op.batch_alter_table('keywords', schema=None) as batch_op:
+ batch_op.create_index('ix_keywords_type', ['type'], unique=False)
+ batch_op.create_index('ix_keywords_keyword', ['keyword'], unique=False)
+ batch_op.create_index('ix_keywords_category', ['category'], unique=False)
+
+ op.create_table('job_keywords_association',
+ sa.Column('job_id', sa.INTEGER(), nullable=False),
+ sa.Column('keyword_id', sa.INTEGER(), nullable=False),
+ sa.Column('relevance_score', sa.FLOAT(), nullable=True),
+ sa.Column('source', sa.VARCHAR(length=50), nullable=True),
+ sa.Column('created_at', sa.DATETIME(), nullable=True),
+ sa.ForeignKeyConstraint(['job_id'], ['job_postings.id'], name='fk_job_keywords_association_job_id_job_postings', ondelete='CASCADE'),
+ sa.ForeignKeyConstraint(['keyword_id'], ['keywords.id'], name='fk_job_keywords_association_keyword_id_keywords', ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('job_id', 'keyword_id', name='pk_job_keywords_association')
+ )
+ with op.batch_alter_table('job_keywords_association', schema=None) as batch_op:
+ batch_op.create_index('ix_job_keywords_association_keyword_id', ['keyword_id'], unique=False)
+ batch_op.create_index('ix_job_keywords_association_job_id', ['job_id'], unique=False)
+
+ # ### end Alembic commands ###
diff --git a/backend/migrations/versions_backup_20250705_131016/c02b37a1ccb6_merge_heads.py b/backend/migrations/versions_backup_20250705_131016/c02b37a1ccb6_merge_heads.py
new file mode 100644
index 00000000..c6c6adac
--- /dev/null
+++ b/backend/migrations/versions_backup_20250705_131016/c02b37a1ccb6_merge_heads.py
@@ -0,0 +1,21 @@
+"""merge heads
+
+Revision ID: c02b37a1ccb6
+Revises: add_jobs_fts, add_subscription_and_recommendations
+Create Date: 2025-06-07 21:46:17.840149
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = 'c02b37a1ccb6'
+down_revision = ('add_jobs_fts', 'add_subscription_and_recommendations')
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ pass
+
+def downgrade():
+ pass
diff --git a/backend/migrations/versions_backup_20250705_131016/create_keyword_tables.py b/backend/migrations/versions_backup_20250705_131016/create_keyword_tables.py
new file mode 100644
index 00000000..c33e48b6
--- /dev/null
+++ b/backend/migrations/versions_backup_20250705_131016/create_keyword_tables.py
@@ -0,0 +1,71 @@
+"""Create keyword tables for job and resume keyword extraction
+
+Revision ID: create_keyword_tables
+Revises: b59d0790fcac
+Create Date: 2025-01-01 10:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from datetime import datetime
+
+# revision identifiers, used by Alembic.
+revision = 'create_keyword_tables'
+down_revision = 'b59d0790fcac'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ # Create the keywords table
+ op.create_table(
+ 'keywords',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('keyword', sa.String(100), nullable=False),
+ sa.Column('category', sa.String(50)), # e.g., 'skill', 'technology', 'domain', 'soft_skill'
+ sa.Column('type', sa.String(20), nullable=False), # 'job' or 'resume'
+ sa.Column('created_at', sa.DateTime(), default=datetime.utcnow),
+ sa.Column('updated_at', sa.DateTime(), default=datetime.utcnow, onupdate=datetime.utcnow),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('keyword', 'type', name='uq_keyword_type')
+ )
+
+ # Create the job_keywords association table
+ op.create_table(
+ 'job_keywords_association',
+ sa.Column('job_id', sa.Integer(), sa.ForeignKey('job_postings.id', ondelete='CASCADE')),
+ sa.Column('keyword_id', sa.Integer(), sa.ForeignKey('keywords.id', ondelete='CASCADE')),
+ sa.Column('relevance_score', sa.Float()), # Score indicating how relevant the keyword is to the job
+ sa.Column('source', sa.String(50)), # How the keyword was derived (e.g., 'title', 'description', 'requirements')
+ sa.Column('created_at', sa.DateTime(), default=datetime.utcnow),
+ sa.PrimaryKeyConstraint('job_id', 'keyword_id')
+ )
+
+ # Create the resume_keywords association table
+ op.create_table(
+ 'resume_keywords_association',
+ sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE')),
+ sa.Column('keyword_id', sa.Integer(), sa.ForeignKey('keywords.id', ondelete='CASCADE')),
+ sa.Column('proficiency_level', sa.String(20)), # e.g., 'beginner', 'intermediate', 'expert'
+ sa.Column('years_experience', sa.Float()), # Years of experience with this skill
+ sa.Column('source', sa.String(50)), # How the keyword was derived (e.g., 'resume', 'profile', 'assessment')
+ sa.Column('last_used', sa.DateTime()), # When this skill was last used
+ sa.Column('created_at', sa.DateTime(), default=datetime.utcnow),
+ sa.Column('updated_at', sa.DateTime(), default=datetime.utcnow, onupdate=datetime.utcnow),
+ sa.PrimaryKeyConstraint('user_id', 'keyword_id')
+ )
+
+ # Create indexes for better performance
+ op.create_index('ix_keywords_keyword', 'keywords', ['keyword'])
+ op.create_index('ix_keywords_category', 'keywords', ['category'])
+ op.create_index('ix_keywords_type', 'keywords', ['type'])
+ op.create_index('ix_job_keywords_association_job_id', 'job_keywords_association', ['job_id'])
+ op.create_index('ix_job_keywords_association_keyword_id', 'job_keywords_association', ['keyword_id'])
+ op.create_index('ix_resume_keywords_association_user_id', 'resume_keywords_association', ['user_id'])
+ op.create_index('ix_resume_keywords_association_keyword_id', 'resume_keywords_association', ['keyword_id'])
+ op.create_index('ix_resume_keywords_association_proficiency', 'resume_keywords_association', ['proficiency_level'])
+
+def downgrade():
+ # Drop the keyword tables
+ op.drop_table('resume_keywords_association')
+ op.drop_table('job_keywords_association')
+ op.drop_table('keywords')
\ No newline at end of file
diff --git a/backend/migrations/versions_backup_20250705_131016/ec7abfcbf940_merge_multiple_migration_heads.py b/backend/migrations/versions_backup_20250705_131016/ec7abfcbf940_merge_multiple_migration_heads.py
new file mode 100644
index 00000000..dfe6c16c
--- /dev/null
+++ b/backend/migrations/versions_backup_20250705_131016/ec7abfcbf940_merge_multiple_migration_heads.py
@@ -0,0 +1,21 @@
+"""Merge multiple migration heads
+
+Revision ID: ec7abfcbf940
+Revises: add_email_verification_fields, add_missing_profile_fields, add_missing_profile_fields_v2, create_keyword_tables, update_desired_salary_range_to_json
+Create Date: 2025-06-24 19:55:16.051203
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = 'ec7abfcbf940'
+down_revision = ('add_email_verification_fields', 'add_missing_profile_fields', 'add_missing_profile_fields_v2', 'create_keyword_tables', 'update_desired_salary_range_to_json')
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ pass
+
+def downgrade():
+ pass
diff --git a/backend/migrations/versions_backup_20250705_131016/remove_work_style_field.py b/backend/migrations/versions_backup_20250705_131016/remove_work_style_field.py
new file mode 100644
index 00000000..e7b54907
--- /dev/null
+++ b/backend/migrations/versions_backup_20250705_131016/remove_work_style_field.py
@@ -0,0 +1,25 @@
+"""Remove work_style field from users table
+
+Revision ID: remove_work_style_field
+Revises: add_comprehensive_profile_fields
+Create Date: 2024-01-01 00:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = 'remove_work_style_field'
+down_revision = 'add_comprehensive_profile_fields'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ """Remove work_style column from users table"""
+ # Remove the work_style column
+ op.drop_column('users', 'work_style')
+
+def downgrade():
+ """Add back work_style column to users table"""
+ # Add the work_style column back
+ op.add_column('users', sa.Column('work_style', sa.String(200), nullable=True))
\ No newline at end of file
diff --git a/backend/migrations/versions/update_desired_salary_range_to_json.py b/backend/migrations/versions_backup_20250705_131016/update_desired_salary_range_to_json.py
similarity index 99%
rename from backend/migrations/versions/update_desired_salary_range_to_json.py
rename to backend/migrations/versions_backup_20250705_131016/update_desired_salary_range_to_json.py
index 66927ce1..14ff5def 100644
--- a/backend/migrations/versions/update_desired_salary_range_to_json.py
+++ b/backend/migrations/versions_backup_20250705_131016/update_desired_salary_range_to_json.py
@@ -9,14 +9,12 @@
import sqlalchemy as sa
from sqlalchemy.dialects import sqlite
-
# revision identifiers, used by Alembic.
revision = 'update_desired_salary_range_to_json'
down_revision = 'remove_work_style_field'
branch_labels = None
depends_on = None
-
def upgrade():
"""Change desired_salary_range from String to JSON"""
# For SQLite, we need to recreate the table since it doesn't support ALTER COLUMN
@@ -27,7 +25,6 @@ def upgrade():
type_=sa.JSON,
existing_nullable=True)
-
def downgrade():
"""Change desired_salary_range back to String"""
with op.batch_alter_table('users') as batch_op:
diff --git a/backend/models/all_models.py b/backend/models/all_models.py
index 3fc65904..b2f6f2e1 100644
--- a/backend/models/all_models.py
+++ b/backend/models/all_models.py
@@ -511,10 +511,14 @@ def safe_parse_json(value):
'last_login': self.last_login.isoformat() if self.last_login else None,
'profile_picture_url': self.profile_picture_url,
'bio': self.bio,
+ # Extract experience from bio field for frontend compatibility
+ 'experience_data': safe_parse_json(self.bio) if self.bio and self.bio.startswith('[') else None,
'phone_number': self.phone_number,
'location': self.location,
'timezone': self.timezone,
'preferences': safe_parse_json(self.preferences),
+ # Extract skills from preferences for frontend compatibility
+ 'skills': (lambda: (lambda prefs: prefs.get('skills', []) if prefs else [])(safe_parse_json(self.preferences)) if self.preferences else [])(),
'resume': self.resume,
'resume_url': self.resume_url,
'resume_file_path': self.resume_file_path,
@@ -555,6 +559,11 @@ def safe_parse_json(value):
'portfolio_links': self._safe_get_relationship_data('portfolio_links'),
'demographic': self.demographic.to_dict() if self._safe_get_single_relationship('demographic') else None,
'military_info': self.military_info.to_dict() if self._safe_get_single_relationship('military_info') else None,
+ # Add individual demographic fields for frontend compatibility
+ 'gender': self.demographic.gender if self._safe_get_single_relationship('demographic') else None,
+ 'disability_status': self.demographic.disability_status if self._safe_get_single_relationship('demographic') else None,
+ # Add military status for frontend compatibility
+ 'military_status': self.military_info.branch if self._safe_get_single_relationship('military_info') else None,
'applicant_value_entries': self._safe_get_relationship_data('applicant_value_entries'),
'job_title_entries': self._safe_get_relationship_data('job_title_entries'),
'assigned_users': self._safe_get_assigned_users(),
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 1c87a82f..c6035470 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -6,14 +6,15 @@ Flask-Migrate==4.0.5
Flask-Mail==0.9.1
Flask-WTF==1.1.1
WTForms==3.0.1
+email_validator==2.0.0
Werkzeug==2.3.7
-# Database dependencies
+# Database dependencies - PostgreSQL only
SQLAlchemy==2.0.21
psycopg2-binary==2.9.7
alembic==1.12.0
-# Data science and ML dependencies - Updated for Python 3.13 compatibility
+# Data science and ML dependencies
pandas>=2.2.0
numpy>=1.26.0
scikit-learn>=1.4.0
@@ -63,7 +64,6 @@ pytest==7.4.2
pytest-flask==1.2.0
pytest-asyncio==0.21.1
pytest-cov==4.1.0
-sqlalchemy[asyncio]==2.0.21
# Development and debugging
python-decouple==3.8
@@ -74,16 +74,14 @@ uuid==1.30
# Production server
gunicorn==21.2.0
-# Additional utilities that might be needed
+# Additional utilities
markdown==3.5.1
bleach==6.0.0
click==8.1.7
itsdangerous==2.1.2
Jinja2==3.1.2
MarkupSafe==2.1.3
-# Async SQLite support
-aiosqlite>=0.19.0
-# Optional dependencies # Optional: For Apify job scraping
+
# Web scraping and data extraction
apify-client==1.7.1
diff --git a/backend/routes/admin_job_search.py b/backend/routes/admin_job_search.py
index 8cf7fa85..9b921163 100644
--- a/backend/routes/admin_job_search.py
+++ b/backend/routes/admin_job_search.py
@@ -4,7 +4,7 @@
from flask import Blueprint, jsonify, request, current_app, make_response
from utils.auth import admin_required
from services.admin_job_search_service import AdminJobSearchService
-from utils.job_recommenders.pipeline import JobPipelineManager, save_jobs_to_db
+from utils.job_recommenders.pipeline import save_jobs_to_db
from models.db import db
from utils.audit_logger import log_moderator_action
from models.audit import ModeratorAction, ActionCategory
@@ -89,19 +89,16 @@ def search_jobs():
f"target_jobs={target_jobs}, num_pages={num_pages}, country={country}")
# Use the new multi-API search service
- result = AdminJobSearchService.search_jobs_multi_api(
+ service = AdminJobSearchService()
+ result = service.search_jobs_multi_api(
query=job_title,
location=location,
- target_jobs=target_jobs,
- max_pages_per_api=num_pages,
- country=country,
- date_posted=date_posted,
- employment_types=employment_types
+ limit=target_jobs
)
# Log the search results
if result['success']:
- jobs = result['data']['jobs']
- current_app.logger.info(f"Multi-API search found {len(jobs)} jobs using APIs: {result['data'].get('apis_used', [])}")
+ jobs = result['jobs']
+ current_app.logger.info(f"Multi-API search found {len(jobs)} jobs using APIs: {result.get('apis_used', [])}")
# Save jobs to database if we have any
new_jobs_count = 0
@@ -114,19 +111,19 @@ def search_jobs():
current_app.logger.error(f"Error saving jobs to database: {str(e)}")
# Add save count to response
- result['data']['new_jobs_saved'] = new_jobs_count
+ result['new_jobs_saved'] = new_jobs_count
response = jsonify({
'success': True,
'message': 'Jobs retrieved successfully using multi-API search',
- 'data': result['data']
+ 'data': result
})
else:
current_app.logger.error(f"Multi-API search failed: {result.get('error', 'Unknown error')}")
response = jsonify({
'success': False,
'message': f"Multi-API search failed: {result.get('error', 'Unknown error')}",
- 'data': result.get('data', {})
+ 'data': result
})
return add_cors_headers(response)
@@ -145,7 +142,8 @@ def search_jobs():
def get_sources():
"""Get available job sources from multi-API manager"""
try:
- sources = AdminJobSearchService.get_sources()
+ service = AdminJobSearchService()
+ sources = service.get_sources()
return jsonify({
'success': True,
@@ -178,7 +176,8 @@ def fetch_from_source():
current_app.logger.info(f"Admin fetch from multi-API: source={source}, position={position}, location={location}")
# Use the new multi-API service
- result = AdminJobSearchService.fetch_from_source(
+ service = AdminJobSearchService()
+ result = service.fetch_from_source(
source=source,
position=position,
location=location,
@@ -280,7 +279,8 @@ def delete_jobs():
# Handle bulk deletion by source
else:
# Use the multi-API service for bulk deletion
- result = AdminJobSearchService.delete_jobs(source=source)
+ service = AdminJobSearchService()
+ result = service.delete_jobs(source=source)
return jsonify(result)
except Exception as e:
diff --git a/backend/routes/api.py b/backend/routes/api.py
index 6c8586bc..81155e26 100644
--- a/backend/routes/api.py
+++ b/backend/routes/api.py
@@ -142,7 +142,6 @@ def run_command_for_show():
else:
return jsonify({'error': 'There was an error, check the terminal.'}), 500
-
@api_bp.route('/recommendations', methods=['POST'])
@login_required
def generate_recommendations():
@@ -156,7 +155,6 @@ def generate_recommendations():
current_app.logger.error(f"Error generating recommendations: {str(e)}")
return jsonify({'error': 'Failed to generate recommendations'}), 500
-
@api_bp.route('/recommendations', methods=['GET'])
@login_required
def get_user_recommendations():
@@ -457,7 +455,6 @@ def auto_apply_pending():
if not pending_jobs:
return jsonify({'message': 'No pending jobs found'}), 200
-
# Create a wrapper function that calls integration_test with a specific URL
async def run_integration_test_for_job(job_url):
# Override the TEST_URL global variable by patching it
@@ -822,7 +819,6 @@ def get_content_shortcut(shortcut):
'deprecated': True
}), 404
-
@api_bp.route('/extract_keywords', methods=['POST'])
@login_required
def extract_keywords():
diff --git a/backend/routes/auth.py b/backend/routes/auth.py
index 3a1494b4..7baeb7aa 100644
--- a/backend/routes/auth.py
+++ b/backend/routes/auth.py
@@ -262,7 +262,6 @@ def check_auth():
})
return jsonify({'authenticated': False}), 401
-
@auth_bp.route('verify-email', methods=['GET', 'POST', 'OPTIONS'])
@with_db_retry()
def verify_email():
@@ -357,7 +356,6 @@ def verify_email():
response = set_cors_headers(response)
return response, 500
-
@auth_bp.route('resend-verification', methods=['POST', 'OPTIONS'])
@with_db_retry()
def resend_verification():
@@ -419,7 +417,6 @@ def resend_verification():
response = set_cors_headers(response)
return response, 500
-
@auth_bp.route('forgot-password', methods=['POST', 'OPTIONS'])
@with_db_retry()
def forgot_password():
@@ -476,7 +473,6 @@ def forgot_password():
response = set_cors_headers(response)
return response, 500
-
@auth_bp.route('reset-password', methods=['POST', 'OPTIONS'])
@with_db_retry()
def reset_password():
diff --git a/backend/routes/content_preview.py b/backend/routes/content_preview.py
index 2ee24e07..930cdf05 100644
--- a/backend/routes/content_preview.py
+++ b/backend/routes/content_preview.py
@@ -101,7 +101,6 @@ def get_previews():
'error': 'Failed to load preview content'
}), 500
-
def get_fallback_content(content_type):
"""Provide fallback content when files are not found"""
fallback_content = {
diff --git a/backend/routes/import unittest.py b/backend/routes/import unittest.py
index 23a7c40f..d6d57ce2 100644
--- a/backend/routes/import unittest.py
+++ b/backend/routes/import unittest.py
@@ -5,9 +5,27 @@
import datetime
from flask import url_for
import os
-from backend.models import User
-from backend.models.profile import DesiredJobTitle
-from backend.models.moderator import Skill, Language, Certification
+try:
+ from models import User
+except ImportError:
+ try:
+ from models import User
+ except ImportError:
+ from backend.models import User
+try:
+ from models.profile import DesiredJobTitle
+except ImportError:
+ try:
+ from models.profile import DesiredJobTitle
+ except ImportError:
+ from backend.models.profile import DesiredJobTitle
+try:
+ from models.moderator import Skill, Language, Certification
+except ImportError:
+ try:
+ from models.moderator import Skill, Language, Certification
+ except ImportError:
+ from backend.models.moderator import Skill, Language, Certification
from flask import Flask
import shutil
@@ -18,7 +36,6 @@
process_resume_file, extract_text_from_resume
)
-
class TestProfileRoutes(unittest.TestCase):
def setUp(self):
# Set up Flask app for testing
@@ -305,7 +322,6 @@ def test_process_resume_file(self, mock_datetime, mock_secure_filename,
mock_file.save.assert_called_once()
mock_db.session.commit.assert_called_once()
-
@patch('PyPDF2.PdfReader')
def test_extract_text_from_pdf(self, mock_pdf_reader):
# Setup mock PDF reader
@@ -332,6 +348,5 @@ def test_extract_text_from_docx(self, mock_docx):
self.assertEqual(result, "Test DOCX content")
mock_docx.assert_called_once_with("test.docx")
-
if __name__ == '__main__':
unittest.main()
\ No newline at end of file
diff --git a/backend/routes/jobs.py b/backend/routes/jobs.py
index eba6696c..e8f42728 100644
--- a/backend/routes/jobs.py
+++ b/backend/routes/jobs.py
@@ -1,928 +1,273 @@
-from flask import Blueprint, jsonify, request, current_app, render_template, abort, send_file, redirect, url_for, flash
-from flask_login import login_required, current_user
-from models.db import db
-from models.job_recommendation import JobRecommendation, SelectedJob
+import json
import logging
-import traceback
-from flask_cors import cross_origin
-
-# Import from the new job_recommenders package structure
+from datetime import datetime
+from flask import Blueprint, request, jsonify, current_app
+from flask_login import login_required, current_user
+from models.all_models import JobPosting, Application, db
from utils.job_recommenders.pipeline import (
- init_db,
- get_latest_jobs,
- search_jobs,
+ search_jobs,
+ get_latest_jobs,
+ save_jobs_to_db,
+ get_job_by_id,
refresh_jobs,
- fetch_jobs_from_adzuna,
- fetch_jobs_from_arbeitnow,
- fetch_jobs_from_greenhouse,
- fetch_jobs_from_remoteok,
- fetch_all_jobs,
- JobPipelineManager,
- cleanup_expired,
- delete_jobs,
- ApifyJobSource
-)
-from utils.job_recommenders.user_recommender import (
- get_recommendations_for_user,
- refresh_recommendations_for_user,
- mark_job_selected,
- get_selected_jobs,
- mark_job_applied,
- get_applied_jobs
+ get_jobs_stats
)
-import os
-import sqlite3
-import json
-from datetime import datetime, timedelta
-from pathlib import Path
-from models.application import Application
-# Initialize logger
-logger = logging.getLogger(__name__)
-
-jobs_bp = Blueprint('jobs', __name__, url_prefix='/jobs')
-
-@jobs_bp.route('/', methods=['GET'])
-def get_jobs():
- """Get the latest jobs from the database"""
- # Make sure we always serve jobs as JSON when requested by APIs
- # Force JSON response for API clients
- is_api_request = request.headers.get('Content-Type') == 'application/json' or \
- 'application/json' in request.headers.get('Accept', '') or \
- request.args.get('format') == 'json' or \
- request.headers.get('X-Requested-With') == 'XMLHttpRequest'
-
- # Debugging - log headers to understand how the request is coming in
- logger.info(f"Jobs route called with headers: {dict(request.headers)}")
- logger.info(f"Is API request? {is_api_request}")
-
- limit = request.args.get('limit', 50, type=int)
-
- if is_api_request:
- # Handle as API request - return JSON
- jobs = get_latest_jobs(limit, app_context=current_app.app_context)
- response = jsonify(jobs)
- response.headers['Content-Type'] = 'application/json'
- return response
- else:
- # This is a direct browser request, redirect to the React frontend route
- logger.info("Redirecting browser request to job-pipeline-test frontend route")
- return redirect('/job-pipeline-test')
-
-@jobs_bp.route('/api/jobs', methods=['GET'])
-def api_get_jobs():
- """API endpoint for fetching jobs - explicitly JSON route"""
- limit = request.args.get('limit', 50, type=int)
- page = request.args.get('page', 1, type=int)
- logger.info(f"API /api/jobs called with limit: {limit}, page: {page}")
-
- try:
- # We need to modify the get_latest_jobs function to support pagination
- # For now, let's implement pagination here
- conn = init_db(app_context=current_app.app_context)
- cursor = conn.cursor()
-
- # First get the total count of jobs
- cursor.execute('SELECT COUNT(*) FROM jobs')
- total_jobs = cursor.fetchone()[0]
-
- # Calculate offset based on page and limit
- offset = (page - 1) * limit
-
- # Get jobs with pagination
- cursor.execute('''
- SELECT id, title, company, location, description, url,
- salary, posted_at, expire_at, source, raw_data
- FROM jobs
- ORDER BY posted_at DESC, id DESC
- LIMIT ? OFFSET ?
- ''', (limit, offset))
-
- columns = [column[0] for column in cursor.description]
- jobs = []
-
- for row in cursor.fetchall():
- job_dict = dict(zip(columns, row))
- jobs.append(job_dict)
-
- # Calculate total pages
- total_pages = (total_jobs + limit - 1) // limit # Ceiling division
-
- logger.info(f"Returning {len(jobs)} jobs as JSON (page {page} of {total_pages})")
-
- # Return jobs with pagination metadata
- response = jsonify({
- 'jobs': jobs,
- 'pagination': {
- 'total': total_jobs,
- 'limit': limit,
- 'current_page': page,
- 'total_pages': total_pages
- }
- })
- response.headers['Content-Type'] = 'application/json'
- conn.close()
- return response
- except Exception as e:
- logger.error(f"Error in /api/jobs: {str(e)}")
- return jsonify({'error': str(e)}), 500
+jobs_bp = Blueprint('jobs', __name__)
-@jobs_bp.route('/json/jobs', methods=['GET'])
-def api_json_jobs():
- """API endpoint for fetching jobs as JSON - explicitly supports the new correct URL path"""
+@jobs_bp.route('/search', methods=['GET'])
+def search_jobs_endpoint():
+ """Search for jobs with optional filters"""
try:
- limit = request.args.get('limit', 50, type=int)
- logger.info(f"API /api/jobs/json/jobs called with limit: {limit}")
-
- # Get jobs directly using the job_pipeline module
- jobs = get_latest_jobs(limit, app_context=current_app.app_context())
-
- # Force JSON response and log the count
- logger.info(f"Returning {len(jobs)} jobs as JSON from /api/jobs/json/jobs endpoint")
-
- # Force JSON response
- response = jsonify(jobs)
- response.headers['Content-Type'] = 'application/json'
- return response
- except Exception as e:
- logger.error(f"Error fetching jobs JSON: {str(e)}")
- return jsonify({'error': str(e), 'success': False}), 500
-
-@jobs_bp.route('/search', methods=['GET', 'OPTIONS'])
-def search():
- # 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
-
- query = request.args.get('q', '')
- limit = request.args.get('limit', 50, type=int)
- threshold = request.args.get('threshold', 50, type=int)
-
- jobs = search_jobs(query, limit, threshold, app_context=current_app.app_context)
- response = jsonify(jobs)
- response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*')
- response.headers['Access-Control-Allow-Credentials'] = 'true'
- return response
-
-@jobs_bp.route('/refresh', methods=['POST'])
-def refresh():
- source = request.json.get('source') if request.is_json else None
-
- manager = JobPipelineManager(current_app.app_context())
-
- if source:
- count = manager.refresh_source(source)
- else:
- count = manager.refresh_all_jobs()
-
- manager.close()
- return jsonify({'success': True, 'message': f'Added {count} new jobs'})
-
-@jobs_bp.route('/get-more-jobs', methods=['GET', 'POST'])
-def get_more_jobs():
- """Fetch additional jobs from selected sources with specific parameters"""
- try:
- # Get parameters from POST request
- if request.method == 'POST':
- data = request.get_json() or {}
- sources = data.get('sources', [])
- job_title = data.get('job_title', '')
- location = data.get('location', '')
- category = data.get('category', '')
- limit_per_source = data.get('limit_per_source', 25)
- # Or from GET request query parameters
- else:
- sources = request.args.get('sources', '').split(',') if request.args.get('sources') else []
- job_title = request.args.get('job_title', '')
- location = request.args.get('location', '')
- category = request.args.get('category', '')
- limit_per_source = request.args.get('limit_per_source', 25, type=int)
-
- logger.info(f"Fetching more jobs with sources={sources}, job_title={job_title}, location={location}, category={category}")
-
- # Import the JobPipelineManager here to avoid circular imports
- from utils.job_recommenders.pipeline import JobPipelineManager
+ query = request.args.get('query', '')
+ location = request.args.get('location', '')
+ limit = int(request.args.get('limit', 50))
- # Create a manager instance and get more jobs
- manager = JobPipelineManager(current_app.app_context())
- new_jobs_count = manager.get_more_jobs(
- sources=sources,
- job_title=job_title,
+ jobs = search_jobs(
+ query=query,
location=location,
- category=category,
- limit_per_source=limit_per_source
+ limit=limit,
+ app_context=current_app
)
return jsonify({
'success': True,
- 'message': f'Added {new_jobs_count} new jobs from selected sources',
- 'count': new_jobs_count
+ 'jobs': jobs,
+ 'count': len(jobs)
})
+
except Exception as e:
- logger.error(f"Error getting more jobs: {str(e)}")
+ logging.error(f"Error in search_jobs_endpoint: {e}")
return jsonify({
'success': False,
- 'message': f'Error getting more jobs: {str(e)}',
'error': str(e)
}), 500
-@jobs_bp.route('/refresh-greenhouse-companies', methods=['POST'])
-def refresh_greenhouse_companies():
- """Refresh jobs from specific Greenhouse companies."""
- if not request.is_json:
- return jsonify({'success': False, 'message': 'Invalid request format, JSON expected'}), 400
-
- companies = request.json.get('companies', [])
- if not companies:
- return jsonify({'success': False, 'message': 'No companies specified'}), 400
-
- manager = JobPipelineManager(current_app.app_context())
- count = manager.refresh_greenhouse_companies(companies)
- manager.close()
-
- return jsonify({
- 'success': True,
- 'message': f'Added {count} new jobs from Greenhouse companies: {", ".join(companies)}',
- 'count': count
- })
-
-@jobs_bp.route('/cleanup', methods=['POST'])
-def cleanup():
- count = cleanup_expired(app_context=current_app.app_context)
- return jsonify({'success': True, 'message': f'Removed {count} expired jobs'})
-
-@jobs_bp.route('/sources', methods=['GET'])
-def get_sources():
- """Get a list of available job sources."""
- manager = JobPipelineManager(current_app.app_context())
-
- # Basic sources
- sources = [{'name': source.name, 'display_name': source.name.capitalize()}
- for source in manager.job_sources if source.name != 'greenhouse']
-
- # Add Greenhouse with its companies
- if any(source.name == 'greenhouse' for source in manager.job_sources):
- # Get Greenhouse companies from environment
- import os
- greenhouse_domains = os.environ.get('GREENHOUSE_DOMAINS', '').split(',')
- greenhouse_domains = [d.strip() for d in greenhouse_domains if d.strip()]
-
- # Add main Greenhouse source
- sources.append({
- 'name': 'greenhouse',
- 'display_name': 'Greenhouse',
- 'companies': greenhouse_domains
- })
-
- # Add individual companies as sources
- for company in greenhouse_domains:
- sources.append({
- 'name': f'greenhouse-{company}',
- 'display_name': f'Greenhouse - {company.capitalize()}',
- 'parent': 'greenhouse'
- })
-
- # Add Apify source
- sources.append({
- 'name': 'apify',
- 'display_name': 'Apify Indeed Scraper',
- 'description': 'Jobs from Indeed via Apify Scraper'
- })
-
- manager.close()
- return jsonify(sources)
-
-@jobs_bp.route('/apify-fetch', methods=['POST'])
-def fetch_from_apify():
- """Fetch jobs from Apify Indeed Scraper"""
+@jobs_bp.route('/latest', methods=['GET'])
+def get_latest_jobs_endpoint():
+ """Get the latest job postings"""
try:
- data = request.json or {}
-
- # Extract parameters
- position = data.get('position', 'software engineer')
- location = data.get('location', 'Remote')
- country = data.get('country', 'US')
- max_items = data.get('maxItems', 50)
-
- # Create an ApifyJobSource instance
- from utils.job_recommenders.pipeline import ApifyJobSource, save_jobs_to_db
- apify_source = ApifyJobSource(max_items=max_items)
+ limit = int(request.args.get('limit', 20))
- # Fetch jobs
- jobs = apify_source.fetch_jobs(
- position=position,
- location=location,
- country=country,
- max_items=max_items
+ jobs = get_latest_jobs(
+ limit=limit,
+ app_context=current_app
)
- # Save to database
- new_jobs_count = 0
- if jobs:
- new_jobs_count = save_jobs_to_db(jobs, app_context=current_app)
-
return jsonify({
'success': True,
- 'message': f'Fetched {len(jobs)} jobs from Apify Indeed Scraper, added {new_jobs_count} new jobs to database'
- })
-
- except Exception as e:
- current_app.logger.error(f"Error fetching from Apify: {str(e)}")
- return jsonify({'success': False, 'error': str(e)})
-
-@jobs_bp.route('/delete', methods=['POST'])
-def delete_jobs_api():
- """Delete jobs from database"""
- try:
- data = request.json or {}
-
- # Extract parameters
- source = data.get('source') # If provided, only delete jobs from this source
-
- # Delete jobs
- from utils.job_recommenders.pipeline import delete_jobs
- deleted_count = delete_jobs(app_context=current_app, source=source)
-
- source_msg = f" from source '{source}'" if source else ""
- return jsonify({
- 'success': True,
- 'message': f'Deleted {deleted_count} jobs{source_msg} from database'
- })
-
- except Exception as e:
- current_app.logger.error(f"Error deleting jobs: {str(e)}")
- return jsonify({'success': False, 'error': str(e)})
-
-@jobs_bp.route('/api/recommendations', methods=['GET', 'OPTIONS'])
-@cross_origin(origins=['http://localhost:3000'], supports_credentials=True)
-def get_recommendations():
- """Get job recommendations for the current user or browse all jobs if not logged in"""
- # Handle OPTIONS request for CORS preflight
- if request.method == 'OPTIONS':
- response = jsonify({'status': 'ok'})
- response.headers['Access-Control-Allow-Origin'] = 'http://localhost:3000'
- 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:
- # Check if this is a direct browser request (not an AJAX/fetch request)
- accept_header = request.headers.get('Accept', '')
- if 'text/html' in accept_header and 'application/json' not in accept_header:
- return redirect('/job-recommendations')
-
- limit = request.args.get('limit', 24, type=int) # Default to 24 jobs per page
- page = request.args.get('page', 1, type=int)
- search_query = request.args.get('q', '')
- location = request.args.get('location', '')
- force_refresh = request.args.get('force_refresh', 'false').lower() == 'true'
-
- # If user is logged in, get personalized recommendations
- if current_user.is_authenticated:
- recommendations = get_recommendations_for_user(
- user_id=current_user.id,
- limit=limit,
- force_refresh=force_refresh
- )
- else:
- # If not logged in, get all jobs with optional search/filter
- recommendations = get_all_jobs(
- limit=limit,
- page=page,
- search_query=search_query,
- location=location
- )
-
- # Log the recommendations for debugging
- if current_user.is_authenticated:
- current_app.logger.info(f"Fetched {len(recommendations)} recommendations for user {current_user.id}")
- else:
- current_app.logger.info(f"Fetched {len(recommendations)} jobs for browsing")
-
- response = jsonify({
- 'recommendations': recommendations,
- 'page': page,
- 'limit': limit,
- 'has_more': len(recommendations) == limit
- })
-
- # Add CORS headers to the response
- response.headers['Access-Control-Allow-Origin'] = 'http://localhost:3000'
- response.headers['Access-Control-Allow-Credentials'] = 'true'
-
- return response
-
- except Exception as e:
- current_app.logger.error(f"Error getting recommendations: {str(e)}")
- response = jsonify({
- 'success': False,
- 'error': str(e)
+ 'jobs': jobs,
+ 'count': len(jobs)
})
- response.headers['Access-Control-Allow-Origin'] = 'http://localhost:3000'
- response.headers['Access-Control-Allow-Credentials'] = 'true'
- return response, 500
-
-def get_all_jobs(limit=24, page=1, search_query='', location=''):
- """Get all jobs with optional search and filtering"""
- try:
- query = JobRecommendation.query
-
- # Apply search filters if provided
- if search_query:
- search_term = f"%{search_query}%"
- query = query.filter(
- db.or_(
- JobRecommendation.job_title.ilike(search_term),
- JobRecommendation.company.ilike(search_term),
- JobRecommendation.description.ilike(search_term)
- )
- )
-
- if location:
- location_term = f"%{location}%"
- query = query.filter(JobRecommendation.location.ilike(location_term))
-
- # Order by most recent first
- query = query.order_by(JobRecommendation.recommended_at.desc())
-
- # Apply pagination
- offset = (page - 1) * limit
- jobs = query.limit(limit).offset(offset).all()
-
- return [{
- 'id': job.id,
- 'job_title': job.job_title,
- 'company': job.company,
- 'location': job.location,
- 'description': job.description,
- 'url': job.url,
- 'salary': job.salary,
- 'match_score': job.match_score if job.match_score else 0,
- 'is_selected': job.is_selected if hasattr(job, 'is_selected') else False,
- 'applied': job.applied if hasattr(job, 'applied') else False,
- 'recommended_at': job.recommended_at.isoformat() if job.recommended_at else None,
- 'source': job.source,
- 'remote': job.remote if hasattr(job, 'remote') else False
- } for job in jobs]
except Exception as e:
- current_app.logger.error(f"Error getting all jobs: {str(e)}")
- return []
-
-@jobs_bp.route('/api/recommendations/refresh', methods=['POST'])
-@login_required
-def refresh_recommendations():
- """Request a refresh of job recommendations for the current user"""
- try:
- limit = request.json.get('limit', 350)
-
- result = refresh_recommendations_for_user(
- user_id=current_user.id,
- limit=limit
- )
-
- return jsonify(result)
-
- except Exception as e:
- current_app.logger.error(f"Error refreshing recommendations: {str(e)}")
+ logging.error(f"Error in get_latest_jobs_endpoint: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
-@jobs_bp.route('/api/recommendations//select', methods=['POST'])
-@login_required
-def select_job(recommendation_id):
- """Mark a job recommendation as selected by the current user"""
+@jobs_bp.route('/', methods=['GET'])
+def get_job_details(job_id):
+ """Get details for a specific job"""
try:
- data = request.json or {}
- selected = data.get('selected', True)
- notes = data.get('notes')
-
- result = mark_job_selected(
- user_id=current_user.id,
- recommendation_id=recommendation_id,
- selected=selected,
- notes=notes
+ job = get_job_by_id(
+ job_id=job_id,
+ app_context=current_app
)
- return jsonify(result)
-
+ if job:
+ return jsonify({
+ 'success': True,
+ 'job': job
+ })
+ else:
+ return jsonify({
+ 'success': False,
+ 'error': 'Job not found'
+ }), 404
+
except Exception as e:
- current_app.logger.error(f"Error selecting job: {str(e)}")
+ logging.error(f"Error in get_job_details: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
-@jobs_bp.route('/api/selected-jobs', methods=['GET'])
+@jobs_bp.route('/refresh', methods=['POST'])
@login_required
-def get_user_selected_jobs():
- """Get jobs selected by the current user"""
+def refresh_jobs_endpoint():
+ """Refresh jobs from external APIs (admin only)"""
try:
- limit = request.args.get('limit', None, type=int)
-
- selected_jobs = get_selected_jobs(
- user_id=current_user.id,
- limit=limit
+ # Check if user has admin privileges (assuming user has is_admin attribute)
+ if not hasattr(current_user, 'is_admin') or not current_user.is_admin:
+ return jsonify({
+ 'success': False,
+ 'error': 'Admin access required'
+ }), 403
+
+ force_refresh = request.json.get('force_refresh', False) if request.json else False
+
+ result = refresh_jobs(
+ force_refresh=force_refresh,
+ app_context=current_app
)
return jsonify({
'success': True,
- 'count': len(selected_jobs),
- 'selected_jobs': selected_jobs
+ 'result': result
})
except Exception as e:
- current_app.logger.error(f"Error getting selected jobs: {str(e)}")
+ logging.error(f"Error in refresh_jobs_endpoint: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
-@jobs_bp.route('/api/recommendations//apply', methods=['POST'])
-@login_required
-def apply_to_job(recommendation_id):
- """Mark a job as applied by the current user"""
+@jobs_bp.route('/stats', methods=['GET'])
+def get_jobs_stats_endpoint():
+ """Get job statistics"""
try:
- data = request.json or {}
- applied = data.get('applied', True)
-
- result = mark_job_applied(
- user_id=current_user.id,
- recommendation_id=recommendation_id,
- applied=applied
- )
-
- return jsonify(result)
-
- except Exception as e:
- current_app.logger.error(f"Error marking job as applied: {str(e)}")
- return jsonify({
- 'success': False,
- 'error': str(e)
- }), 500
-
-@jobs_bp.route('/api/applied-jobs', methods=['GET'])
-@login_required
-def get_user_applied_jobs():
- """Get jobs applied to by the current user"""
- try:
- limit = request.args.get('limit', None, type=int)
-
- applied_jobs = get_applied_jobs(
- user_id=current_user.id,
- limit=limit
- )
+ stats = get_jobs_stats(app_context=current_app)
return jsonify({
'success': True,
- 'count': len(applied_jobs),
- 'applied_jobs': applied_jobs
+ 'stats': stats
})
except Exception as e:
- current_app.logger.error(f"Error getting applied jobs: {str(e)}")
+ logging.error(f"Error in get_jobs_stats_endpoint: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
-@jobs_bp.route('/api/recommendations/stats', methods=['GET'])
+@jobs_bp.route('/apply', methods=['POST'])
@login_required
-def get_recommendation_stats():
- """Get statistics about the user's job recommendations"""
+def apply_to_job():
+ """Apply to a job posting"""
try:
- # Count total recommendations
- total_recs = JobRecommendation.query.filter_by(user_id=current_user.id).count()
-
- # Count selected jobs
- selected_jobs = db.session.query(JobRecommendation)\
- .join(SelectedJob, JobRecommendation.id == SelectedJob.recommendation_id)\
- .filter(JobRecommendation.user_id == current_user.id)\
- .count()
+ data = request.get_json()
+ job_id = data.get('job_id')
+
+ if not job_id:
+ return jsonify({
+ 'success': False,
+ 'error': 'Job ID is required'
+ }), 400
+
+ # Check if job exists
+ job = get_job_by_id(job_id, app_context=current_app)
+ if not job:
+ return jsonify({
+ 'success': False,
+ 'error': 'Job not found'
+ }), 404
+
+ # Check if user already applied
+ existing_application = Application.query.filter_by(
+ user_id=current_user.id,
+ job_id=job_id
+ ).first()
- # Count applied jobs (count applications by user)
- from models.application import Application
- applied_jobs = Application.query.filter_by(user_id=current_user.id).count()
+ if existing_application:
+ return jsonify({
+ 'success': False,
+ 'error': 'You have already applied to this job'
+ }), 400
- # Get average match score
- score_query = db.session.query(
- db.func.avg(JobRecommendation.match_score)
- ).filter(JobRecommendation.user_id == current_user.id)
+ # Create new application
+ application = Application(
+ user_id=current_user.id,
+ job_id=job_id,
+ status='pending',
+ applied_at=datetime.utcnow()
+ )
- avg_score = score_query.scalar() or 0
+ db.session.add(application)
+ db.session.commit()
return jsonify({
'success': True,
- 'stats': {
- 'total_recommendations': total_recs,
- 'selected_jobs': selected_jobs,
- 'applied_jobs': applied_jobs,
- 'average_match_score': round(avg_score, 1)
- }
+ 'message': 'Application submitted successfully',
+ 'application_id': application.id
})
except Exception as e:
- current_app.logger.error(f"Error getting recommendation stats: {str(e)}")
+ logging.error(f"Error in apply_to_job: {e}")
+ db.session.rollback()
return jsonify({
'success': False,
'error': str(e)
}), 500
-@jobs_bp.route('/pipeline', methods=['GET'])
+@jobs_bp.route('/applications', methods=['GET'])
@login_required
-def job_pipeline():
- """Job pipeline dashboard with functionality to view raw jobs in database and generate recommendations"""
- # Get latest jobs from database
- limit = request.args.get('limit', 50, type=int)
- page = request.args.get('page', 1, type=int)
- query = request.args.get('q', '')
-
- # For searching jobs
- if query:
- jobs = search_jobs(query, limit, 0, app_context=current_app.app_context)
- else:
- jobs = get_latest_jobs(limit, app_context=current_app.app_context)
-
- # For job sources dropdown
- manager = JobPipelineManager(current_app.app_context())
- sources = [source.name for source in manager.job_sources]
- manager.close()
-
- # Get recommendation stats
- total_recs = JobRecommendation.query.filter_by(user_id=current_user.id).count()
-
- # Format jobs for display
- formatted_jobs = []
- for job in jobs:
- formatted_jobs.append({
- 'id': job.get('id'),
- 'title': job.get('title', 'Unknown Title'),
- 'company': job.get('company', 'Unknown Company'),
- 'location': job.get('location', 'Unknown Location'),
- 'url': job.get('url', '#'),
- 'source': job.get('source', 'Unknown Source'),
- 'posted_at': job.get('posted_at', 'Unknown Date')
- })
-
- # Render a simple HTML page
- return render_template('job_pipeline.html',
- jobs=formatted_jobs,
- sources=sources,
- total_jobs=len(formatted_jobs),
- query=query,
- total_recommendations=total_recs,
- user=current_user)
-
-@jobs_bp.route('/recommendations', methods=['POST'])
-@login_required
-def force_generate_recommendations():
- """Force generate job recommendations for the current user"""
+def get_user_applications():
+ """Get current user's job applications"""
try:
- limit = request.json.get('limit', 350) if request.is_json else 350
-
- # First get latest jobs to ensure we have jobs in the database
- jobs_added = 0
- manager = JobPipelineManager(current_app.app_context())
-
- # Get jobs from each source
- for source in manager.job_sources:
- try:
- count = manager.refresh_source(source.name)
- jobs_added += count
- except Exception as e:
- current_app.logger.error(f"Error refreshing source {source.name}: {str(e)}")
-
- manager.close()
-
- # Now force refresh recommendations for the user
- result = refresh_recommendations_for_user(
- user_id=current_user.id,
- limit=limit
- )
-
- # Get the count of recommendations
- rec_count = JobRecommendation.query.filter_by(user_id=current_user.id).count()
+ applications = Application.query.filter_by(
+ user_id=current_user.id
+ ).order_by(Application.applied_at.desc()).all()
+
+ application_list = []
+ for app in applications:
+ job_details = get_job_by_id(app.job_id, app_context=current_app)
+ application_list.append({
+ 'id': app.id,
+ 'job_id': app.job_id,
+ 'status': app.status,
+ 'applied_at': app.applied_at.isoformat() if app.applied_at else None,
+ 'job_details': job_details
+ })
return jsonify({
'success': True,
- 'message': f'Successfully generated {rec_count} recommendations based on {jobs_added} new jobs',
- 'recommendations_count': rec_count,
- 'jobs_added': jobs_added
+ 'applications': application_list,
+ 'count': len(application_list)
})
+
except Exception as e:
- current_app.logger.error(f"Error generating recommendations: {str(e)}")
- current_app.logger.error(traceback.format_exc())
+ logging.error(f"Error in get_user_applications: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
-@jobs_bp.route('/job-pipeline-test', methods=['GET'])
-def job_pipeline_test():
- """Test page for job pipeline"""
- return render_template('jobs/job_pipeline_test.html')
-
-@jobs_bp.route('/api/all-jobs-with-recommendations', methods=['GET'])
-@login_required
-def get_all_jobs_with_recommendations():
- """Get all jobs with recommendation status for the current user (simplified version)"""
- try:
- current_app.logger.info(f"Getting jobs with recommendations for user {current_user.id}")
-
- # Use SQLAlchemy ORM instead of raw SQL for better error handling
- from models.job_posting import JobPosting
- from models.job_recommendation import JobRecommendation
- from models.company import Company
-
- # Get all jobs with companies
- jobs_query = db.session.query(JobPosting).join(Company, JobPosting.company_id == Company.id, isouter=True)
- all_jobs = jobs_query.limit(100).all() # Limit to prevent large responses
-
- current_app.logger.info(f"Found {len(all_jobs)} jobs in database")
-
- # Get recommended jobs for this user
- recommended_jobs = JobRecommendation.query.filter_by(user_id=current_user.id).all()
- recommended_job_ids = set()
- recommendation_data = {}
-
- for rec in recommended_jobs:
- if rec.job_id:
- recommended_job_ids.add(int(rec.job_id))
- recommendation_data[int(rec.job_id)] = {
- 'match_score': rec.match_score or 0,
- 'applied': getattr(rec, 'applied', False),
- 'recommended_at': rec.recommended_at.isoformat() if rec.recommended_at else None,
- 'explanation': rec.explanation or ''
- }
-
- current_app.logger.info(f"Found {len(recommended_job_ids)} recommended jobs for user")
-
- # Format jobs for response
- jobs_list = []
- for job in all_jobs:
- try:
- # Get company name safely
- company_name = job.company.name if job.company else 'Unknown Company'
-
- # Format salary string
- salary_str = ''
- if job.salary_min or job.salary_max:
- if job.salary_min and job.salary_max:
- salary_str = f"${job.salary_min:,.0f} - ${job.salary_max:,.0f}"
- elif job.salary_min:
- salary_str = f"${job.salary_min:,.0f}+"
- elif job.salary_max:
- salary_str = f"Up to ${job.salary_max:,.0f}"
-
- if job.salary_period:
- salary_str += f" {job.salary_period}"
-
- job_formatted = {
- 'id': job.id,
- 'job_title': job.title or 'Unknown Title',
- 'company': company_name,
- 'location': job.location or '',
- 'description': (job.description[:300] + '...') if job.description and len(job.description) > 300 else (job.description or ''),
- 'url': job.url or '',
- 'salary': salary_str,
- 'employment_type': job.employment_type or '',
- 'posted_at': job.posted_at.isoformat() if job.posted_at else None,
- 'is_recommended': job.id in recommended_job_ids,
- 'is_selected': job.id in recommended_job_ids,
- 'recommendation_data': recommendation_data.get(job.id, {}),
- 'keywords': [], # Simplified - no keywords for now to prevent errors
- 'keyword_match_count': 0 # Simplified
- }
- jobs_list.append(job_formatted)
- except Exception as e:
- current_app.logger.error(f"Error formatting job {job.id}: {str(e)}")
- continue
-
- # Sort jobs - recommended first, then by posted date
- jobs_list.sort(key=lambda x: (not x['is_recommended'], x['posted_at'] or ''), reverse=True)
-
- current_app.logger.info(f"Returning {len(jobs_list)} formatted jobs")
-
- return jsonify({
- 'all_jobs': jobs_list,
- 'recommended_count': len(recommended_job_ids),
- 'total_jobs': len(jobs_list),
- 'user_keywords': [] # Simplified for now
- })
-
- except Exception as e:
- current_app.logger.error(f"Error getting all jobs with recommendations: {str(e)}")
- current_app.logger.error(traceback.format_exc())
-
- # Return a minimal response instead of crashing
- return jsonify({
- 'all_jobs': [],
- 'recommended_count': 0,
- 'total_jobs': 0,
- 'user_keywords': [],
- 'error': 'Failed to load jobs data'
- }), 200 # Return 200 to prevent frontend crashes
-
-@jobs_bp.route('/api/update-job-selections', methods=['POST'])
+@jobs_bp.route('/bulk-save', methods=['POST'])
@login_required
-def update_user_job_selections():
- """Update job selections for the current user"""
+def bulk_save_jobs():
+ """Bulk save jobs to database (admin only)"""
try:
- data = request.get_json() if request.is_json else {}
- selected_job_ids = data.get('selected_job_ids', [])
-
- current_app.logger.info(f"Updating job selections for user {current_user.id}: {selected_job_ids}")
-
- # Get existing recommendations using direct SQL
- from models.all_models import db as main_db
- from sqlalchemy import text
-
- # Get existing recommendations
- query = text("SELECT id, job_id, match_score, explanation, recommended_at FROM user_job_recommendations WHERE user_id = :user_id")
- result = main_db.session.execute(query, {'user_id': current_user.id})
-
- existing_recs = []
- generated_recs = {}
-
- for row in result:
- rec_data = {
- 'id': row.id,
- 'job_id': int(row.job_id) if row.job_id else None,
- 'match_score': row.match_score,
- 'explanation': row.explanation,
- 'recommended_at': row.recommended_at
- }
- existing_recs.append(rec_data)
-
- # Keep track of generated recommendations (those with match scores)
- if rec_data['match_score'] and rec_data['match_score'] > 0:
- generated_recs[rec_data['job_id']] = rec_data
-
- # Remove all existing recommendations
- delete_query = text("DELETE FROM user_job_recommendations WHERE user_id = :user_id")
- main_db.session.execute(delete_query, {'user_id': current_user.id})
-
- # Re-add selected recommendations
- for job_id in selected_job_ids:
- job_id = int(job_id)
-
- if job_id in generated_recs:
- # Re-add the generated recommendation
- rec = generated_recs[job_id]
- insert_query = text("""
- INSERT INTO user_job_recommendations (user_id, job_id, match_score, explanation, recommended_at)
- VALUES (:user_id, :job_id, :match_score, :explanation, :recommended_at)
- """)
- main_db.session.execute(insert_query, {
- 'user_id': current_user.id,
- 'job_id': job_id,
- 'match_score': rec['match_score'],
- 'explanation': rec['explanation'],
- 'recommended_at': rec['recommended_at']
- })
- else:
- # Create new manual selection (check if job exists in SQLite)
- from pathlib import Path
- db_path = Path(__file__).parent.parent / 'instance' / 'instant_apply.db'
- conn = sqlite3.connect(str(db_path))
- cursor = conn.cursor()
- cursor.execute('SELECT id FROM job_postings WHERE id = ?', (job_id,))
- job_exists = cursor.fetchone()
- conn.close()
-
- if job_exists:
- insert_query = text("""
- INSERT INTO user_job_recommendations (user_id, job_id, match_score, explanation, recommended_at)
- VALUES (:user_id, :job_id, :match_score, :explanation, :recommended_at)
- """)
- main_db.session.execute(insert_query, {
- 'user_id': current_user.id,
- 'job_id': job_id,
- 'match_score': 0, # Manual selection
- 'explanation': 'Manually selected by user',
- 'recommended_at': datetime.now()
- })
-
- main_db.session.commit()
+ # Check if user has admin privileges
+ if not hasattr(current_user, 'is_admin') or not current_user.is_admin:
+ return jsonify({
+ 'success': False,
+ 'error': 'Admin access required'
+ }), 403
+
+ data = request.get_json()
+ jobs = data.get('jobs', [])
+
+ if not jobs:
+ return jsonify({
+ 'success': False,
+ 'error': 'No jobs provided'
+ }), 400
+
+ result = save_jobs_to_db(
+ jobs=jobs,
+ app_context=current_app
+ )
return jsonify({
'success': True,
- 'message': f'Updated job selections for user {current_user.id}',
- 'selected_count': len(selected_job_ids)
+ 'result': result
})
except Exception as e:
- current_app.logger.error(f"Error updating job selections for user {current_user.id}: {str(e)}")
- current_app.logger.error(traceback.format_exc())
- main_db.session.rollback()
+ logging.error(f"Error in bulk_save_jobs: {e}")
return jsonify({
'success': False,
'error': str(e)
diff --git a/backend/routes/moderator.py b/backend/routes/moderator.py
index ff374d8e..63c984c8 100644
--- a/backend/routes/moderator.py
+++ b/backend/routes/moderator.py
@@ -476,7 +476,15 @@ def get_user_recommendations(user_id):
def generate_user_recommendations(user_id):
"""Generate new job recommendations for a specific assigned user"""
try:
- from backend.utils.job_recommenders.user_recommender import get_recommendations_for_user
+ # Import the recommendation function with proper fallbacks
+ try:
+ from utils.job_recommenders.user_recommender import get_recommendations_for_user
+ except ImportError:
+ try:
+ from backend.utils.job_recommenders.user_recommender import get_recommendations_for_user
+ except ImportError:
+ def get_recommendations_for_user(*args, **kwargs):
+ return []
data = request.get_json() if request.is_json else {}
limit = data.get('limit', 50) # Default to 50 recommendations
diff --git a/backend/routes/profile.py b/backend/routes/profile.py
index 0b23dff6..3152dcf8 100644
--- a/backend/routes/profile.py
+++ b/backend/routes/profile.py
@@ -1,1440 +1,9 @@
-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
-
- # 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)}")
- 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:
- # 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
- upload_folder = current_app.config.get('UPLOAD_FOLDER', 'uploads')
- os.makedirs(upload_folder, exist_ok=True)
- file_path = os.path.join(upload_folder, unique_filename)
- file.save(file_path)
- # 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.
+"""
+# Import from the profile package __init__.py
+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..b77a8148
--- /dev/null
+++ b/backend/routes/profile/__init__.py
@@ -0,0 +1,40 @@
+"""
+Profile routes package initialization
+"""
+
+from .main import profile_main_bp
+from .resume import profile_resume_bp
+from .sections import profile_sections_bp
+from .keywords import profile_keywords_bp
+
+def register_profile_routes(app):
+ """Register all profile-related blueprints"""
+ try:
+ # Register main profile blueprint
+ app.register_blueprint(profile_main_bp, url_prefix='/api/profile')
+
+ # Register resume management blueprint
+ app.register_blueprint(profile_resume_bp, url_prefix='/api/profile/resume')
+
+ # Register profile sections blueprint
+ app.register_blueprint(profile_sections_bp, url_prefix='/api/profile/sections')
+
+ # Register keywords management blueprint
+ app.register_blueprint(profile_keywords_bp, url_prefix='/api/profile/keywords')
+
+ print("✅ Registered all profile blueprints")
+
+ except ImportError as e:
+ print(f"⚠️ Some profile blueprints not available: {e}")
+ # Register only the main blueprint if others fail
+ app.register_blueprint(profile_main_bp, url_prefix='/api/profile')
+ print("✅ Registered main profile blueprint only")
+
+# Export blueprints for direct import if needed
+__all__ = [
+ 'profile_main_bp',
+ 'profile_resume_bp',
+ 'profile_sections_bp',
+ 'profile_keywords_bp',
+ '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..46538df1
--- /dev/null
+++ b/backend/routes/profile/keywords.py
@@ -0,0 +1,457 @@
+"""
+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
+
+try:
+ from models.db import db
+except ImportError:
+ try:
+ from models.db import db
+ except ImportError:
+ from backend.models.db import db
+try:
+ from models.all_models import User
+except ImportError:
+ try:
+ from models.all_models import User
+ except ImportError:
+ from backend.models.all_models import User
+try:
+ from models import JobKeyword
+except ImportError:
+ try:
+ from models import JobKeyword
+ except ImportError:
+ from backend.models import JobKeyword
+try:
+ from services.resume_keyword_service import ResumeKeywordService
+except ImportError:
+ try:
+ from services.resume_keyword_service import ResumeKeywordService
+ except ImportError:
+ from backend.services.resume_keyword_service import ResumeKeywordService
+
+keyword_bp = Blueprint('keyword', __name__)
+resume_service = ResumeKeywordService()
+
+@keyword_bp.route('', methods=['GET'])
+@keyword_bp.route('/', 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('/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('/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('/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('/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
+
+"""
+Keywords management routes for user profiles
+"""
+import logging
+import json
+from flask import Blueprint, request, jsonify
+from flask_login import login_required, current_user
+
+# Flexible imports for different execution contexts
+try:
+ from models.all_models import User, db
+except ImportError:
+ try:
+ from backend.models.all_models import User, db
+ except ImportError:
+ User = None
+ db = None
+
+try:
+ from utils.profile_utils import get_user_profile_data, update_user_profile
+except ImportError:
+ try:
+ from backend.utils.profile_utils import get_user_profile_data, update_user_profile
+ except ImportError:
+ def get_user_profile_data(*args, **kwargs):
+ return {}
+ def update_user_profile(*args, **kwargs):
+ return {'success': False, 'error': 'Profile utilities not available'}
+
+try:
+ from utils.resume_utils import extract_resume_keywords
+except ImportError:
+ try:
+ from backend.utils.resume_utils import extract_resume_keywords
+ except ImportError:
+ def extract_resume_keywords(*args, **kwargs):
+ return []
+
+logger = logging.getLogger(__name__)
+
+profile_keywords_bp = Blueprint('profile_keywords', __name__)
+
+@profile_keywords_bp.route('/', methods=['GET'])
+@login_required
+def get_keywords():
+ """Get user's keywords from resume and profile"""
+ try:
+ if not User:
+ return jsonify({
+ 'success': False,
+ 'error': 'User model not available'
+ }), 500
+
+ user = User.query.get(current_user.id)
+ if not user:
+ return jsonify({
+ 'success': False,
+ 'error': 'User not found'
+ }), 404
+
+ # Get keywords from different sources
+ keywords = {
+ 'resume_keywords': [],
+ 'skill_keywords': [],
+ 'all_keywords': []
+ }
+
+ # Get resume keywords
+ if hasattr(user, 'resume_keywords') and user.resume_keywords:
+ try:
+ if hasattr(user.resume_keywords, 'all'):
+ keyword_objects = user.resume_keywords.all()
+ keywords['resume_keywords'] = [kw.keyword for kw in keyword_objects if hasattr(kw, 'keyword')]
+ else:
+ if isinstance(user.resume_keywords, str):
+ keywords['resume_keywords'] = json.loads(user.resume_keywords)
+ elif isinstance(user.resume_keywords, list):
+ keywords['resume_keywords'] = user.resume_keywords
+ except (json.JSONDecodeError, AttributeError):
+ keywords['resume_keywords'] = []
+
+ # Get skill keywords
+ if hasattr(user, 'skills') and user.skills:
+ try:
+ if isinstance(user.skills, str):
+ try:
+ skills_data = json.loads(user.skills)
+ if isinstance(skills_data, list):
+ keywords['skill_keywords'] = skills_data
+ except json.JSONDecodeError:
+ keywords['skill_keywords'] = [skill.strip() for skill in user.skills.split(',')]
+ elif isinstance(user.skills, list):
+ keywords['skill_keywords'] = user.skills
+ except Exception:
+ keywords['skill_keywords'] = []
+
+ # Combine all keywords
+ all_keywords = set()
+ all_keywords.update(keywords['resume_keywords'])
+ all_keywords.update(keywords['skill_keywords'])
+ keywords['all_keywords'] = list(all_keywords)
+
+ return jsonify({
+ 'success': True,
+ 'keywords': keywords
+ })
+
+ except Exception as e:
+ logger.error(f"Error getting keywords: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 500
+
+@profile_keywords_bp.route('/extract', methods=['POST'])
+@login_required
+def extract_keywords():
+ """Extract keywords from provided text"""
+ try:
+ data = request.get_json() or {}
+ text = data.get('text', '')
+
+ if not text:
+ return jsonify({
+ 'success': False,
+ 'error': 'No text provided'
+ }), 400
+
+ # Extract keywords from text
+ keywords = extract_resume_keywords(text)
+
+ return jsonify({
+ 'success': True,
+ 'keywords': keywords,
+ 'count': len(keywords)
+ })
+
+ except Exception as e:
+ logger.error(f"Error extracting keywords: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 500
+
+@profile_keywords_bp.route('/update', methods=['POST'])
+@login_required
+def update_keywords():
+ """Update user's keywords"""
+ try:
+ data = request.get_json() or {}
+
+ updates = {}
+
+ # Update skills if provided
+ if 'skills' in data:
+ updates['skills'] = data['skills']
+
+ # Update other keyword-related fields
+ keyword_fields = ['preferred_job_titles', 'certifications']
+ for field in keyword_fields:
+ if field in data:
+ updates[field] = data[field]
+
+ if not updates:
+ return jsonify({
+ 'success': False,
+ 'error': 'No valid fields to update'
+ }), 400
+
+ # Update profile
+ result = update_user_profile(current_user.id, updates)
+
+ return jsonify(result)
+
+ except Exception as e:
+ logger.error(f"Error updating keywords: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 500
+
+@profile_keywords_bp.route('/suggest', methods=['POST'])
+@login_required
+def suggest_keywords():
+ """Suggest keywords based on job title or description"""
+ try:
+ data = request.get_json() or {}
+ job_title = data.get('job_title', '')
+ job_description = data.get('job_description', '')
+
+ # Common keywords by job category
+ keyword_suggestions = {
+ 'software': ['python', 'javascript', 'react', 'sql', 'git', 'agile', 'rest api', 'docker'],
+ 'data': ['python', 'sql', 'tableau', 'excel', 'statistics', 'machine learning', 'pandas'],
+ 'marketing': ['seo', 'social media', 'analytics', 'content creation', 'email marketing', 'crm'],
+ 'design': ['photoshop', 'illustrator', 'figma', 'ui/ux', 'typography', 'branding'],
+ 'project': ['project management', 'agile', 'scrum', 'jira', 'stakeholder management', 'budgeting'],
+ 'sales': ['crm', 'lead generation', 'negotiation', 'customer relationship', 'sales strategy'],
+ 'hr': ['recruitment', 'employee relations', 'performance management', 'compensation', 'training']
+ }
+
+ # Determine category based on job title
+ job_text = (job_title + ' ' + job_description).lower()
+ suggested_keywords = []
+
+ for category, keywords in keyword_suggestions.items():
+ if category in job_text:
+ suggested_keywords.extend(keywords)
+
+ # If no specific category matches, suggest general business keywords
+ if not suggested_keywords:
+ suggested_keywords = [
+ 'communication', 'teamwork', 'problem solving', 'leadership',
+ 'time management', 'analytical thinking', 'customer service'
+ ]
+
+ return jsonify({
+ 'success': True,
+ 'suggested_keywords': suggested_keywords[:10], # Limit to 10 suggestions
+ 'job_category': next((cat for cat in keyword_suggestions.keys() if cat in job_text), 'general')
+ })
+
+ except Exception as e:
+ logger.error(f"Error suggesting keywords: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 500
+
+@profile_keywords_bp.route('/refresh', methods=['POST'])
+@login_required
+def refresh_resume_keywords():
+ """Refresh keywords from user's resume"""
+ try:
+ if not User:
+ return jsonify({
+ 'success': False,
+ 'error': 'User model not available'
+ }), 500
+
+ user = User.query.get(current_user.id)
+ if not user:
+ return jsonify({
+ 'success': False,
+ 'error': 'User not found'
+ }), 404
+
+ # Get resume text
+ resume_text = getattr(user, 'resume_text', '') or ''
+
+ if not resume_text:
+ return jsonify({
+ 'success': False,
+ 'error': 'No resume text found'
+ }), 400
+
+ # Extract fresh keywords
+ keywords = extract_resume_keywords(resume_text)
+
+ # Update user's keywords if we have a way to store them
+ # This would depend on your specific user model structure
+
+ return jsonify({
+ 'success': True,
+ 'keywords': keywords,
+ 'count': len(keywords),
+ 'message': f"Extracted {len(keywords)} keywords from resume"
+ })
+
+ except Exception as e:
+ logger.error(f"Error refreshing keywords: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 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..37882766
--- /dev/null
+++ b/backend/routes/profile/main.py
@@ -0,0 +1,378 @@
+"""
+Main profile routes for user profile management.
+"""
+import logging
+import json
+from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
+from flask_login import login_required, current_user
+from werkzeug.utils import secure_filename
+import os
+
+# Flexible imports for different execution contexts
+try:
+ from models.all_models import User, Experience, Skill, Project
+ from models.db import db
+ from models.base_models import user_skills
+except ImportError:
+ try:
+ from backend.models.all_models import User, Experience, Skill, Project
+ from backend.models.db import db
+ from backend.models.base_models import user_skills
+ except ImportError:
+ User = None
+ Experience = None
+ Skill = None
+ Project = None
+ user_skills = None
+ db = None
+
+try:
+ from utils.resume_utils import process_resume_file, extract_resume_keywords
+except ImportError:
+ try:
+ from backend.utils.resume_utils import process_resume_file, extract_resume_keywords
+ except ImportError:
+ def process_resume_file(*args, **kwargs):
+ return {'success': False, 'error': 'Resume processing not available'}
+ def extract_resume_keywords(*args, **kwargs):
+ return []
+
+try:
+ from utils.profile_utils import update_user_profile, get_user_profile_data
+except ImportError:
+ try:
+ from backend.utils.profile_utils import update_user_profile, get_user_profile_data
+ except ImportError:
+ def update_user_profile(*args, **kwargs):
+ return {'success': False, 'error': 'Profile update not available'}
+ def get_user_profile_data(*args, **kwargs):
+ return {}
+
+logger = logging.getLogger(__name__)
+
+profile_main_bp = Blueprint('profile_main', __name__)
+
+@profile_main_bp.route('/')
+@login_required
+def profile_dashboard():
+ """Profile dashboard page"""
+ try:
+ # Check if models are available
+ if not User or not db:
+ return jsonify({
+ 'success': False,
+ 'error': 'Database models not available'
+ }), 500
+
+ # Get user directly from database to ensure fresh data
+ user = User.query.get(current_user.id)
+ if not user:
+ return jsonify({
+ 'success': False,
+ 'error': 'User not found'
+ }), 404
+
+ # Return user data directly using the to_dict method
+ user_data = user.to_dict()
+
+ return jsonify(user_data)
+
+ except Exception as e:
+ logger.error(f"Error loading profile dashboard: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 500
+
+@profile_main_bp.route('/update', methods=['POST'])
+@login_required
+def update_profile():
+ """Update user profile"""
+ try:
+ data = request.get_json() or {}
+
+ # Update profile using utility function
+ result = update_user_profile(current_user.id, data)
+
+ if result['success']:
+ return jsonify({
+ 'success': True,
+ 'message': 'Profile updated successfully'
+ })
+ else:
+ return jsonify({
+ 'success': False,
+ 'error': result.get('error', 'Update failed')
+ }), 400
+
+ except Exception as e:
+ logger.error(f"Error updating profile: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 500
+
+@profile_main_bp.route('/upload-resume', methods=['POST'])
+@login_required
+def upload_resume():
+ """Upload and process resume file"""
+ try:
+ if 'resume' not in request.files:
+ return jsonify({
+ 'success': False,
+ 'error': 'No resume file provided'
+ }), 400
+
+ file = request.files['resume']
+
+ if file.filename == '':
+ return jsonify({
+ 'success': False,
+ 'error': 'No file selected'
+ }), 400
+
+ # Process resume file
+ result = process_resume_file(file, current_user.id)
+
+ return jsonify(result)
+
+ except Exception as e:
+ logger.error(f"Error uploading resume: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 500
+
+@profile_main_bp.route('/data')
+@login_required
+def get_profile_data():
+ """Get current user's profile data"""
+ try:
+ profile_data = get_user_profile_data(current_user.id)
+ return jsonify({
+ 'success': True,
+ 'data': profile_data
+ })
+ except Exception as e:
+ logger.error(f"Error getting profile data: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 500
+
+@profile_main_bp.route('/skills', methods=['GET', 'POST'])
+@login_required
+def manage_skills():
+ """Manage user skills"""
+ try:
+ if request.method == 'GET':
+ # Get current skills
+ profile_data = get_user_profile_data(current_user.id)
+ skills = profile_data.get('skills', [])
+
+ return jsonify({
+ 'success': True,
+ 'skills': skills
+ })
+
+ elif request.method == 'POST':
+ # Update skills
+ data = request.get_json() or {}
+ skills = data.get('skills', [])
+
+ # Update profile with new skills
+ result = update_user_profile(current_user.id, {'skills': skills})
+
+ return jsonify(result)
+
+ except Exception as e:
+ logger.error(f"Error managing skills: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 500
+
+@profile_main_bp.route('/experience', methods=['GET', 'POST'])
+@login_required
+def manage_experience():
+ """Manage user experience"""
+ try:
+ if request.method == 'GET':
+ # Get current experience
+ profile_data = get_user_profile_data(current_user.id)
+ experience = profile_data.get('experience', '')
+
+ return jsonify({
+ 'success': True,
+ 'experience': experience
+ })
+
+ elif request.method == 'POST':
+ # Update experience
+ data = request.get_json() or {}
+ experience = data.get('experience', '')
+
+ # Update profile with new experience
+ result = update_user_profile(current_user.id, {'experience': experience})
+
+ return jsonify(result)
+
+ except Exception as e:
+ logger.error(f"Error managing experience: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 500
+
+@profile_main_bp.route('/preferences', methods=['GET', 'POST'])
+@login_required
+def manage_preferences():
+ """Manage user job preferences"""
+ try:
+ if request.method == 'GET':
+ # Get current preferences
+ profile_data = get_user_profile_data(current_user.id)
+ preferences = {
+ 'preferred_job_titles': profile_data.get('preferred_job_titles', []),
+ 'preferred_locations': profile_data.get('preferred_locations', []),
+ 'work_mode_preference': profile_data.get('work_mode_preference', ''),
+ 'desired_salary_range': profile_data.get('desired_salary_range', '')
+ }
+
+ return jsonify({
+ 'success': True,
+ 'preferences': preferences
+ })
+
+ elif request.method == 'POST':
+ # Update preferences
+ data = request.get_json() or {}
+
+ preferences_update = {}
+ if 'preferred_job_titles' in data:
+ preferences_update['preferred_job_titles'] = data['preferred_job_titles']
+ if 'preferred_locations' in data:
+ preferences_update['preferred_locations'] = data['preferred_locations']
+ if 'work_mode_preference' in data:
+ preferences_update['work_mode_preference'] = data['work_mode_preference']
+ if 'desired_salary_range' in data:
+ preferences_update['desired_salary_range'] = data['desired_salary_range']
+
+ # Update profile with new preferences
+ result = update_user_profile(current_user.id, preferences_update)
+
+ return jsonify(result)
+
+ except Exception as e:
+ logger.error(f"Error managing preferences: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 500
+
+@profile_main_bp.route('/delete', methods=['DELETE'])
+@login_required
+def delete_profile():
+ """Delete user profile (soft delete)"""
+ try:
+ if not User or not db:
+ return jsonify({
+ 'success': False,
+ 'error': 'Database not available'
+ }), 500
+
+ # Mark user as inactive instead of deleting
+ current_user.is_active = False
+ db.session.commit()
+
+ return jsonify({
+ 'success': True,
+ 'message': 'Profile deactivated successfully'
+ })
+
+ except Exception as e:
+ logger.error(f"Error deleting profile: {e}")
+ if db:
+ db.session.rollback()
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 500
+
+@profile_main_bp.route('/export')
+@login_required
+def export_profile():
+ """Export user profile data"""
+ try:
+ profile_data = get_user_profile_data(current_user.id)
+
+ # Add user basic info
+ export_data = {
+ 'user_id': current_user.id,
+ 'name': current_user.name,
+ 'email': current_user.email,
+ 'profile': profile_data,
+ 'exported_at': str(datetime.utcnow())
+ }
+
+ return jsonify({
+ 'success': True,
+ 'data': export_data
+ })
+
+ except Exception as e:
+ logger.error(f"Error exporting profile: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 500
+
+@profile_main_bp.route('/test-db', methods=['GET'])
+def test_database_data():
+ """Test endpoint to check database data directly"""
+ try:
+ # Check if models are available
+ if not User or not db:
+ return jsonify({
+ 'success': False,
+ 'error': 'Database models not available'
+ }), 500
+
+ # Get user with ID 2
+ user = User.query.get(2)
+ if not user:
+ return jsonify({
+ 'success': False,
+ 'error': 'User not found'
+ }), 404
+
+ # Check experiences directly
+ experiences = Experience.query.filter_by(user_id=user.id).all()
+ experience_data = [exp.to_dict() for exp in experiences]
+
+ # Check skills directly
+ skills = Skill.query.join(user_skills).filter(user_skills.c.user_id == user.id).all()
+ skill_data = [skill.to_dict() for skill in skills]
+
+ # Check projects directly
+ projects = Project.query.filter_by(user_id=user.id).all()
+ project_data = [proj.to_dict() for proj in projects]
+
+ return jsonify({
+ 'success': True,
+ 'user_id': user.id,
+ 'user_email': user.email,
+ 'experiences_count': len(experience_data),
+ 'experiences': experience_data,
+ 'skills_count': len(skill_data),
+ 'skills': skill_data,
+ 'projects_count': len(project_data),
+ 'projects': project_data
+ })
+
+ except Exception as e:
+ logger.error(f"Error in test database endpoint: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 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..42dabe31
--- /dev/null
+++ b/backend/routes/profile/resume.py
@@ -0,0 +1,723 @@
+"""
+Resume management routes for user profiles.
+"""
+import logging
+import json
+import os
+import traceback
+from datetime import datetime as dt
+from flask import Blueprint, request, jsonify, current_app
+from flask_login import login_required, current_user
+from werkzeug.utils import secure_filename
+import PyPDF2
+import docx2txt
+
+# Flexible imports for different execution contexts
+try:
+ # First try direct import from models package
+ from models.all_models import User
+ from models.db import db
+ print("✅ Successfully imported User and db from models package")
+except ImportError as e1:
+ print(f"❌ Failed to import from models package: {e1}")
+ try:
+ # Try importing from backend.models package
+ from backend.models.all_models import User
+ from backend.models.db import db
+ print("✅ Successfully imported User and db from backend.models package")
+ except ImportError as e2:
+ print(f"❌ Failed to import from backend.models package: {e2}")
+ try:
+ # Try importing from models package using __init__.py
+ from models import User, db
+ print("✅ Successfully imported User and db from models __init__.py")
+ except ImportError as e3:
+ print(f"❌ Failed to import from models __init__.py: {e3}")
+ # If all else fails, set to None and handle gracefully
+ User = None
+ db = None
+ import logging
+ logging.error("Failed to import User and db models from any location")
+ print("❌ All import attempts failed - setting models to None")
+
+try:
+ from utils.document_parser import parse_pdf, parse_and_save_resume
+ from services.resume_keyword_service import ResumeKeywordService
+except ImportError:
+ try:
+ from backend.utils.document_parser import parse_pdf, parse_and_save_resume
+ from backend.services.resume_keyword_service import ResumeKeywordService
+ except ImportError:
+ def parse_pdf(*args, **kwargs):
+ return {}
+ def parse_and_save_resume(*args, **kwargs):
+ return '', '', '', ''
+ class ResumeKeywordService:
+ def extract_keywords_from_resume(self, *args, **kwargs):
+ return {'keywords_extracted': 0, 'keywords': []}
+
+logger = logging.getLogger(__name__)
+
+profile_resume_bp = Blueprint('profile_resume', __name__)
+resume_service = ResumeKeywordService()
+
+# Base route for compatibility - handles POST /api/profile/resume
+@profile_resume_bp.route('', methods=['POST'])
+@profile_resume_bp.route('/', methods=['POST'])
+@login_required
+def upload_resume_base():
+ """Upload resume at the base route for compatibility"""
+ return api_upload_resume()
+
+@profile_resume_bp.route('/upload', methods=['POST'])
+@login_required
+def upload_resume():
+ """Upload and process a resume file"""
+ return api_upload_resume()
+
+def api_upload_resume():
+ """API endpoint for resume uploads from the React frontend"""
+ try:
+ # Check if models are available
+ if not User or not db:
+ current_app.logger.error("User model or database not available")
+ return jsonify({
+ 'success': False,
+ 'error': 'Database models not available. Please check server configuration.'
+ }), 500
+
+ 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:
+ 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
+
+ if db:
+ 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())
+ if db:
+ 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 or 'resume' in request.files:
+ current_app.logger.info("Processing multipart form file upload")
+ file = request.files.get('resume_file') or request.files.get('resume')
+ 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
+ if db:
+ 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 = 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())
+ if db:
+ 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())
+ if db:
+ db.session.rollback()
+ return jsonify({'success': False, 'error': f'Unexpected error: {str(e)}'}), 500
+
+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
+ 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:
+ # Auto-fill user profile with parsed data
+ if parsed_data:
+ # 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
+
+ # Save parsed data (experiences, skills, etc.) to database
+ current_app.logger.info(f"About to save parsed data for user {current_user.id}")
+ save_parsed_data_to_database(current_user, parsed_data)
+ current_app.logger.info(f"Finished saving parsed data for user {current_user.id}")
+
+ # Commit changes to database
+ if db:
+ db.session.commit()
+
+ 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 extract_text_from_resume(file_path):
+ """Extract text from resume file based on file type"""
+ try:
+ if file_path.lower().endswith('.pdf'):
+ with open(file_path, 'rb') as file:
+ pdf_reader = PyPDF2.PdfReader(file)
+ text = ""
+ for page in pdf_reader.pages:
+ text += page.extract_text()
+ return text
+ elif file_path.lower().endswith('.docx'):
+ 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 ""
+ except Exception as e:
+ current_app.logger.error(f"Error extracting text from {file_path}: {str(e)}")
+ return ""
+
+@profile_resume_bp.route('/text', methods=['GET'])
+@login_required
+def get_resume_text():
+ """Get the extracted text from user's resume"""
+ try:
+ if not User:
+ return jsonify({
+ 'success': False,
+ 'error': 'User model not available'
+ }), 500
+
+ # Get user's resume text
+ user = User.query.get(current_user.id)
+ if not user:
+ return jsonify({
+ 'success': False,
+ 'error': 'User not found'
+ }), 404
+
+ resume_text = getattr(user, 'resume', '') or ''
+
+ return jsonify({
+ 'success': True,
+ 'resume_text': resume_text
+ })
+
+ except Exception as e:
+ logger.error(f"Error getting resume text: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 500
+
+@profile_resume_bp.route('/update-text', methods=['POST'])
+@login_required
+def update_resume_text():
+ """Update the resume text manually"""
+ try:
+ data = request.get_json() or {}
+ resume_text = data.get('resume_text', '')
+
+ if not User or not db:
+ return jsonify({
+ 'success': False,
+ 'error': 'Database not available'
+ }), 500
+
+ # Update user's resume text
+ user = User.query.get(current_user.id)
+ if not user:
+ return jsonify({
+ 'success': False,
+ 'error': 'User not found'
+ }), 404
+
+ # Update resume text
+ user.resume = resume_text
+
+ if db:
+ db.session.commit()
+
+ return jsonify({
+ 'success': True,
+ 'message': 'Resume text updated successfully'
+ })
+
+ except Exception as e:
+ logger.error(f"Error updating resume text: {e}")
+ if db:
+ db.session.rollback()
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 500
+
+@profile_resume_bp.route('/delete', methods=['DELETE'])
+@login_required
+def delete_resume():
+ """Delete user's resume"""
+ try:
+ if not User or not db:
+ return jsonify({
+ 'success': False,
+ 'error': 'Database not available'
+ }), 500
+
+ # Get user
+ user = User.query.get(current_user.id)
+ if not user:
+ return jsonify({
+ 'success': False,
+ 'error': 'User not found'
+ }), 404
+
+ # Clear resume data
+ user.resume = None
+ user.resume_file_path = None
+ user.resume_filename = None
+ user.resume_mime_type = None
+
+ if db:
+ db.session.commit()
+
+ return jsonify({
+ 'success': True,
+ 'message': 'Resume deleted successfully'
+ })
+
+ except Exception as e:
+ logger.error(f"Error deleting resume: {e}")
+ if db:
+ db.session.rollback()
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 500
+
+@profile_resume_bp.route('/info', methods=['GET'])
+@login_required
+def get_resume_info():
+ """Get resume information (file name, upload date, etc.)"""
+ try:
+ if not User:
+ return jsonify({
+ 'success': False,
+ 'error': 'User model not available'
+ }), 500
+
+ user = User.query.get(current_user.id)
+ if not user:
+ return jsonify({
+ 'success': False,
+ 'error': 'User not found'
+ }), 404
+
+ resume_info = {
+ 'has_resume': bool(getattr(user, 'resume', None) or getattr(user, 'resume_file_path', None)),
+ 'file_path': getattr(user, 'resume_file_path', None),
+ 'filename': getattr(user, 'resume_filename', None),
+ 'mime_type': getattr(user, 'resume_mime_type', None),
+ 'text_length': len(getattr(user, 'resume', '') or ''),
+ }
+
+ return jsonify({
+ 'success': True,
+ 'resume_info': resume_info
+ })
+
+ except Exception as e:
+ logger.error(f"Error getting resume info: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 500
+
+@profile_resume_bp.route('/keywords', methods=['GET'])
+@login_required
+def get_resume_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:
+ logger.error(f"Error getting user keywords: {str(e)}")
+ return jsonify({'success': False, 'error': str(e)}), 500
+
+@profile_resume_bp.route('/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({'success': False, 'error': 'No file uploaded'}), 400
+
+ file = request.files['resume']
+ if file.filename == '':
+ return jsonify({'success': False, 'error': 'No file selected'}), 400
+
+ # Save file temporarily and parse content
+ filename = secure_filename(file.filename)
+ temp_path = os.path.join(current_app.config.get('UPLOAD_FOLDER', 'uploads'), 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'):
+ resume_text = docx2txt.process(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
+ extraction_result = resume_service.extract_keywords_from_resume(
+ user_id=current_user.id,
+ resume_text=resume_text
+ )
+
+ if db:
+ 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)}")
+ if db:
+ db.session.rollback()
+ return jsonify({'success': False, 'error': str(e)}), 500
+
+def save_parsed_data_to_database(user, parsed_data):
+ """Save parsed resume data (experiences, skills, etc.) to the database"""
+ try:
+ current_app.logger.info(f"Starting save_parsed_data_to_database for user {user.id}")
+ if not parsed_data:
+ current_app.logger.info("No parsed data to save")
+ return
+
+ # Import models here to avoid circular imports
+ from models.all_models import Experience, Skill, Project
+
+ # Save experiences
+ if parsed_data.get('experience'):
+ current_app.logger.info(f"Saving {len(parsed_data['experience'])} experiences")
+ # Clear existing experiences for this user
+ Experience.query.filter_by(user_id=user.id).delete()
+
+ for exp_data in parsed_data['experience']:
+ # Parse dates from text format to proper date objects
+ def parse_date(date_str):
+ if not date_str or date_str.lower() == 'present':
+ return None
+
+ try:
+ # Handle formats like "June 2025", "March 2025", etc.
+ from datetime import datetime
+ from dateutil import parser
+ return parser.parse(date_str, fuzzy=True).date()
+ except:
+ # If parsing fails, return None
+ return None
+
+ # Handle "Present" end date - set to None for current positions
+ end_date = exp_data.get('end_date', '')
+ if end_date == 'Present' or end_date == 'present':
+ end_date = None
+ is_current = True
+ else:
+ end_date = parse_date(end_date)
+ is_current = False
+
+ # Parse start date
+ start_date = parse_date(exp_data.get('start_date', ''))
+
+ experience = Experience(
+ user_id=user.id,
+ company_name=exp_data.get('company', ''),
+ position=exp_data.get('title', ''),
+ description=exp_data.get('description', ''),
+ location=exp_data.get('location', ''),
+ start_date=start_date,
+ end_date=end_date,
+ is_current=is_current
+ )
+ db.session.add(experience)
+ current_app.logger.info(f"Added experience: {exp_data.get('title')} at {exp_data.get('company')}")
+ else:
+ current_app.logger.info("No experiences to save")
+
+ # Save skills - use the many-to-many relationship
+ if parsed_data.get('skills'):
+ current_app.logger.info(f"Saving {len(parsed_data['skills'])} skills")
+ # Clear existing skills for this user using SQL
+ from sqlalchemy import text
+ db.session.execute(
+ text('DELETE FROM user_skills WHERE user_id = :user_id'),
+ {'user_id': user.id}
+ )
+
+ for skill_name in parsed_data['skills']:
+ # Find or create the skill
+ skill = Skill.query.filter_by(name=skill_name).first()
+ if not skill:
+ skill = Skill(name=skill_name)
+ db.session.add(skill)
+ db.session.flush() # Get the skill ID
+
+ # Add skill to user using SQL
+ db.session.execute(
+ text('INSERT INTO user_skills (user_id, skill_id) VALUES (:user_id, :skill_id)'),
+ {'user_id': user.id, 'skill_id': skill.id}
+ )
+ current_app.logger.info(f"Added skill: {skill_name}")
+ else:
+ current_app.logger.info("No skills to save")
+
+ # Save education - store in user's education_level field for now
+ if parsed_data.get('education'):
+ # For now, store education as a JSON string in the user's education_level field
+ # This is a simplified approach - in a full implementation, you'd have a separate Education model
+ education_data = []
+ for edu_data in parsed_data['education']:
+ if isinstance(edu_data, dict):
+ education_data.append({
+ 'school': edu_data.get('school', ''),
+ 'degree': edu_data.get('degree', ''),
+ 'field': edu_data.get('field', ''),
+ 'start_date': edu_data.get('start_date', ''),
+ 'end_date': edu_data.get('end_date', ''),
+ 'gpa': edu_data.get('gpa', '')
+ })
+ else:
+ education_data.append({'school': str(edu_data)})
+
+ # Store in user's bio field as JSON (temporary solution)
+ user.bio = json.dumps(education_data)
+
+ # Save projects
+ if parsed_data.get('projects'):
+ current_app.logger.info(f"Saving {len(parsed_data['projects'])} projects")
+ # Clear existing projects for this user
+ Project.query.filter_by(user_id=user.id).delete()
+
+ for proj_data in parsed_data['projects']:
+ if isinstance(proj_data, dict):
+ # Parse dates for projects
+ start_date = parse_date(proj_data.get('start_date', ''))
+ end_date = parse_date(proj_data.get('end_date', ''))
+
+ project = Project(
+ user_id=user.id,
+ name=proj_data.get('name', ''),
+ description=proj_data.get('description', ''),
+ technologies=json.dumps(proj_data.get('technologies', [])),
+ url=proj_data.get('url', ''),
+ start_date=start_date,
+ end_date=end_date
+ )
+ else:
+ # Handle string format
+ project = Project(
+ user_id=user.id,
+ name=str(proj_data),
+ description='',
+ technologies='[]',
+ url='',
+ start_date=None,
+ end_date=None
+ )
+ db.session.add(project)
+ current_app.logger.info(f"Added project: {project.name}")
+
+ # Commit all changes
+ if db:
+ db.session.commit()
+ current_app.logger.info(f"Successfully saved parsed data to database for user {user.id}")
+
+ except Exception as e:
+ current_app.logger.error(f"Error saving parsed data to database: {str(e)}")
+ current_app.logger.error(traceback.format_exc())
+ if db:
+ db.session.rollback()
\ 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..96166f17
--- /dev/null
+++ b/backend/routes/profile/sections.py
@@ -0,0 +1,1032 @@
+"""
+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
+
+try:
+ from models.db import db
+except ImportError:
+ try:
+ from models.db import db
+ except ImportError:
+ from backend.models.db import db
+try:
+ from models.all_models import User
+except ImportError:
+ try:
+ from models.all_models import User
+ except ImportError:
+ from backend.models.all_models import User
+
+sections_bp = Blueprint('sections', __name__)
+
+# Simple validation functions
+def validate_experience_data(data):
+ """Validate experience data"""
+ errors = {}
+
+ if not data.get('title') or len(data['title'].strip()) < 1:
+ errors['title'] = 'Job title is required'
+ elif len(data['title']) > 200:
+ errors['title'] = 'Job title must be under 200 characters'
+
+ if not data.get('company') or len(data['company'].strip()) < 1:
+ errors['company'] = 'Company name is required'
+ elif len(data['company']) > 200:
+ errors['company'] = 'Company name must be under 200 characters'
+
+ if data.get('location') and len(data['location']) > 200:
+ errors['location'] = 'Location must be under 200 characters'
+
+ if data.get('description') and len(data['description']) > 2000:
+ errors['description'] = 'Description must be under 2000 characters'
+
+ return errors
+
+def validate_project_data(data):
+ """Validate project data"""
+ errors = {}
+
+ if not data.get('name') or len(data['name'].strip()) < 1:
+ errors['name'] = 'Project name is required'
+ elif len(data['name']) > 200:
+ errors['name'] = 'Project name must be under 200 characters'
+
+ if data.get('description') and len(data['description']) > 2000:
+ errors['description'] = 'Description must be under 2000 characters'
+
+ if data.get('url') and len(data['url']) > 500:
+ errors['url'] = 'URL must be under 500 characters'
+
+ if data.get('role') and len(data['role']) > 200:
+ errors['role'] = 'Role must be under 200 characters'
+
+ return errors
+
+def validate_education_data(data):
+ """Validate education data"""
+ errors = {}
+
+ if not data.get('degree') or len(data['degree'].strip()) < 1:
+ errors['degree'] = 'Degree is required'
+ elif len(data['degree']) > 200:
+ errors['degree'] = 'Degree must be under 200 characters'
+
+ if not data.get('school') or len(data['school'].strip()) < 1:
+ errors['school'] = 'School name is required'
+ elif len(data['school']) > 200:
+ errors['school'] = 'School name must be under 200 characters'
+
+ if data.get('location') and len(data['location']) > 200:
+ errors['location'] = 'Location must be under 200 characters'
+
+ if data.get('gpa') and len(data['gpa']) > 10:
+ errors['gpa'] = 'GPA must be under 10 characters'
+
+ if data.get('major') and len(data['major']) > 200:
+ errors['major'] = 'Major must be under 200 characters'
+
+ return errors
+
+def validate_language_data(data):
+ """Validate language data"""
+ errors = {}
+
+ if not data.get('name') or len(data['name'].strip()) < 1:
+ errors['name'] = 'Language name is required'
+ elif len(data['name']) > 100:
+ errors['name'] = 'Language name must be under 100 characters'
+
+ valid_levels = ["Beginner", "Intermediate", "Advanced", "Native"]
+ if not data.get('proficiency_level') or data['proficiency_level'] not in valid_levels:
+ errors['proficiency_level'] = f'Proficiency level must be one of: {", ".join(valid_levels)}'
+
+ return errors
+
+def validate_certification_data(data):
+ """Validate certification data"""
+ errors = {}
+
+ if not data.get('name') or len(data['name'].strip()) < 1:
+ errors['name'] = 'Certification name is required'
+ elif len(data['name']) > 200:
+ errors['name'] = 'Certification name must be under 200 characters'
+
+ if data.get('issuer') and len(data['issuer']) > 200:
+ errors['issuer'] = 'Issuer must be under 200 characters'
+
+ if data.get('url') and len(data['url']) > 500:
+ errors['url'] = 'URL must be under 500 characters'
+
+ return errors
+
+# 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:
+ data = request.get_json()
+ if not data:
+ return jsonify({
+ 'success': False,
+ 'error': 'No data provided'
+ }), 400
+
+ # Validate data
+ errors = validate_experience_data(data)
+ if errors:
+ return jsonify({
+ 'success': False,
+ 'error': 'Validation error',
+ 'validation_errors': errors
+ }), 400
+
+ 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 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:
+ data = request.get_json()
+ if not data:
+ return jsonify({
+ 'success': False,
+ 'error': 'No data provided'
+ }), 400
+
+ # Validate data
+ errors = validate_experience_data(data)
+ if errors:
+ return jsonify({
+ 'success': False,
+ 'error': 'Validation error',
+ 'validation_errors': errors
+ }), 400
+
+ 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 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
+ original_length = len(experience_data)
+ experience_data = [exp for exp in experience_data if exp.get('id') != experience_id]
+
+ if len(experience_data) == original_length:
+ return jsonify({
+ 'success': False,
+ 'error': 'Experience not found'
+ }), 404
+
+ 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
+
+# Projects 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:
+ data = request.get_json()
+ if not data:
+ return jsonify({
+ 'success': False,
+ 'error': 'No data provided'
+ }), 400
+
+ # Validate data
+ errors = validate_project_data(data)
+ if errors:
+ return jsonify({
+ 'success': False,
+ 'error': 'Validation error',
+ 'validation_errors': errors
+ }), 400
+
+ 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 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:
+ data = request.get_json()
+ if not data:
+ return jsonify({
+ 'success': False,
+ 'error': 'No data provided'
+ }), 400
+
+ # Validate data
+ errors = validate_project_data(data)
+ if errors:
+ return jsonify({
+ 'success': False,
+ 'error': 'Validation error',
+ 'validation_errors': errors
+ }), 400
+
+ 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 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
+ original_length = len(projects_data)
+ projects_data = [proj for proj in projects_data if proj.get('id') != project_id]
+
+ if len(projects_data) == original_length:
+ return jsonify({
+ 'success': False,
+ 'error': 'Project not found'
+ }), 404
+
+ 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:
+ data = request.get_json()
+ if not data:
+ return jsonify({
+ 'success': False,
+ 'error': 'No data provided'
+ }), 400
+
+ # Validate data
+ errors = validate_education_data(data)
+ if errors:
+ return jsonify({
+ 'success': False,
+ 'error': 'Validation error',
+ 'validation_errors': errors
+ }), 400
+
+ 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 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:
+ data = request.get_json()
+ if not data:
+ return jsonify({
+ 'success': False,
+ 'error': 'No data provided'
+ }), 400
+
+ # Validate data
+ errors = validate_education_data(data)
+ if errors:
+ return jsonify({
+ 'success': False,
+ 'error': 'Validation error',
+ 'validation_errors': errors
+ }), 400
+
+ 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 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
+ original_length = len(education_data)
+ education_data = [edu for edu in education_data if edu.get('id') != education_id]
+
+ if len(education_data) == original_length:
+ return jsonify({
+ 'success': False,
+ 'error': 'Education not found'
+ }), 404
+
+ 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
+
+# Languages 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:
+ data = request.get_json()
+ if not data:
+ return jsonify({
+ 'success': False,
+ 'error': 'No data provided'
+ }), 400
+
+ # Validate data
+ errors = validate_language_data(data)
+ if errors:
+ return jsonify({
+ 'success': False,
+ 'error': 'Validation error',
+ 'validation_errors': errors
+ }), 400
+
+ 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 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:
+ data = request.get_json()
+ if not data:
+ return jsonify({
+ 'success': False,
+ 'error': 'No data provided'
+ }), 400
+
+ # Validate data
+ errors = validate_language_data(data)
+ if errors:
+ return jsonify({
+ 'success': False,
+ 'error': 'Validation error',
+ 'validation_errors': errors
+ }), 400
+
+ 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 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
+ original_length = len(languages_data)
+ languages_data = [lang for lang in languages_data if lang.get('id') != language_id]
+
+ if len(languages_data) == original_length:
+ return jsonify({
+ 'success': False,
+ 'error': 'Language not found'
+ }), 404
+
+ 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
+
+# Certifications 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:
+ data = request.get_json()
+ if not data:
+ return jsonify({
+ 'success': False,
+ 'error': 'No data provided'
+ }), 400
+
+ # Validate data
+ errors = validate_certification_data(data)
+ if errors:
+ return jsonify({
+ 'success': False,
+ 'error': 'Validation error',
+ 'validation_errors': errors
+ }), 400
+
+ 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 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:
+ data = request.get_json()
+ if not data:
+ return jsonify({
+ 'success': False,
+ 'error': 'No data provided'
+ }), 400
+
+ # Validate data
+ errors = validate_certification_data(data)
+ if errors:
+ return jsonify({
+ 'success': False,
+ 'error': 'Validation error',
+ 'validation_errors': errors
+ }), 400
+
+ 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 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
+ original_length = len(certifications_data)
+ certifications_data = [cert for cert in certifications_data if cert.get('id') != certification_id]
+
+ if len(certifications_data) == original_length:
+ return jsonify({
+ 'success': False,
+ 'error': 'Certification not found'
+ }), 404
+
+ 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
+
+"""
+Profile sections management routes
+"""
+import logging
+from flask import Blueprint, request, jsonify
+from flask_login import login_required, current_user
+
+# Flexible imports for different execution contexts
+try:
+ from utils.profile_utils import get_user_profile_data, update_user_profile
+except ImportError:
+ try:
+ from backend.utils.profile_utils import get_user_profile_data, update_user_profile
+ except ImportError:
+ def get_user_profile_data(*args, **kwargs):
+ return {}
+ def update_user_profile(*args, **kwargs):
+ return {'success': False, 'error': 'Profile utilities not available'}
+
+logger = logging.getLogger(__name__)
+
+profile_sections_bp = Blueprint('profile_sections', __name__)
+
+@profile_sections_bp.route('/basic', methods=['GET', 'POST'])
+@login_required
+def manage_basic_info():
+ """Manage basic profile information"""
+ try:
+ if request.method == 'GET':
+ profile_data = get_user_profile_data(current_user.id)
+ basic_info = {
+ 'first_name': profile_data.get('first_name', ''),
+ 'last_name': profile_data.get('last_name', ''),
+ 'email': profile_data.get('email', ''),
+ 'phone': profile_data.get('phone', ''),
+ 'location': profile_data.get('location', ''),
+ 'bio': profile_data.get('bio', '')
+ }
+
+ return jsonify({
+ 'success': True,
+ 'basic_info': basic_info
+ })
+
+ elif request.method == 'POST':
+ data = request.get_json() or {}
+
+ # Update basic information
+ result = update_user_profile(current_user.id, data)
+
+ return jsonify(result)
+
+ except Exception as e:
+ logger.error(f"Error managing basic info: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 500
+
+@profile_sections_bp.route('/professional', methods=['GET', 'POST'])
+@login_required
+def manage_professional_info():
+ """Manage professional profile information"""
+ try:
+ if request.method == 'GET':
+ profile_data = get_user_profile_data(current_user.id)
+ professional_info = {
+ 'professional_summary': profile_data.get('professional_summary', ''),
+ 'experience_level': profile_data.get('experience_level', ''),
+ 'current_company': profile_data.get('current_company', ''),
+ 'current_position': profile_data.get('current_position', ''),
+ 'industry': profile_data.get('industry', ''),
+ 'linkedin_url': profile_data.get('linkedin_url', ''),
+ 'portfolio_url': profile_data.get('portfolio_url', ''),
+ 'github_url': profile_data.get('github_url', '')
+ }
+
+ return jsonify({
+ 'success': True,
+ 'professional_info': professional_info
+ })
+
+ elif request.method == 'POST':
+ data = request.get_json() or {}
+
+ # Update professional information
+ result = update_user_profile(current_user.id, data)
+
+ return jsonify(result)
+
+ except Exception as e:
+ logger.error(f"Error managing professional info: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 500
+
+@profile_sections_bp.route('/education', methods=['GET', 'POST'])
+@login_required
+def manage_education():
+ """Manage education information"""
+ try:
+ if request.method == 'GET':
+ profile_data = get_user_profile_data(current_user.id)
+ education_info = {
+ 'education': profile_data.get('education', ''),
+ 'education_level': profile_data.get('education_level', ''),
+ 'field_of_study': profile_data.get('field_of_study', ''),
+ 'certifications': profile_data.get('certifications', [])
+ }
+
+ return jsonify({
+ 'success': True,
+ 'education_info': education_info
+ })
+
+ elif request.method == 'POST':
+ data = request.get_json() or {}
+
+ # Update education information
+ result = update_user_profile(current_user.id, data)
+
+ return jsonify(result)
+
+ except Exception as e:
+ logger.error(f"Error managing education: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 500
+
+@profile_sections_bp.route('/preferences', methods=['GET', 'POST'])
+@login_required
+def manage_job_preferences():
+ """Manage job preferences"""
+ try:
+ if request.method == 'GET':
+ profile_data = get_user_profile_data(current_user.id)
+ preferences = {
+ 'preferred_job_titles': profile_data.get('preferred_job_titles', []),
+ 'preferred_locations': profile_data.get('preferred_locations', []),
+ 'work_mode_preference': profile_data.get('work_mode_preference', ''),
+ 'desired_salary_range': profile_data.get('desired_salary_range', ''),
+ 'availability_date': profile_data.get('availability_date', ''),
+ 'willing_to_relocate': profile_data.get('willing_to_relocate', False)
+ }
+
+ return jsonify({
+ 'success': True,
+ 'preferences': preferences
+ })
+
+ elif request.method == 'POST':
+ data = request.get_json() or {}
+
+ # Update job preferences
+ result = update_user_profile(current_user.id, data)
+
+ return jsonify(result)
+
+ except Exception as e:
+ logger.error(f"Error managing preferences: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': str(e)
+ }), 500
\ No newline at end of file
diff --git a/backend/routes/profile/test.py b/backend/routes/profile/test.py
new file mode 100644
index 00000000..fb9ec1c9
--- /dev/null
+++ b/backend/routes/profile/test.py
@@ -0,0 +1,45 @@
+"""
+Test routes for debugging profile API issues
+"""
+from flask import Blueprint, jsonify, request
+from flask_login import current_user
+import traceback
+
+test_bp = Blueprint('test', __name__)
+
+@test_bp.route('/test', methods=['GET', 'OPTIONS'])
+def test_profile():
+ """Simple test endpoint to debug issues"""
+ try:
+ if request.method == 'OPTIONS':
+ response = jsonify({'status': 'ok'})
+ response.headers['Access-Control-Allow-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
+
+ print("✅ Test endpoint called successfully")
+ print(f"✅ Current user authenticated: {current_user.is_authenticated if hasattr(current_user, 'is_authenticated') else 'No current_user'}")
+ print(f"✅ Request method: {request.method}")
+ print(f"✅ Request path: {request.path}")
+
+ return jsonify({
+ 'success': True,
+ 'message': 'Test endpoint working',
+ 'authenticated': current_user.is_authenticated if hasattr(current_user, 'is_authenticated') else False,
+ 'user_id': getattr(current_user, 'id', None) if hasattr(current_user, 'id') else None
+ })
+
+ except Exception as e:
+ print("❌ ERROR in test endpoint:")
+ print(f"❌ Error type: {type(e).__name__}")
+ print(f"❌ Error message: {str(e)}")
+ print("❌ Full traceback:")
+ print(traceback.format_exc())
+
+ return jsonify({
+ 'success': False,
+ 'error': str(e),
+ 'error_type': type(e).__name__
+ }), 500
\ No newline at end of file
diff --git a/backend/routes/session.py b/backend/routes/session.py
index 93b3b31a..c01c915d 100644
--- a/backend/routes/session.py
+++ b/backend/routes/session.py
@@ -43,7 +43,6 @@ def track_request():
# Update the last activity time
user_activity[current_user.id] = now
-
@session_bp.route('/heartbeat', methods=['POST', 'OPTIONS'])
@login_required
def heartbeat():
@@ -83,7 +82,6 @@ def heartbeat():
return jsonify({'status': 'ok'})
-
@session_bp.route('/page-view', methods=['POST', 'OPTIONS'])
@login_required
def page_view():
@@ -121,7 +119,6 @@ def page_view():
return jsonify({'status': 'ok'})
-
@session_bp.route('/page-leave', methods=['POST', 'OPTIONS'])
@login_required
def page_leave():
diff --git a/backend/run_server.py b/backend/run_server.py
index 771285b2..f61809cc 100644
--- a/backend/run_server.py
+++ b/backend/run_server.py
@@ -1,6 +1,12 @@
#!/usr/bin/env python3
import os
-from backend.app import create_app
+try:
+ from app import create_app
+except ImportError:
+ try:
+ from app import create_app
+ except ImportError:
+ from backend.app import create_app
if __name__ == '__main__':
app = create_app()
diff --git a/backend/scripts/apply_for_user.py b/backend/scripts/apply_for_user.py
index 9d061593..0b01b84c 100755
--- a/backend/scripts/apply_for_user.py
+++ b/backend/scripts/apply_for_user.py
@@ -20,7 +20,13 @@
# Now import from the backend
from flask import Flask
from config import Config
-from backend.models.all_models import db, User, JobPosting
+try:
+ from models.all_models import db, User, JobPosting
+except ImportError:
+ try:
+ from models.all_models import db, User, JobPosting
+ except ImportError:
+ from backend.models.all_models import db, User, JobPosting
from utils.application_filler.core import ApplicationFiller
# Set up logging
diff --git a/backend/scripts/populate_keywords_db.py b/backend/scripts/populate_keywords_db.py
index 32cd3359..e09bcf8b 100644
--- a/backend/scripts/populate_keywords_db.py
+++ b/backend/scripts/populate_keywords_db.py
@@ -8,10 +8,34 @@
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-from backend.models.db import db
-from backend.models.job_keyword import JobKeyword
-from backend.models.base_models import resume_keywords_association
-from backend.app import create_app
+try:
+ from models.db import db
+except ImportError:
+ try:
+ from models.db import db
+ except ImportError:
+ from backend.models.db import db
+try:
+ from models.job_keyword import JobKeyword
+except ImportError:
+ try:
+ from models.job_keyword import JobKeyword
+ except ImportError:
+ from backend.models.job_keyword import JobKeyword
+try:
+ from models.base_models import resume_keywords_association
+except ImportError:
+ try:
+ from models.base_models import resume_keywords_association
+ except ImportError:
+ from backend.models.base_models import resume_keywords_association
+try:
+ from app import create_app
+except ImportError:
+ try:
+ from app import create_app
+ except ImportError:
+ from backend.app import create_app
def populate_keywords():
"""Populate the database with comprehensive keywords"""
diff --git a/backend/scripts/update_job_keywords.py b/backend/scripts/update_job_keywords.py
index 03f58872..46f7b594 100644
--- a/backend/scripts/update_job_keywords.py
+++ b/backend/scripts/update_job_keywords.py
@@ -10,7 +10,13 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
-from backend.models.job_posting import JobPosting
+try:
+ from models.job_posting import JobPosting
+except ImportError:
+ try:
+ from models.job_posting import JobPosting
+ except ImportError:
+ from backend.models.job_posting import JobPosting
from utils.job_search.keyword_extractor import JobKeywordExtractor
from config import get_settings
diff --git a/backend/scripts/update_user_role.py b/backend/scripts/update_user_role.py
index f19a8c6f..725f40d8 100755
--- a/backend/scripts/update_user_role.py
+++ b/backend/scripts/update_user_role.py
@@ -12,7 +12,13 @@
sys.path.insert(0, project_root)
from app import create_app
-from backend.models.all_models import User, ADMIN_ROLE, db
+try:
+ from models.all_models import User, ADMIN_ROLE, db
+except ImportError:
+ try:
+ from models.all_models import User, ADMIN_ROLE, db
+ except ImportError:
+ from backend.models.all_models import User, ADMIN_ROLE, db
def update_user_role(email, new_role=ADMIN_ROLE):
"""Update a user's role."""
diff --git a/backend/services/admin_job_search_service.py b/backend/services/admin_job_search_service.py
index 22fa0889..2a0137dc 100644
--- a/backend/services/admin_job_search_service.py
+++ b/backend/services/admin_job_search_service.py
@@ -1,345 +1,336 @@
-import asyncio
-from typing import List, Dict, Any, Optional
-from datetime import datetime
import logging
-from models.all_models import db, JobPosting, Company, JobRecommendation
+import json
+from datetime import datetime
+from typing import List, Dict, Any, Optional
+from flask import current_app
from utils.job_search.multi_api_manager import MultiAPIJobSearchManager
-from utils.job_recommenders.pipeline import JobPipelineManager, save_jobs_to_db
-from .job_search_service import JobSearchService
+from utils.job_recommenders.pipeline import (
+ save_jobs_to_db,
+ search_jobs,
+ get_latest_jobs,
+ get_jobs_stats,
+ refresh_jobs
+)
logger = logging.getLogger(__name__)
class AdminJobSearchService:
- """Service class for handling admin job search operations with multi-API support"""
+ """Service for admin job search operations"""
- @staticmethod
- def search_jobs_multi_api(
- query: str = '',
- location: str = '',
- target_jobs: int = 50,
- max_pages_per_api: int = 5,
- country: str = 'us',
- date_posted: str = 'week',
- employment_types: str = 'FULLTIME',
- source: Optional[str] = None,
- limit: int = 50,
- page: int = 1
- ) -> Dict[str, Any]:
- """
- Search for jobs using multiple APIs until target number is reached
-
- Args:
- query: Search query string
- location: Location to search in
- target_jobs: Target number of jobs to find
- max_pages_per_api: Maximum pages to search per API
- country: Country code to search in
- date_posted: Time range for posted jobs
- employment_types: Type of employment
- source: Optional source to filter by (for backward compatibility)
- limit: Number of results per page for response pagination
- page: Page number for response pagination
-
- Returns:
- Dictionary containing search results and metadata
- """
-
- logger.info(f"AdminJobSearchService.search_jobs_multi_api called:")
- logger.info(f" query: {query}, location: {location}, target_jobs: {target_jobs}")
- logger.info(f" max_pages_per_api: {max_pages_per_api}, limit: {limit}, page: {page}")
-
+ def __init__(self):
+ self.multi_api_manager = MultiAPIJobSearchManager()
+
+ def search_jobs_multi_api(self, query: str = "", location: str = "", limit: int = 50) -> Dict[str, Any]:
+ """Search jobs across multiple APIs"""
try:
- # Initialize multi-API manager
- manager = MultiAPIJobSearchManager()
-
- # Log initial API status
- logger.info("AdminJobSearchService: Checking API status before search:")
- for api in manager.apis:
- logger.info(f" {api.api_name}: is_available={api.is_available()}, status={api.get_status()}, can_make_request={api.can_make_request()}, last_error={api.get_last_error()}")
-
- # Use asyncio to run the async search
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
-
- try:
- # Search across multiple APIs until target is reached
- result = loop.run_until_complete(
- manager.search_jobs_until_target(
- query=query,
- target_jobs=target_jobs,
- location=location,
- max_pages_per_api=max_pages_per_api,
- results_per_page=10, # Internal page size for APIs
- country=country,
- date_posted=date_posted,
- employment_types=employment_types
- )
- )
-
- # Log result details
- logger.info(f"AdminJobSearchService: Search completed with {len(result.jobs)} jobs from APIs: {result.apis_used}")
- if result.errors:
- logger.info(f"AdminJobSearchService: Errors encountered: {result.errors}")
-
- finally:
- loop.close()
-
- # Convert JobSearchResult objects to dictionaries
- jobs_data = []
- for job in result.jobs:
- job_dict = job.to_dict()
- jobs_data.append(job_dict)
-
- # Apply response pagination
- start_idx = (page - 1) * limit
- end_idx = start_idx + limit
- paginated_jobs = jobs_data[start_idx:end_idx]
+ logger.info(f"Searching jobs with query: '{query}', location: '{location}', limit: {limit}")
+
+ # Search using multi-API manager (async method)
+ import asyncio
+ result = asyncio.run(self.multi_api_manager.search_jobs_until_target(
+ query=query,
+ location=location,
+ target_jobs=limit
+ ))
return {
'success': True,
- 'data': {
- 'jobs': paginated_jobs,
- 'total': result.total_found,
- 'page': page,
- 'limit': limit,
- 'total_pages': (result.total_found + limit - 1) // limit,
- 'query': query,
- 'location': location,
- 'target_jobs': target_jobs,
- 'target_reached': result.target_reached,
- 'search_completed': result.search_completed,
- 'apis_used': result.apis_used,
- 'errors': result.errors,
- 'country': country,
- 'date_posted': date_posted,
- 'employment_types': employment_types,
- # Enhanced cascading information
- 'cascade_info': {
- 'total_apis_available': len(manager.apis),
- 'apis_attempted': len(result.apis_used) + len(result.errors),
- 'apis_successful': len(result.apis_used),
- 'apis_failed': len(result.errors),
- 'cascade_successful': len(result.apis_used) > 1,
- 'search_strategy': 'cascading_multi_api',
- 'api_details': result.apis_used,
- 'error_details': result.errors,
- 'quota_issues': [
- error for error in result.errors
- if 'quota' in error.get('message', '').lower()
- ]
- }
- }
+ 'jobs': [job.__dict__ for job in result.jobs], # Convert JobSearchResult objects to dicts
+ 'count': len(result.jobs),
+ 'source': 'multi_api',
+ 'apis_used': result.apis_used,
+ 'total_found': result.total_found
}
except Exception as e:
- logger.error(f"Error in multi-API job search: {str(e)}")
+ logger.error(f"Error in multi-API job search: {e}")
return {
'success': False,
'error': str(e),
- 'data': {
- 'jobs': [],
- 'total': 0,
- 'page': page,
- 'limit': limit,
- 'total_pages': 0
- }
+ 'jobs': [],
+ 'count': 0
}
- @staticmethod
- def search_jobs(
- query: str = '',
- location: str = '',
- num_pages: int = 3,
- country: str = 'us',
- date_posted: str = 'week',
- employment_types: str = 'FULLTIME',
- source: Optional[str] = None,
- limit: int = 50,
- page: int = 1
- ) -> Dict[str, Any]:
- """
- Legacy search method - now uses multi-API search
- Kept for backward compatibility
- """
- return AdminJobSearchService.search_jobs_multi_api(
- query=query,
- location=location,
- target_jobs=limit * num_pages,
- max_pages_per_api=num_pages,
- country=country,
- date_posted=date_posted,
- employment_types=employment_types,
- source=source,
- limit=limit,
- page=page
- )
-
- @staticmethod
- def fetch_from_source(
- source: str,
- position: str = 'software engineer',
- location: str = 'Remote',
- country: str = 'US',
- max_items: int = 50
- ) -> Dict[str, Any]:
- """
- Fetch jobs using multi-API manager and save to database
-
- Args:
- source: Source preference (will try all available APIs)
- position: Job position to search for
- location: Location to search in
- country: Country code to search in
- max_items: Maximum number of items to fetch
-
- Returns:
- Dictionary containing fetch results and stats
- """
-
- logger.info(f"Admin fetching jobs with multi-API for position: {position}, location: {location}")
-
+ def search_jobs_database(self, query: str = "", location: str = "", limit: int = 50) -> Dict[str, Any]:
+ """Search jobs in the database"""
try:
- # Use multi-API search
- result = AdminJobSearchService.search_jobs_multi_api(
- query=position,
+ logger.info(f"Searching database with query: '{query}', location: '{location}', limit: {limit}")
+
+ jobs = search_jobs(
+ query=query,
location=location,
- target_jobs=max_items,
- max_pages_per_api=5,
- country=country.lower()
+ limit=limit,
+ app_context=current_app
)
- if result['success']:
- jobs_found = len(result['data']['jobs'])
- return {
- 'success': True,
- 'message': f'Found {jobs_found} jobs using multi-API search',
- 'stats': {
- 'found': jobs_found,
- 'apis_used': result['data'].get('apis_used', []),
- 'target_reached': result['data'].get('target_reached', False)
- }
- }
- else:
+ return {
+ 'success': True,
+ 'jobs': jobs,
+ 'count': len(jobs),
+ 'source': 'database'
+ }
+
+ except Exception as e:
+ logger.error(f"Error in database job search: {e}")
+ return {
+ 'success': False,
+ 'error': str(e),
+ 'jobs': [],
+ 'count': 0
+ }
+
+ def get_latest_jobs(self, limit: int = 20) -> Dict[str, Any]:
+ """Get latest jobs from database"""
+ try:
+ jobs = get_latest_jobs(
+ limit=limit,
+ app_context=current_app
+ )
+
+ return {
+ 'success': True,
+ 'jobs': jobs,
+ 'count': len(jobs)
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting latest jobs: {e}")
+ return {
+ 'success': False,
+ 'error': str(e),
+ 'jobs': [],
+ 'count': 0
+ }
+
+ def save_jobs_to_database(self, jobs: List[Dict]) -> Dict[str, Any]:
+ """Save jobs to database"""
+ try:
+ if not jobs:
return {
'success': False,
- 'message': f'Multi-API search failed: {result.get("error", "Unknown error")}',
- 'stats': {'found': 0, 'apis_used': []}
+ 'error': 'No jobs provided',
+ 'saved': 0,
+ 'updated': 0
}
-
+
+ logger.info(f"Saving {len(jobs)} jobs to database")
+
+ result = save_jobs_to_db(
+ jobs=jobs,
+ app_context=current_app
+ )
+
+ return {
+ 'success': True,
+ 'saved': result.get('saved', 0),
+ 'updated': result.get('updated', 0),
+ 'total_processed': result.get('total', 0),
+ 'keywords_extracted': result.get('keywords_extracted', 0)
+ }
+
except Exception as e:
- logger.error(f"Error in admin job fetch: {str(e)}")
- raise
+ logger.error(f"Error saving jobs to database: {e}")
+ return {
+ 'success': False,
+ 'error': str(e),
+ 'saved': 0,
+ 'updated': 0
+ }
- @staticmethod
- def delete_jobs(source: Optional[str] = None) -> Dict[str, Any]:
- """
- Delete jobs from database with admin privileges
-
- Args:
- source: Optional source to filter deletions by
-
- Returns:
- Dictionary containing deletion results
- """
+ def refresh_job_database(self, force_refresh: bool = False) -> Dict[str, Any]:
+ """Refresh job database from external APIs"""
try:
- manager = JobPipelineManager()
- deleted_count = manager.delete_jobs(source=source)
- manager.close()
+ logger.info(f"Refreshing job database (force_refresh: {force_refresh})")
+
+ result = refresh_jobs(
+ force_refresh=force_refresh,
+ app_context=current_app
+ )
+
+ return result
+
+ except Exception as e:
+ logger.error(f"Error refreshing job database: {e}")
+ return {
+ 'success': False,
+ 'error': str(e),
+ 'jobs_processed': 0,
+ 'jobs_saved': 0,
+ 'jobs_updated': 0
+ }
+
+ def get_job_statistics(self) -> Dict[str, Any]:
+ """Get job database statistics"""
+ try:
+ stats = get_jobs_stats(app_context=current_app)
- source_msg = f" from source '{source}'" if source else ""
return {
'success': True,
- 'message': f'Deleted {deleted_count} jobs{source_msg}',
+ 'stats': stats
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting job statistics: {e}")
+ return {
+ 'success': False,
+ 'error': str(e),
'stats': {
- 'deleted': deleted_count
+ 'total_jobs': 0,
+ 'active_jobs': 0,
+ 'today_jobs': 0,
+ 'inactive_jobs': 0
+ }
+ }
+
+ def search_and_save_jobs(self, query: str = "", location: str = "", limit: int = 50) -> Dict[str, Any]:
+ """Search jobs via API and save to database"""
+ try:
+ # Search jobs using multi-API
+ search_result = self.search_jobs_multi_api(
+ query=query,
+ location=location,
+ limit=limit
+ )
+
+ if not search_result['success'] or not search_result['jobs']:
+ return {
+ 'success': False,
+ 'error': 'No jobs found from API search',
+ 'search_result': search_result,
+ 'save_result': None
}
+
+ # Save jobs to database
+ save_result = self.save_jobs_to_database(search_result['jobs'])
+
+ return {
+ 'success': True,
+ 'search_result': search_result,
+ 'save_result': save_result,
+ 'total_jobs_found': search_result['count'],
+ 'jobs_saved': save_result.get('saved', 0),
+ 'jobs_updated': save_result.get('updated', 0)
}
except Exception as e:
- logger.error(f"Error in admin job deletion: {str(e)}")
- raise
+ logger.error(f"Error in search and save jobs: {e}")
+ return {
+ 'success': False,
+ 'error': str(e),
+ 'search_result': None,
+ 'save_result': None
+ }
- @staticmethod
- def search_jobs(query: str = '', company: str = '', location: str = '', limit: int = 50) -> List[JobPosting]:
- """
- Search jobs in the database by various criteria
-
- Args:
- query: Search query for title/description
- company: Company name to filter by
- location: Location to filter by
- limit: Maximum number of results
-
- Returns:
- List of JobPosting objects
- """
- logger.info(f"Searching database for jobs: query='{query}', company='{company}', location='{location}', limit={limit}")
-
+ def get_job_sources_info(self) -> Dict[str, Any]:
+ """Get information about available job sources"""
try:
- # Build query
- jobs_query = JobPosting.query
-
- # Filter by query (title/description)
- if query:
- jobs_query = jobs_query.filter(
- db.or_(
- JobPosting.title.ilike(f'%{query}%'),
- JobPosting.description.ilike(f'%{query}%')
- )
- )
-
- # Filter by company
- if company:
- jobs_query = jobs_query.join(Company).filter(
- Company.name.ilike(f'%{company}%')
- )
-
- # Filter by location
- if location:
- jobs_query = jobs_query.filter(
- JobPosting.location.ilike(f'%{location}%')
- )
-
- # Order by most recent first and limit results
- jobs = jobs_query.order_by(JobPosting.created_at.desc()).limit(limit).all()
-
- logger.info(f"Found {len(jobs)} jobs matching criteria")
- return jobs
+ # Get API manager info
+ api_info = self.multi_api_manager.get_api_status()
+
+ # Get database stats
+ db_stats = self.get_job_statistics()
+
+ return {
+ 'success': True,
+ 'api_sources': api_info,
+ 'database_stats': db_stats.get('stats', {}),
+ 'total_api_sources': len(api_info.get('apis', [])),
+ 'active_api_sources': len([api for api in api_info.get('apis', []) if api.get('active', False)])
+ }
except Exception as e:
- logger.error(f"Error searching jobs in database: {str(e)}")
- return []
-
- @staticmethod
- def get_sources() -> List[Dict[str, Any]]:
- """
- Get list of available job sources from multi-API manager
-
- Returns:
- List of source dictionaries with details
- """
+ logger.error(f"Error getting job sources info: {e}")
+ return {
+ 'success': False,
+ 'error': str(e),
+ 'api_sources': {},
+ 'database_stats': {}
+ }
+
+ def get_sources(self) -> List[Dict[str, Any]]:
+ """Get available job sources"""
try:
- manager = MultiAPIJobSearchManager()
- api_status = manager.get_api_status()
-
+ api_status = self.multi_api_manager.get_api_status()
sources = []
+
for api_name, status in api_status.items():
sources.append({
- 'name': api_name.lower().replace(' ', '_'),
- 'display_name': api_name,
- 'description': f'Jobs from {api_name} API',
- 'status': status['status'],
- 'priority': status['priority'],
- 'available': status['available']
+ 'name': api_name,
+ 'status': status.get('status', 'unknown'),
+ 'available': status.get('available', False),
+ 'priority': status.get('priority', 0)
})
return sources
except Exception as e:
- logger.error(f"Error getting job sources: {str(e)}")
- # Fallback to basic list
- return [{
- 'name': 'multi_api',
- 'display_name': 'Multi-API Search',
- 'description': 'Search across multiple job APIs'
- }]
+ logger.error(f"Error getting sources: {e}")
+ return []
+
+ def fetch_from_source(self, source: str, position: str, location: str, country: str = 'US', max_items: int = 50) -> Dict[str, Any]:
+ """Fetch jobs from a specific source"""
+ try:
+ # For now, just use the multi-API search since we don't have source-specific methods
+ result = self.search_jobs_multi_api(
+ query=position,
+ location=location,
+ limit=max_items
+ )
+
+ if result['success']:
+ return {
+ 'success': True,
+ 'jobs': result['jobs'],
+ 'count': result['count'],
+ 'source': source
+ }
+ else:
+ return result
+
+ except Exception as e:
+ logger.error(f"Error fetching from source {source}: {e}")
+ return {
+ 'success': False,
+ 'error': str(e),
+ 'jobs': [],
+ 'count': 0
+ }
+
+ def delete_jobs(self, source: str = None, job_ids: List[int] = None) -> Dict[str, Any]:
+ """Delete jobs from database"""
+ try:
+ from models.all_models import JobPosting
+ from models.db import db
+
+ deleted_count = 0
+
+ if job_ids:
+ # Delete specific jobs by ID
+ for job_id in job_ids:
+ job = JobPosting.query.get(job_id)
+ if job:
+ db.session.delete(job)
+ deleted_count += 1
+ db.session.commit()
+
+ elif source:
+ # Delete jobs by source
+ jobs_to_delete = JobPosting.query.filter_by(source=source).all()
+ for job in jobs_to_delete:
+ db.session.delete(job)
+ deleted_count += 1
+ db.session.commit()
+
+ return {
+ 'success': True,
+ 'deleted_count': deleted_count,
+ 'message': f'Deleted {deleted_count} jobs'
+ }
+
+ except Exception as e:
+ logger.error(f"Error deleting jobs: {e}")
+ return {
+ 'success': False,
+ 'error': str(e),
+ 'deleted_count': 0
+ }
diff --git a/backend/services/resume_keyword_service.py b/backend/services/resume_keyword_service.py
index c39ce87f..6fdb62c6 100644
--- a/backend/services/resume_keyword_service.py
+++ b/backend/services/resume_keyword_service.py
@@ -5,12 +5,41 @@
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
-
+try:
+ from models.db import db
+except ImportError:
+ try:
+ from models.db import db
+ except ImportError:
+ from backend.models.db import db
+try:
+ from models import User, JobPosting
+except ImportError:
+ try:
+ from models import User, JobPosting
+ except ImportError:
+ from backend.models import User, JobPosting
+try:
+ from models.job_keyword import JobKeyword, job_keywords_association
+except ImportError:
+ try:
+ from models.job_keyword import JobKeyword, job_keywords_association
+ except ImportError:
+ from backend.models.job_keyword import JobKeyword, job_keywords_association
+try:
+ from models.base_models import resume_keywords_association
+except ImportError:
+ try:
+ from models.base_models import resume_keywords_association
+ except ImportError:
+ from backend.models.base_models import resume_keywords_association
+try:
+ from utils.job_recommenders.enhanced_extractor import EnhancedKeywordExtractor
+except ImportError:
+ try:
+ from utils.job_recommenders.enhanced_extractor import EnhancedKeywordExtractor
+ except ImportError:
+ from backend.utils.job_recommenders.enhanced_extractor import EnhancedKeywordExtractor
class ResumeKeywordService:
"""Service for extracting and managing keywords from resumes"""
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 6eef4ca5..b6937c78 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -1,212 +1,253 @@
+"""
+Test configuration and fixtures for InstantApply backend tests
+"""
+
import pytest
-import asyncio
-from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
-from sqlalchemy.orm import sessionmaker
-from sqlalchemy.pool import StaticPool
-from models.base import Base
-from database import get_db_url
-from flask import Flask
-from flask_sqlalchemy import SQLAlchemy
-from flask_migrate import Migrate
import os
-import tempfile
-from datetime import datetime
-import logging
+import sys
+from pathlib import Path
-# Set TESTING environment variable
-os.environ['TESTING'] = 'true'
+# Add the backend directory to Python path for imports
+backend_dir = Path(__file__).parent.parent
+sys.path.insert(0, str(backend_dir))
-# Configure logging for tests
-logging.basicConfig(
- level=logging.DEBUG,
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
-)
-
-# Set aiosqlite logging to WARNING level to reduce noise in tests
-logging.getLogger('aiosqlite').setLevel(logging.WARNING)
+# Import Flask and other dependencies
+from flask import Flask
+from flask_login import LoginManager
-# Import your app factory and models
-from backend import create_app
-from models import db as _db
+# Flexible imports for different test environments
+try:
+ from app import create_app
+except ImportError:
+ try:
+ from backend.app import create_app
+ except ImportError:
+ def create_app(config_name='testing'):
+ """Fallback app factory for testing"""
+ app = Flask(__name__)
+ app.config['TESTING'] = True
+ app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
+ app.config['SECRET_KEY'] = 'test-secret-key'
+ return app
-class TestDatabase:
- """Test database manager"""
-
- def __init__(self):
- self.engine = create_async_engine(
- get_db_url(testing=True),
- connect_args={"check_same_thread": False},
- poolclass=StaticPool
- )
- self.async_session = sessionmaker(
- self.engine,
- class_=AsyncSession,
- expire_on_commit=False
- )
-
- async def init(self):
- """Initialize the test database"""
- async with self.engine.begin() as conn:
- await conn.run_sync(Base.metadata.drop_all)
- await conn.run_sync(Base.metadata.create_all)
-
- async def cleanup(self):
- """Clean up the test database"""
- async with self.engine.begin() as conn:
- await conn.run_sync(Base.metadata.drop_all)
-
- async def __aenter__(self):
- """Async context manager entry"""
- return self
-
- async def __aexit__(self, exc_type, exc_val, exc_tb):
- """Async context manager exit"""
- await self.cleanup()
-
- def session(self):
- """Get a test database session"""
- return self.async_session()
-
-@pytest.fixture(scope="session")
-def event_loop():
- """Create an instance of the default event loop for the test session"""
- loop = asyncio.get_event_loop_policy().new_event_loop()
- yield loop
- loop.close()
-
-@pytest.fixture(scope="session")
-async def test_db():
- """Create and initialize the test database"""
- db = TestDatabase()
- await db.init()
- yield db
- await db.cleanup()
+try:
+ from models.db import db
+except ImportError:
+ try:
+ from backend.models.db import db
+ except ImportError:
+ from flask_sqlalchemy import SQLAlchemy
+ db = SQLAlchemy()
-@pytest.fixture(autouse=True)
-async def cleanup_db(test_db):
- """Clean up the database after each test"""
- yield
- async with test_db.engine.begin() as conn:
- await conn.run_sync(Base.metadata.drop_all)
- await conn.run_sync(Base.metadata.create_all)
+try:
+ from models.all_models import User, JobPosting, Profile, Company
+except ImportError:
+ try:
+ from backend.models.all_models import User, JobPosting, Profile, Company
+ except ImportError:
+ # Define minimal model classes for testing if not available
+ class User:
+ def __init__(self, **kwargs):
+ for key, value in kwargs.items():
+ setattr(self, key, value)
+
+ class JobPosting:
+ def __init__(self, **kwargs):
+ for key, value in kwargs.items():
+ setattr(self, key, value)
+
+ class Profile:
+ def __init__(self, **kwargs):
+ for key, value in kwargs.items():
+ setattr(self, key, value)
+
+ class Company:
+ def __init__(self, **kwargs):
+ for key, value in kwargs.items():
+ setattr(self, key, value)
@pytest.fixture(scope='session')
def app():
"""Create application for the tests."""
- # Create a temporary directory for test databases
- temp_dir = tempfile.mkdtemp()
- db_path = os.path.join(temp_dir, 'test.db')
+ # Set testing environment
+ os.environ['FLASK_ENV'] = 'testing'
+ os.environ['DATABASE_URL'] = 'sqlite:///:memory:'
+
+ # Create app with testing configuration
+ app = create_app('testing')
- # Create the app with test configuration
- app = create_app({
+ # Override configuration for testing
+ app.config.update({
'TESTING': True,
- 'SQLALCHEMY_DATABASE_URI': f'sqlite:///{db_path}',
+ 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
'SQLALCHEMY_TRACK_MODIFICATIONS': False,
- 'WTF_CSRF_ENABLED': False, # Disable CSRF for testing
+ 'SECRET_KEY': 'test-secret-key',
+ 'WTF_CSRF_ENABLED': False,
+ 'LOGIN_DISABLED': True,
})
- # Create the database and ensure all models are properly registered
- with app.app_context():
- from models.registry import registry
- registry.finalize_registration() # Ensure all models are registered
- _db.create_all()
-
- yield app
-
- # Clean up
- with app.app_context():
- _db.session.remove()
- _db.drop_all()
-
- # Remove the temporary directory and its contents
- try:
- import shutil
- shutil.rmtree(temp_dir)
- except Exception as e:
- print(f"Warning: Failed to remove temporary test directory {temp_dir}: {e}")
-
-@pytest.fixture(scope='function')
-def db(app):
- """Create a fresh database for each test."""
+ # Create application context
with app.app_context():
- # Ensure models are registered
- from models.registry import registry
- registry.finalize_registration()
-
- # Drop all tables and recreate them
- _db.drop_all()
- _db.create_all()
-
- # Start a transaction
- connection = _db.engine.connect()
- transaction = connection.begin()
+ # Initialize database
+ try:
+ db.create_all()
+ except Exception as e:
+ print(f"Warning: Could not create database tables: {e}")
- # Create a session bound to the transaction
- session = _db.session
+ yield app
- yield _db
-
- # Roll back the transaction and close the connection
- transaction.rollback()
- connection.close()
- session.remove()
+ # Cleanup
+ try:
+ db.session.remove()
+ db.drop_all()
+ except Exception as e:
+ print(f"Warning: Could not cleanup database: {e}")
@pytest.fixture(scope='function')
-def client(app, db):
- """Create a test client for the app."""
+def client(app):
+ """Create a test client for the Flask application."""
return app.test_client()
@pytest.fixture(scope='function')
def runner(app):
- """Create a test CLI runner for the app."""
+ """Create a test runner for the Flask application's Click commands."""
return app.test_cli_runner()
-# Sample test data fixtures
+@pytest.fixture(scope='function')
+def db_session(app):
+ """Create a database session for testing."""
+ with app.app_context():
+ try:
+ db.create_all()
+ yield db.session
+ db.session.rollback()
+ db.session.remove()
+ except Exception as e:
+ print(f"Warning: Database session fixture error: {e}")
+ yield None
+
@pytest.fixture
-def sample_user(db):
+def sample_user(db_session):
"""Create a sample user for testing."""
- from models import User
- user = User(
- email='test@example.com',
- first_name='Test',
- last_name='User',
- is_active=True,
- is_verified=True,
- created_at=datetime.utcnow()
- )
- user.set_password('password123')
- db.session.add(user)
- db.session.commit()
- return user
+ try:
+ if User and db_session:
+ user = User(
+ name="Test User",
+ email="test@example.com",
+ password_hash="hashed_password",
+ is_verified=True
+ )
+ db_session.add(user)
+ db_session.commit()
+ return user
+ else:
+ # Return a mock user if database is not available
+ return User(
+ id=1,
+ name="Test User",
+ email="test@example.com",
+ password_hash="hashed_password",
+ is_verified=True
+ )
+ except Exception as e:
+ print(f"Warning: Could not create sample user: {e}")
+ return None
+
+@pytest.fixture
+def sample_job(db_session):
+ """Create a sample job posting for testing."""
+ try:
+ if JobPosting and db_session:
+ job = JobPosting(
+ title="Software Engineer",
+ company="Test Company",
+ location="Remote",
+ description="Test job description",
+ requirements="Python, Flask, Testing",
+ url="https://example.com/job/123",
+ status="active"
+ )
+ db_session.add(job)
+ db_session.commit()
+ return job
+ else:
+ # Return a mock job if database is not available
+ return JobPosting(
+ id=1,
+ title="Software Engineer",
+ company="Test Company",
+ location="Remote",
+ description="Test job description",
+ requirements="Python, Flask, Testing",
+ url="https://example.com/job/123",
+ status="active"
+ )
+ except Exception as e:
+ print(f"Warning: Could not create sample job: {e}")
+ return None
@pytest.fixture
-def sample_subscription_plan(db):
- """Create a sample subscription plan for testing."""
- from models import SubscriptionPlan
- plan = SubscriptionPlan(
- name='Test Plan',
- description='Test subscription plan',
- price=9.99,
- applications_included=5,
- duration_days=30,
- is_active=True
- )
- db.session.add(plan)
- db.session.commit()
- return plan
+def sample_company(db_session):
+ """Create a sample company for testing."""
+ try:
+ if Company and db_session:
+ company = Company(
+ name="Test Company",
+ website="https://testcompany.com",
+ description="A test company for testing purposes"
+ )
+ db_session.add(company)
+ db_session.commit()
+ return company
+ else:
+ # Return a mock company if database is not available
+ return Company(
+ id=1,
+ name="Test Company",
+ website="https://testcompany.com",
+ description="A test company for testing purposes"
+ )
+ except Exception as e:
+ print(f"Warning: Could not create sample company: {e}")
+ return None
@pytest.fixture
-def sample_job_posting(db):
- """Create a sample job posting for testing."""
- from models import JobPosting
- job = JobPosting(
- title='Test Job',
- company_id=1,
- description='Test job description',
- location='Test Location',
- job_type='FULL_TIME',
- created_at=datetime.utcnow()
- )
- db.session.add(job)
- db.session.commit()
- return job
\ No newline at end of file
+def auth_headers():
+ """Create authentication headers for API testing."""
+ return {
+ 'Content-Type': 'application/json',
+ 'Authorization': 'Bearer test-token'
+ }
+
+@pytest.fixture
+def mock_gemini_response():
+ """Mock response for Gemini API calls."""
+ return {
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {
+ "text": "Mock response from Gemini API"
+ }
+ ]
+ }
+ }
+ ]
+ }
+
+@pytest.fixture(autouse=True)
+def setup_test_environment():
+ """Automatically setup test environment for each test."""
+ # Set test environment variables
+ os.environ['TESTING'] = 'True'
+ os.environ['FLASK_ENV'] = 'testing'
+
+ # Disable external API calls during testing
+ os.environ['DISABLE_EXTERNAL_APIS'] = 'True'
+
+ yield
+
+ # Cleanup after test
+ if 'DISABLE_EXTERNAL_APIS' in os.environ:
+ del os.environ['DISABLE_EXTERNAL_APIS']
\ No newline at end of file
diff --git a/backend/update_user_role.py b/backend/update_user_role.py
deleted file mode 100644
index 2f86b35e..00000000
--- a/backend/update_user_role.py
+++ /dev/null
@@ -1,112 +0,0 @@
-import sqlite3
-import os
-from dotenv import load_dotenv
-import logging
-from pathlib import Path
-
-# Configure logging
-logging.basicConfig(level=logging.INFO)
-logger = logging.getLogger(__name__)
-
-# Load environment variables from project root
-project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-dotenv_path = os.path.join(project_root, '.env')
-load_dotenv(dotenv_path=dotenv_path)
-
-# Get database path from environment variables
-db_name = os.environ.get('DATABASE_NAME', 'instant_apply.db')
-db_url = os.environ.get('DATABASE_URL')
-
-# logger.info(f"Environment variables - DATABASE_NAME: {db_name}")
-# logger.info(f"Environment variables - DATABASE_URL: {db_url}")
-
-# Get the absolute path to the backend directory
-backend_dir = os.path.dirname(os.path.abspath(__file__))
-# logger.info(f"Backend directory: {backend_dir}")
-
-if db_url and db_url.startswith('sqlite:///'):
- # Extract path from SQLite URL
- db_path = db_url.replace('sqlite:///', '')
- # Remove 'backend/' prefix if it exists since we're already in the backend directory
- if db_path.startswith('backend/'):
- db_path = db_path[8:] # Remove 'backend/' prefix
- # Make path absolute relative to backend directory
- db_path = os.path.join(backend_dir, db_path)
- # logger.info(f"Using database path from DATABASE_URL: {db_path}")
-else:
- # Fallback to backend/instance directory
- instance_dir = os.path.join(backend_dir, 'instance')
- db_path = os.path.join(instance_dir, db_name)
- # logger.info(f"Using fallback database path: {db_path}")
-
-# Ensure the directory exists
-db_dir = os.path.dirname(db_path)
-try:
- os.makedirs(db_dir, exist_ok=True)
- # logger.info(f"Ensured database directory exists at: {db_dir}")
-except Exception as e:
- # logger.error(f"Failed to create database directory: {str(e)}")
- print(f"Error: Could not create database directory at {db_dir}")
- print(f"Error details: {str(e)}")
- exit(1)
-
-# Check if database file exists
-if os.path.exists(db_path):
- # logger.info(f"Database file exists at: {db_path}")
- # logger.info(f"Database file size: {os.path.getsize(db_path)} bytes")
- pass
-else:
- # logger.warning(f"Database file does not exist at: {db_path}")
- print(f"Warning: Database file does not exist at {db_path}")
- print("The script will attempt to create it when connecting.")
-
-# Check if we can write to the directory
-if not os.access(db_dir, os.W_OK):
- # logger.error(f"No write permission for database directory: {db_dir}")
- print(f"Error: No write permission for database directory: {db_dir}")
- print("Please check directory permissions")
- exit(1)
-
-# logger.info(f"Final database path being used: {db_path}")
-print(f"Using database at: {db_path}")
-
-email = 'admin@instantapply.tech'
-
-try:
- # Connect to the database
- # logger.info(f"Connecting to database at: {db_path}")
- conn = sqlite3.connect(db_path)
- cursor = conn.cursor()
-
- # Get current role
- cursor.execute("SELECT role FROM users WHERE email = ?", (email,))
- result = cursor.fetchone()
-
- if result:
- current_role = result[0]
- print(f"Current role: {current_role}")
-
- # Update role to admin
- cursor.execute("UPDATE users SET role = 'admin' WHERE email = ?", (email,))
- conn.commit()
-
- # Verify the update
- cursor.execute("SELECT role FROM users WHERE email = ?", (email,))
- new_role = cursor.fetchone()[0]
- print(f"Updated role to: {new_role}")
- print("User is now an admin")
- else:
- print(f"No user found with email {email}")
-
-except sqlite3.Error as e:
- # logger.error(f"Database error: {str(e)}")
- print(f"Database error: {str(e)}")
- print(f"Database path: {db_path}")
- print(f"Database directory: {db_dir}")
- print(f"Directory exists: {os.path.exists(db_dir)}")
- print(f"Directory writable: {os.access(db_dir, os.W_OK)}")
- exit(1)
-finally:
- if 'conn' in locals():
- conn.close()
- # logger.info("Database connection closed")
\ No newline at end of file
diff --git a/backend/utils/application_filler/core.py b/backend/utils/application_filler/core.py
index 825ae653..ae4db893 100644
--- a/backend/utils/application_filler/core.py
+++ b/backend/utils/application_filler/core.py
@@ -8,7 +8,13 @@
from typing import Dict, Any
from playwright.async_api import Page
-from backend.models.all_models import User
+try:
+ from models.all_models import User
+except ImportError:
+ try:
+ from models.all_models import User
+ except ImportError:
+ from backend.models.all_models import User
from .utils import valid_url, save_full_page_screenshot
from .resume_handler import prioritize_resume_upload, handle_resume_upload
from .form_detector import (
diff --git a/backend/utils/audit_logger.py b/backend/utils/audit_logger.py
index 5ebd483b..30281378 100644
--- a/backend/utils/audit_logger.py
+++ b/backend/utils/audit_logger.py
@@ -129,7 +129,6 @@ def decorated_function(*args, **kwargs):
return decorator
-
def log_page_view(page_name):
"""
Decorator specifically for logging page views by moderators.
diff --git a/backend/utils/auth.py b/backend/utils/auth.py
index db3d0d00..fac12d60 100644
--- a/backend/utils/auth.py
+++ b/backend/utils/auth.py
@@ -1,8 +1,20 @@
from functools import wraps
from flask import request, jsonify, abort
from flask_login import current_user
-from backend.models.all_models import USER_ROLE, MODERATOR_ROLE, ADMIN_ROLE, User
-from backend.models.audit import AuditLog, ActionCategory, ModeratorAction
+try:
+ from models.all_models import USER_ROLE, MODERATOR_ROLE, ADMIN_ROLE, User
+except ImportError:
+ try:
+ from models.all_models import USER_ROLE, MODERATOR_ROLE, ADMIN_ROLE, User
+ except ImportError:
+ from backend.models.all_models import USER_ROLE, MODERATOR_ROLE, ADMIN_ROLE, User
+try:
+ from models.audit import AuditLog, ActionCategory, ModeratorAction
+except ImportError:
+ try:
+ from models.audit import AuditLog, ActionCategory, ModeratorAction
+ except ImportError:
+ from backend.models.audit import AuditLog, ActionCategory, ModeratorAction
def role_required(*roles):
"""
diff --git a/backend/utils/email_service.py b/backend/utils/email_service.py
index 61783934..e066a60b 100644
--- a/backend/utils/email_service.py
+++ b/backend/utils/email_service.py
@@ -165,7 +165,6 @@