diff --git a/.gitignore b/.gitignore index a5f6005..1e95414 100644 --- a/.gitignore +++ b/.gitignore @@ -162,10 +162,16 @@ Thumbs.db # User data and uploads user_data/ uploads/ +documents/ # AI model cache .cache/ models/ +ai_cache/ +embeddings_cache/ + +# Database volumes +postgres_data/ # Spyder project settings .spyderproject diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..13111ef --- /dev/null +++ b/Dockerfile.backend @@ -0,0 +1,28 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install Python dependencies +COPY requirements-backend.txt . +RUN pip install --no-cache-dir -r requirements-backend.txt + +# Copy source code +COPY src/ ./src/ + +# Create non-root user +RUN useradd -m -u 1000 jobforge && chown -R jobforge:jobforge /app +USER jobforge + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +EXPOSE 8000 + +CMD ["uvicorn", "src.backend.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..952db98 --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install Python dependencies +COPY requirements-frontend.txt . +RUN pip install --no-cache-dir -r requirements-frontend.txt + +# Copy source code +COPY src/ ./src/ + +# Create non-root user +RUN useradd -m -u 1000 jobforge && chown -R jobforge:jobforge /app +USER jobforge + +EXPOSE 8501 + +CMD ["python", "src/frontend/main.py"] \ No newline at end of file diff --git a/requirements-frontend.txt b/requirements-frontend.txt index 902bc64..5ae5d81 100644 --- a/requirements-frontend.txt +++ b/requirements-frontend.txt @@ -11,11 +11,15 @@ httpx==0.27.0 pandas==2.2.1 plotly==5.18.0 +# File handling +Pillow==10.2.0 + # Utilities python-dotenv==1.0.1 structlog==24.1.0 # Development pytest==8.0.2 +pytest-dash==2.5.0 black==24.2.0 isort==5.13.2 \ No newline at end of file diff --git a/src/backend/__init__.py b/src/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/api/__init__.py b/src/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/api/applications.py b/src/backend/api/applications.py new file mode 100644 index 0000000..65ac848 --- /dev/null +++ b/src/backend/api/applications.py @@ -0,0 +1,205 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel +from typing import List, Optional +from datetime import datetime + +from ..core.database import get_db +from ..models.user import User +from ..models.application import Application, ApplicationStatus +from ..models.job import Job +from .auth import get_current_user + +router = APIRouter() + +class ApplicationCreate(BaseModel): + job_id: int + notes: Optional[str] = None + +class ApplicationUpdate(BaseModel): + status: Optional[ApplicationStatus] = None + notes: Optional[str] = None + applied_date: Optional[datetime] = None + follow_up_date: Optional[datetime] = None + +class ApplicationResponse(BaseModel): + id: int + job_id: int + status: ApplicationStatus + notes: Optional[str] + applied_date: Optional[datetime] + follow_up_date: Optional[datetime] + created_at: datetime + + class Config: + from_attributes = True + +class ApplicationWithJobResponse(ApplicationResponse): + job_title: str + company: str + location: Optional[str] + +@router.post("/", response_model=ApplicationResponse) +async def create_application( + application_data: ApplicationCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + # Verify job exists + job = await db.get(Job, application_data.job_id) + if not job: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Job not found" + ) + + # Check if application already exists + existing = await db.execute( + select(Application).where( + Application.user_id == current_user.id, + Application.job_id == application_data.job_id + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Application already exists for this job" + ) + + application = Application( + user_id=current_user.id, + job_id=application_data.job_id, + notes=application_data.notes + ) + + db.add(application) + await db.commit() + await db.refresh(application) + + return ApplicationResponse.from_orm(application) + +@router.get("/", response_model=List[ApplicationWithJobResponse]) +async def get_applications( + status: Optional[ApplicationStatus] = None, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + query = select(Application, Job).join(Job).where(Application.user_id == current_user.id) + + if status: + query = query.where(Application.status == status) + + result = await db.execute(query) + applications = [] + + for app, job in result.all(): + app_dict = { + "id": app.id, + "job_id": app.job_id, + "status": app.status, + "notes": app.notes, + "applied_date": app.applied_date, + "follow_up_date": app.follow_up_date, + "created_at": app.created_at, + "job_title": job.title, + "company": job.company, + "location": job.location + } + applications.append(ApplicationWithJobResponse(**app_dict)) + + return applications + +@router.get("/{application_id}", response_model=ApplicationWithJobResponse) +async def get_application( + application_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + result = await db.execute( + select(Application, Job) + .join(Job) + .where( + Application.id == application_id, + Application.user_id == current_user.id + ) + ) + app_job = result.first() + + if not app_job: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Application not found" + ) + + app, job = app_job + return ApplicationWithJobResponse( + id=app.id, + job_id=app.job_id, + status=app.status, + notes=app.notes, + applied_date=app.applied_date, + follow_up_date=app.follow_up_date, + created_at=app.created_at, + job_title=job.title, + company=job.company, + location=job.location + ) + +@router.put("/{application_id}", response_model=ApplicationResponse) +async def update_application( + application_id: int, + update_data: ApplicationUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + application = await db.execute( + select(Application).where( + Application.id == application_id, + Application.user_id == current_user.id + ) + ) + application = application.scalar_one_or_none() + + if not application: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Application not found" + ) + + update_dict = update_data.dict(exclude_unset=True) + if update_dict.get("status") == ApplicationStatus.APPLIED and not application.applied_date: + update_dict["applied_date"] = datetime.utcnow() + + for field, value in update_dict.items(): + setattr(application, field, value) + + await db.commit() + await db.refresh(application) + + return ApplicationResponse.from_orm(application) + +@router.delete("/{application_id}") +async def delete_application( + application_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + application = await db.execute( + select(Application).where( + Application.id == application_id, + Application.user_id == current_user.id + ) + ) + application = application.scalar_one_or_none() + + if not application: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Application not found" + ) + + await db.delete(application) + await db.commit() + + return {"message": "Application deleted successfully"} \ No newline at end of file diff --git a/src/backend/api/auth.py b/src/backend/api/auth.py new file mode 100644 index 0000000..f21a789 --- /dev/null +++ b/src/backend/api/auth.py @@ -0,0 +1,130 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel, EmailStr +from typing import Optional +import bcrypt +import jwt +from datetime import datetime, timedelta + +from ..core.database import get_db +from ..core.config import settings +from ..models.user import User + +router = APIRouter() +security = HTTPBearer() + +class UserCreate(BaseModel): + email: EmailStr + password: str + first_name: str + last_name: str + phone: Optional[str] = None + +class UserLogin(BaseModel): + email: EmailStr + password: str + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + +class UserResponse(BaseModel): + id: int + email: str + first_name: str + last_name: str + is_active: bool + + class Config: + from_attributes = True + +def create_access_token(data: dict): + to_encode = data.copy() + expire = datetime.utcnow() + timedelta(minutes=settings.jwt_expire_minutes) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8')) + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: AsyncSession = Depends(get_db) +): + try: + payload = jwt.decode( + credentials.credentials, + settings.jwt_secret_key, + algorithms=[settings.jwt_algorithm] + ) + user_id: int = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials" + ) + except jwt.PyJWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials" + ) + + user = await db.get(User, user_id) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found" + ) + return user + +@router.post("/register", response_model=UserResponse) +async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)): + # Check if user exists + existing_user = await db.execute( + User.__table__.select().where(User.email == user_data.email) + ) + if existing_user.first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + # Create new user + hashed_pwd = hash_password(user_data.password) + user = User( + email=user_data.email, + hashed_password=hashed_pwd, + first_name=user_data.first_name, + last_name=user_data.last_name, + phone=user_data.phone + ) + + db.add(user) + await db.commit() + await db.refresh(user) + + return UserResponse.from_orm(user) + +@router.post("/login", response_model=Token) +async def login(login_data: UserLogin, db: AsyncSession = Depends(get_db)): + user_result = await db.execute( + User.__table__.select().where(User.email == login_data.email) + ) + user_row = user_result.first() + + if not user_row or not verify_password(login_data.password, user_row.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password" + ) + + access_token = create_access_token(data={"sub": str(user_row.id)}) + return Token(access_token=access_token) + +@router.get("/me", response_model=UserResponse) +async def get_current_user_info(current_user: User = Depends(get_current_user)): + return UserResponse.from_orm(current_user) \ No newline at end of file diff --git a/src/backend/api/documents.py b/src/backend/api/documents.py new file mode 100644 index 0000000..a7ee178 --- /dev/null +++ b/src/backend/api/documents.py @@ -0,0 +1,184 @@ +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel +from typing import List, Optional +import structlog + +from ..core.database import get_db +from ..models.user import User +from ..models.document import Document, DocumentType +from .auth import get_current_user + +logger = structlog.get_logger() +router = APIRouter() + +class DocumentResponse(BaseModel): + id: int + filename: str + document_type: DocumentType + file_size: Optional[int] + ai_generated: str + created_at: str + + class Config: + from_attributes = True + +class DocumentCreate(BaseModel): + filename: str + document_type: DocumentType + text_content: Optional[str] = None + +@router.post("/upload", response_model=DocumentResponse) +async def upload_document( + file: UploadFile = File(...), + document_type: DocumentType = DocumentType.OTHER, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + try: + file_content = await file.read() + + # Basic file validation + if len(file_content) > 10 * 1024 * 1024: # 10MB limit + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail="File too large. Maximum size is 10MB." + ) + + document = Document( + user_id=current_user.id, + filename=file.filename or "uploaded_file", + original_filename=file.filename, + document_type=document_type, + file_size=len(file_content), + mime_type=file.content_type, + file_content=file_content, + ai_generated="false" + ) + + db.add(document) + await db.commit() + await db.refresh(document) + + logger.info("Document uploaded", + user_id=current_user.id, + document_id=document.id, + filename=file.filename) + + return DocumentResponse.from_orm(document) + + except Exception as e: + logger.error("Document upload failed", error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to upload document" + ) + +@router.get("/", response_model=List[DocumentResponse]) +async def get_documents( + document_type: Optional[DocumentType] = None, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + query = select(Document).where(Document.user_id == current_user.id) + + if document_type: + query = query.where(Document.document_type == document_type) + + query = query.order_by(Document.created_at.desc()) + + result = await db.execute(query) + documents = result.scalars().all() + + return [DocumentResponse.from_orm(doc) for doc in documents] + +@router.get("/{document_id}", response_model=DocumentResponse) +async def get_document( + document_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + document = await db.execute( + select(Document).where( + Document.id == document_id, + Document.user_id == current_user.id + ) + ) + document = document.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + return DocumentResponse.from_orm(document) + +@router.delete("/{document_id}") +async def delete_document( + document_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + document = await db.execute( + select(Document).where( + Document.id == document_id, + Document.user_id == current_user.id + ) + ) + document = document.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + await db.delete(document) + await db.commit() + + logger.info("Document deleted", + user_id=current_user.id, + document_id=document_id) + + return {"message": "Document deleted successfully"} + +@router.post("/generate-cover-letter") +async def generate_cover_letter( + job_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + # Placeholder for AI cover letter generation + # In full implementation, this would use Claude/OpenAI APIs + + cover_letter_content = f""" +Dear Hiring Manager, + +I am writing to express my interest in the position at your company. +[AI-generated content would be here based on job requirements and user profile] + +Best regards, +{current_user.full_name} +""" + + document = Document( + user_id=current_user.id, + filename=f"cover_letter_job_{job_id}.txt", + document_type=DocumentType.COVER_LETTER, + text_content=cover_letter_content, + ai_generated="true", + ai_model_used="claude-3", + generation_prompt=f"Generate cover letter for job ID {job_id}" + ) + + db.add(document) + await db.commit() + await db.refresh(document) + + return { + "message": "Cover letter generated successfully", + "document_id": document.id, + "content": cover_letter_content + } \ No newline at end of file diff --git a/src/backend/api/jobs.py b/src/backend/api/jobs.py new file mode 100644 index 0000000..b56e52f --- /dev/null +++ b/src/backend/api/jobs.py @@ -0,0 +1,143 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from pydantic import BaseModel +from typing import List, Optional +from datetime import datetime + +from ..core.database import get_db +from ..models.user import User +from ..models.job import Job +from .auth import get_current_user + +router = APIRouter() + +class JobCreate(BaseModel): + title: str + company: str + location: Optional[str] = None + salary_min: Optional[int] = None + salary_max: Optional[int] = None + remote_option: bool = False + description: str + requirements: Optional[str] = None + benefits: Optional[str] = None + source_url: Optional[str] = None + source_platform: Optional[str] = None + posted_date: Optional[datetime] = None + +class JobResponse(BaseModel): + id: int + title: str + company: str + location: Optional[str] + salary_min: Optional[int] + salary_max: Optional[int] + remote_option: bool + description: str + requirements: Optional[str] + benefits: Optional[str] + source_url: Optional[str] + posted_date: Optional[datetime] + created_at: datetime + + class Config: + from_attributes = True + +class JobSearchResponse(BaseModel): + id: int + title: str + company: str + location: Optional[str] + salary_min: Optional[int] + salary_max: Optional[int] + remote_option: bool + description: str + match_score: Optional[float] + posted_date: Optional[datetime] + + class Config: + from_attributes = True + +@router.post("/", response_model=JobResponse) +async def create_job( + job_data: JobCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + job = Job(**job_data.dict()) + db.add(job) + await db.commit() + await db.refresh(job) + + return JobResponse.from_orm(job) + +@router.get("/", response_model=List[JobSearchResponse]) +async def search_jobs( + q: Optional[str] = Query(None, description="Search query"), + location: Optional[str] = Query(None, description="Location filter"), + remote: Optional[bool] = Query(None, description="Remote work filter"), + salary_min: Optional[int] = Query(None, description="Minimum salary"), + company: Optional[str] = Query(None, description="Company filter"), + limit: int = Query(20, ge=1, le=100), + offset: int = Query(0, ge=0), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + query = select(Job).where(Job.is_active == True) + + if q: + search_filter = func.lower(Job.title).contains(q.lower()) | \ + func.lower(Job.description).contains(q.lower()) | \ + func.lower(Job.company).contains(q.lower()) + query = query.where(search_filter) + + if location: + query = query.where(func.lower(Job.location).contains(location.lower())) + + if remote is not None: + query = query.where(Job.remote_option == remote) + + if salary_min: + query = query.where(Job.salary_min >= salary_min) + + if company: + query = query.where(func.lower(Job.company).contains(company.lower())) + + query = query.order_by(Job.posted_date.desc().nullslast(), Job.created_at.desc()) + query = query.offset(offset).limit(limit) + + result = await db.execute(query) + jobs = result.scalars().all() + + return [JobSearchResponse.from_orm(job) for job in jobs] + +@router.get("/{job_id}", response_model=JobResponse) +async def get_job( + job_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + job = await db.get(Job, job_id) + if not job or not job.is_active: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Job not found" + ) + + return JobResponse.from_orm(job) + +@router.get("/recommendations/") +async def get_job_recommendations( + limit: int = Query(10, ge=1, le=50), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + # For now, return recent jobs sorted by created date + # In a full implementation, this would use AI matching based on user profile + query = select(Job).where(Job.is_active == True).order_by(Job.created_at.desc()).limit(limit) + + result = await db.execute(query) + jobs = result.scalars().all() + + return [JobSearchResponse.from_orm(job) for job in jobs] \ No newline at end of file diff --git a/src/backend/core/__init__.py b/src/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/core/config.py b/src/backend/core/config.py new file mode 100644 index 0000000..9f04708 --- /dev/null +++ b/src/backend/core/config.py @@ -0,0 +1,30 @@ +from pydantic_settings import BaseSettings +from pydantic import Field +import os + +class Settings(BaseSettings): + database_url: str = Field( + default="postgresql+asyncpg://jobforge_user:jobforge_password@localhost:5432/jobforge_mvp", + env="DATABASE_URL" + ) + + claude_api_key: str = Field(env="CLAUDE_API_KEY") + openai_api_key: str = Field(env="OPENAI_API_KEY") + jwt_secret_key: str = Field(env="JWT_SECRET_KEY") + + debug: bool = Field(default=False, env="DEBUG") + log_level: str = Field(default="INFO", env="LOG_LEVEL") + + jwt_algorithm: str = "HS256" + jwt_expire_minutes: int = 60 * 24 * 7 # 7 days + + cors_origins: list[str] = [ + "http://localhost:8501", + "http://frontend:8501" + ] + + class Config: + env_file = ".env" + case_sensitive = False + +settings = Settings() \ No newline at end of file diff --git a/src/backend/core/database.py b/src/backend/core/database.py new file mode 100644 index 0000000..36cef58 --- /dev/null +++ b/src/backend/core/database.py @@ -0,0 +1,49 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy import text +import structlog + +from .config import settings + +logger = structlog.get_logger() + +class Base(DeclarativeBase): + pass + +engine = create_async_engine( + settings.database_url, + echo=settings.debug, + pool_pre_ping=True, + pool_recycle=300 +) + +AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False +) + +async def get_db(): + async with AsyncSessionLocal() as session: + try: + yield session + except Exception: + await session.rollback() + raise + finally: + await session.close() + +async def init_db(): + try: + async with engine.begin() as conn: + await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) + logger.info("Database extensions initialized") + + from ..models import user, application, job, document + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + logger.info("Database tables created") + + except Exception as e: + logger.error("Database initialization failed", error=str(e)) + raise \ No newline at end of file diff --git a/src/backend/main.py b/src/backend/main.py new file mode 100644 index 0000000..9a07770 --- /dev/null +++ b/src/backend/main.py @@ -0,0 +1,63 @@ +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +import structlog +from contextlib import asynccontextmanager + +from .core.config import settings +from .core.database import init_db +from .api import auth, applications, jobs, documents + +logger = structlog.get_logger() + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("Starting Job Forge backend...") + await init_db() + logger.info("Database initialized") + yield + logger.info("Shutting down Job Forge backend...") + +app = FastAPI( + title="Job Forge API", + description="AI-Powered Job Application Assistant", + version="1.0.0", + lifespan=lifespan +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:8501", "http://frontend:8501"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +@app.get("/health") +async def health_check(): + return {"status": "healthy", "service": "job-forge-backend"} + +@app.get("/") +async def root(): + return {"message": "Job Forge API", "version": "1.0.0"} + +app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"]) +app.include_router(applications.router, prefix="/api/applications", tags=["Applications"]) +app.include_router(jobs.router, prefix="/api/jobs", tags=["Jobs"]) +app.include_router(documents.router, prefix="/api/documents", tags=["Documents"]) + +@app.exception_handler(HTTPException) +async def http_exception_handler(request, exc): + logger.error("HTTP exception", status_code=exc.status_code, detail=exc.detail) + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail} + ) + +@app.exception_handler(Exception) +async def general_exception_handler(request, exc): + logger.error("Unhandled exception", error=str(exc)) + return JSONResponse( + status_code=500, + content={"detail": "Internal server error"} + ) \ No newline at end of file diff --git a/src/frontend/__init__.py b/src/frontend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/frontend/callbacks.py b/src/frontend/callbacks.py new file mode 100644 index 0000000..1f64c4c --- /dev/null +++ b/src/frontend/callbacks.py @@ -0,0 +1,235 @@ +from dash import Input, Output, State, callback, clientside_callback +import dash_mantine_components as dmc +import httpx +import structlog + +from .pages.home import create_home_page +from .pages.auth import create_login_page + +logger = structlog.get_logger() + +def register_callbacks(app, config): + @app.callback( + Output("page-content", "children"), + Output("header-actions", "children"), + Input("url", "pathname"), + State("auth-store", "data") + ) + def display_page(pathname, auth_data): + # Check if user is authenticated + is_authenticated = auth_data and auth_data.get("token") + + if not is_authenticated: + # Show login page for unauthenticated users + if pathname == "/login" or pathname is None or pathname == "/": + return create_login_page(), [] + else: + return create_login_page(), [] + + # Authenticated user navigation + header_actions = [ + dmc.Button( + "Logout", + id="logout-btn", + variant="outline", + color="red", + leftIcon="tabler:logout" + ) + ] + + # Route to different pages + if pathname == "/" or pathname is None: + return create_home_page(), header_actions + elif pathname == "/jobs": + return create_jobs_page(), header_actions + elif pathname == "/applications": + return create_applications_page(), header_actions + elif pathname == "/documents": + return create_documents_page(), header_actions + elif pathname == "/profile": + return create_profile_page(), header_actions + else: + return create_home_page(), header_actions + + @app.callback( + Output("auth-store", "data"), + Output("auth-alerts", "children"), + Input("login-submit", "n_clicks"), + State("login-email", "value"), + State("login-password", "value"), + prevent_initial_call=True + ) + def handle_login(n_clicks, email, password): + if not n_clicks or not email or not password: + return None, [] + + try: + response = httpx.post( + f"{config.auth_url}/login", + json={"email": email, "password": password}, + timeout=10.0 + ) + + if response.status_code == 200: + token_data = response.json() + auth_data = { + "token": token_data["access_token"], + "email": email + } + + success_alert = dmc.Alert( + "Login successful! Redirecting...", + title="Success", + color="green", + duration=3000 + ) + + return auth_data, success_alert + else: + error_alert = dmc.Alert( + "Invalid email or password", + title="Login Failed", + color="red" + ) + return None, error_alert + + except Exception as e: + logger.error("Login error", error=str(e)) + error_alert = dmc.Alert( + "Connection error. Please try again.", + title="Error", + color="red" + ) + return None, error_alert + + @app.callback( + Output("auth-store", "data", allow_duplicate=True), + Output("auth-alerts", "children", allow_duplicate=True), + Input("register-submit", "n_clicks"), + State("register-email", "value"), + State("register-password", "value"), + State("register-password-confirm", "value"), + State("register-first-name", "value"), + State("register-last-name", "value"), + State("register-phone", "value"), + prevent_initial_call=True + ) + def handle_register(n_clicks, email, password, password_confirm, first_name, last_name, phone): + if not n_clicks: + return None, [] + + # Validation + if not all([email, password, first_name, last_name]): + error_alert = dmc.Alert( + "All required fields must be filled", + title="Validation Error", + color="red" + ) + return None, error_alert + + if password != password_confirm: + error_alert = dmc.Alert( + "Passwords do not match", + title="Validation Error", + color="red" + ) + return None, error_alert + + try: + user_data = { + "email": email, + "password": password, + "first_name": first_name, + "last_name": last_name + } + if phone: + user_data["phone"] = phone + + response = httpx.post( + f"{config.auth_url}/register", + json=user_data, + timeout=10.0 + ) + + if response.status_code == 200: + # Auto-login after successful registration + login_response = httpx.post( + f"{config.auth_url}/login", + json={"email": email, "password": password}, + timeout=10.0 + ) + + if login_response.status_code == 200: + token_data = login_response.json() + auth_data = { + "token": token_data["access_token"], + "email": email + } + + success_alert = dmc.Alert( + "Registration successful! Welcome to Job Forge!", + title="Success", + color="green", + duration=3000 + ) + + return auth_data, success_alert + + error_alert = dmc.Alert( + "Registration failed. Email may already be in use.", + title="Registration Failed", + color="red" + ) + return None, error_alert + + except Exception as e: + logger.error("Registration error", error=str(e)) + error_alert = dmc.Alert( + "Connection error. Please try again.", + title="Error", + color="red" + ) + return None, error_alert + + @app.callback( + Output("auth-store", "clear_data"), + Input("logout-btn", "n_clicks"), + prevent_initial_call=True + ) + def handle_logout(n_clicks): + if n_clicks: + return True + return False + +# Placeholder functions for other pages +def create_jobs_page(): + return dmc.Container( + children=[ + dmc.Title("Job Search", mb="lg"), + dmc.Text("Job search functionality coming soon...") + ] + ) + +def create_applications_page(): + return dmc.Container( + children=[ + dmc.Title("My Applications", mb="lg"), + dmc.Text("Application tracking functionality coming soon...") + ] + ) + +def create_documents_page(): + return dmc.Container( + children=[ + dmc.Title("Documents", mb="lg"), + dmc.Text("Document management functionality coming soon...") + ] + ) + +def create_profile_page(): + return dmc.Container( + children=[ + dmc.Title("Profile", mb="lg"), + dmc.Text("Profile management functionality coming soon...") + ] + ) \ No newline at end of file diff --git a/src/frontend/config.py b/src/frontend/config.py new file mode 100644 index 0000000..21268a4 --- /dev/null +++ b/src/frontend/config.py @@ -0,0 +1,27 @@ +import os +from typing import Optional + +class Config: + def __init__(self): + self.BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000") + self.DEBUG = os.getenv("DEBUG", "false").lower() == "true" + + @property + def api_base_url(self) -> str: + return f"{self.BACKEND_URL}/api" + + @property + def auth_url(self) -> str: + return f"{self.api_base_url}/auth" + + @property + def applications_url(self) -> str: + return f"{self.api_base_url}/applications" + + @property + def jobs_url(self) -> str: + return f"{self.api_base_url}/jobs" + + @property + def documents_url(self) -> str: + return f"{self.api_base_url}/documents" \ No newline at end of file diff --git a/src/frontend/layouts/__init__.py b/src/frontend/layouts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/frontend/layouts/layout.py b/src/frontend/layouts/layout.py new file mode 100644 index 0000000..e22bfb2 --- /dev/null +++ b/src/frontend/layouts/layout.py @@ -0,0 +1,121 @@ +from dash import html, dcc +import dash_mantine_components as dmc +from dash_iconify import DashIconify + +def create_layout(): + return dmc.MantineProvider( + theme={ + "fontFamily": "'Inter', sans-serif", + "primaryColor": "blue", + "components": { + "Button": {"styles": {"root": {"fontWeight": 400}}}, + "Alert": {"styles": {"title": {"fontWeight": 500}}}, + "AvatarGroup": {"styles": {"truncated": {"fontWeight": 500}}}, + }, + }, + children=[ + dcc.Store(id="auth-store", storage_type="session"), + dcc.Store(id="user-store", storage_type="session"), + dcc.Location(id="url", refresh=False), + + html.Div( + id="main-content", + children=[ + create_header(), + html.Div(id="page-content") + ] + ) + ] + ) + +def create_header(): + return dmc.Header( + height=70, + fixed=True, + children=[ + dmc.Container( + size="xl", + children=[ + dmc.Group( + position="apart", + align="center", + style={"height": 70}, + children=[ + dmc.Group( + align="center", + spacing="xs", + children=[ + DashIconify( + icon="tabler:briefcase", + width=32, + color="#228BE6" + ), + dmc.Text( + "Job Forge", + size="xl", + weight=700, + color="blue" + ) + ] + ), + + dmc.Group( + id="header-actions", + spacing="md", + children=[ + dmc.Button( + "Login", + id="login-btn", + variant="outline", + leftIcon=DashIconify(icon="tabler:login") + ) + ] + ) + ] + ) + ] + ) + ] + ) + +def create_navigation(): + return dmc.Navbar( + width={"base": 300}, + children=[ + dmc.ScrollArea( + style={"height": "calc(100vh - 70px)"}, + children=[ + dmc.NavLink( + label="Dashboard", + icon=DashIconify(icon="tabler:dashboard"), + href="/", + id="nav-dashboard" + ), + dmc.NavLink( + label="Job Search", + icon=DashIconify(icon="tabler:search"), + href="/jobs", + id="nav-jobs" + ), + dmc.NavLink( + label="Applications", + icon=DashIconify(icon="tabler:briefcase"), + href="/applications", + id="nav-applications" + ), + dmc.NavLink( + label="Documents", + icon=DashIconify(icon="tabler:file-text"), + href="/documents", + id="nav-documents" + ), + dmc.NavLink( + label="Profile", + icon=DashIconify(icon="tabler:user"), + href="/profile", + id="nav-profile" + ) + ] + ) + ] + ) \ No newline at end of file diff --git a/src/frontend/main.py b/src/frontend/main.py new file mode 100644 index 0000000..0deea4b --- /dev/null +++ b/src/frontend/main.py @@ -0,0 +1,36 @@ +import dash +from dash import html, dcc +import dash_mantine_components as dmc +from dash_iconify import DashIconify +import os + +from .config import Config +from .layouts.layout import create_layout +from .callbacks import register_callbacks + +# Initialize config +config = Config() + +# Initialize Dash app +app = dash.Dash( + __name__, + external_stylesheets=[ + "https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap" + ], + suppress_callback_exceptions=True, + title="Job Forge - AI-Powered Job Application Assistant" +) + +# Set up the layout +app.layout = create_layout() + +# Register callbacks +register_callbacks(app, config) + +if __name__ == "__main__": + app.run_server( + host="0.0.0.0", + port=8501, + debug=config.DEBUG, + dev_tools_hot_reload=config.DEBUG + ) \ No newline at end of file diff --git a/src/frontend/pages/__init__.py b/src/frontend/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/frontend/pages/auth.py b/src/frontend/pages/auth.py new file mode 100644 index 0000000..beaa205 --- /dev/null +++ b/src/frontend/pages/auth.py @@ -0,0 +1,164 @@ +from dash import html, dcc +import dash_mantine_components as dmc +from dash_iconify import DashIconify + +def create_login_page(): + return dmc.Container( + size="xs", + style={"marginTop": "10vh"}, + children=[ + dmc.Paper( + shadow="lg", + radius="md", + p="xl", + children=[ + dmc.Group( + position="center", + mb="xl", + children=[ + DashIconify( + icon="tabler:briefcase", + width=40, + color="#228BE6" + ), + dmc.Title("Job Forge", order=2, color="blue") + ] + ), + + dmc.Tabs( + id="auth-tabs", + value="login", + children=[ + dmc.TabsList( + grow=True, + children=[ + dmc.Tab("Login", value="login"), + dmc.Tab("Register", value="register") + ] + ), + + dmc.TabsPanel( + value="login", + children=[ + html.Form( + id="login-form", + children=[ + dmc.TextInput( + id="login-email", + label="Email", + placeholder="your.email@example.com", + icon=DashIconify(icon="tabler:mail"), + required=True, + mb="md" + ), + dmc.PasswordInput( + id="login-password", + label="Password", + placeholder="Your password", + icon=DashIconify(icon="tabler:lock"), + required=True, + mb="xl" + ), + dmc.Button( + "Login", + id="login-submit", + fullWidth=True, + leftIcon=DashIconify(icon="tabler:login") + ) + ] + ) + ] + ), + + dmc.TabsPanel( + value="register", + children=[ + html.Form( + id="register-form", + children=[ + dmc.Group( + grow=True, + children=[ + dmc.TextInput( + id="register-first-name", + label="First Name", + placeholder="John", + required=True, + style={"flex": 1} + ), + dmc.TextInput( + id="register-last-name", + label="Last Name", + placeholder="Doe", + required=True, + style={"flex": 1} + ) + ] + ), + dmc.TextInput( + id="register-email", + label="Email", + placeholder="your.email@example.com", + icon=DashIconify(icon="tabler:mail"), + required=True, + mt="md" + ), + dmc.TextInput( + id="register-phone", + label="Phone (Optional)", + placeholder="+1 (555) 123-4567", + icon=DashIconify(icon="tabler:phone"), + mt="md" + ), + dmc.PasswordInput( + id="register-password", + label="Password", + placeholder="Your password", + icon=DashIconify(icon="tabler:lock"), + required=True, + mt="md" + ), + dmc.PasswordInput( + id="register-password-confirm", + label="Confirm Password", + placeholder="Confirm your password", + icon=DashIconify(icon="tabler:lock"), + required=True, + mt="md", + mb="xl" + ), + dmc.Button( + "Register", + id="register-submit", + fullWidth=True, + leftIcon=DashIconify(icon="tabler:user-plus") + ) + ] + ) + ] + ) + ] + ), + + html.Div(id="auth-alerts", style={"marginTop": "1rem"}) + ] + ) + ] + ) + +def create_logout_confirmation(): + return dmc.Modal( + title="Confirm Logout", + id="logout-modal", + children=[ + dmc.Text("Are you sure you want to logout?"), + dmc.Group( + position="right", + mt="md", + children=[ + dmc.Button("Cancel", id="logout-cancel", variant="outline"), + dmc.Button("Logout", id="logout-confirm", color="red") + ] + ) + ] + ) \ No newline at end of file diff --git a/src/frontend/pages/home.py b/src/frontend/pages/home.py new file mode 100644 index 0000000..beff756 --- /dev/null +++ b/src/frontend/pages/home.py @@ -0,0 +1,185 @@ +from dash import html +import dash_mantine_components as dmc +from dash_iconify import DashIconify + +def create_home_page(): + return dmc.Container( + size="xl", + pt="md", + children=[ + dmc.Title("Welcome to Job Forge", order=1, mb="lg"), + + dmc.Grid( + children=[ + dmc.Col( + dmc.Card( + children=[ + dmc.Group( + children=[ + DashIconify( + icon="tabler:search", + width=40, + color="#228BE6" + ), + dmc.Stack( + spacing=5, + children=[ + dmc.Text("Find Jobs", weight=600, size="lg"), + dmc.Text( + "Search and discover job opportunities", + size="sm", + color="dimmed" + ) + ] + ) + ] + ), + dmc.Button( + "Search Jobs", + fullWidth=True, + mt="md", + id="home-search-jobs-btn" + ) + ], + withBorder=True, + shadow="sm", + radius="md", + p="lg" + ), + span=6 + ), + + dmc.Col( + dmc.Card( + children=[ + dmc.Group( + children=[ + DashIconify( + icon="tabler:briefcase", + width=40, + color="#40C057" + ), + dmc.Stack( + spacing=5, + children=[ + dmc.Text("Track Applications", weight=600, size="lg"), + dmc.Text( + "Manage your job applications", + size="sm", + color="dimmed" + ) + ] + ) + ] + ), + dmc.Button( + "View Applications", + fullWidth=True, + mt="md", + color="green", + id="home-applications-btn" + ) + ], + withBorder=True, + shadow="sm", + radius="md", + p="lg" + ), + span=6 + ), + + dmc.Col( + dmc.Card( + children=[ + dmc.Group( + children=[ + DashIconify( + icon="tabler:file-text", + width=40, + color="#FD7E14" + ), + dmc.Stack( + spacing=5, + children=[ + dmc.Text("AI Documents", weight=600, size="lg"), + dmc.Text( + "Generate resumes and cover letters", + size="sm", + color="dimmed" + ) + ] + ) + ] + ), + dmc.Button( + "Create Documents", + fullWidth=True, + mt="md", + color="orange", + id="home-documents-btn" + ) + ], + withBorder=True, + shadow="sm", + radius="md", + p="lg" + ), + span=6 + ), + + dmc.Col( + dmc.Card( + children=[ + dmc.Group( + children=[ + DashIconify( + icon="tabler:user", + width=40, + color="#BE4BDB" + ), + dmc.Stack( + spacing=5, + children=[ + dmc.Text("Profile", weight=600, size="lg"), + dmc.Text( + "Manage your profile and settings", + size="sm", + color="dimmed" + ) + ] + ) + ] + ), + dmc.Button( + "Edit Profile", + fullWidth=True, + mt="md", + color="violet", + id="home-profile-btn" + ) + ], + withBorder=True, + shadow="sm", + radius="md", + p="lg" + ), + span=6 + ) + ], + gutter="md" + ), + + dmc.Divider(my="xl"), + + dmc.Title("Recent Activity", order=2, mb="md"), + dmc.Card( + children=[ + dmc.Text("No recent activity yet. Start by searching for jobs or uploading your resume!") + ], + withBorder=True, + shadow="sm", + radius="md", + p="lg" + ) + ] + ) \ No newline at end of file