supporiting documents uploaded. One step away from starting development.
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -162,10 +162,16 @@ Thumbs.db
|
|||||||
# User data and uploads
|
# User data and uploads
|
||||||
user_data/
|
user_data/
|
||||||
uploads/
|
uploads/
|
||||||
|
documents/
|
||||||
|
|
||||||
# AI model cache
|
# AI model cache
|
||||||
.cache/
|
.cache/
|
||||||
models/
|
models/
|
||||||
|
ai_cache/
|
||||||
|
embeddings_cache/
|
||||||
|
|
||||||
|
# Database volumes
|
||||||
|
postgres_data/
|
||||||
|
|
||||||
# Spyder project settings
|
# Spyder project settings
|
||||||
.spyderproject
|
.spyderproject
|
||||||
|
|||||||
28
Dockerfile.backend
Normal file
28
Dockerfile.backend
Normal file
@@ -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"]
|
||||||
23
Dockerfile.frontend
Normal file
23
Dockerfile.frontend
Normal file
@@ -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"]
|
||||||
@@ -11,11 +11,15 @@ httpx==0.27.0
|
|||||||
pandas==2.2.1
|
pandas==2.2.1
|
||||||
plotly==5.18.0
|
plotly==5.18.0
|
||||||
|
|
||||||
|
# File handling
|
||||||
|
Pillow==10.2.0
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
structlog==24.1.0
|
structlog==24.1.0
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
pytest==8.0.2
|
pytest==8.0.2
|
||||||
|
pytest-dash==2.5.0
|
||||||
black==24.2.0
|
black==24.2.0
|
||||||
isort==5.13.2
|
isort==5.13.2
|
||||||
0
src/backend/__init__.py
Normal file
0
src/backend/__init__.py
Normal file
0
src/backend/api/__init__.py
Normal file
0
src/backend/api/__init__.py
Normal file
205
src/backend/api/applications.py
Normal file
205
src/backend/api/applications.py
Normal file
@@ -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"}
|
||||||
130
src/backend/api/auth.py
Normal file
130
src/backend/api/auth.py
Normal file
@@ -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)
|
||||||
184
src/backend/api/documents.py
Normal file
184
src/backend/api/documents.py
Normal file
@@ -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
|
||||||
|
}
|
||||||
143
src/backend/api/jobs.py
Normal file
143
src/backend/api/jobs.py
Normal file
@@ -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]
|
||||||
0
src/backend/core/__init__.py
Normal file
0
src/backend/core/__init__.py
Normal file
30
src/backend/core/config.py
Normal file
30
src/backend/core/config.py
Normal file
@@ -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()
|
||||||
49
src/backend/core/database.py
Normal file
49
src/backend/core/database.py
Normal file
@@ -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
|
||||||
63
src/backend/main.py
Normal file
63
src/backend/main.py
Normal file
@@ -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"}
|
||||||
|
)
|
||||||
0
src/frontend/__init__.py
Normal file
0
src/frontend/__init__.py
Normal file
235
src/frontend/callbacks.py
Normal file
235
src/frontend/callbacks.py
Normal file
@@ -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...")
|
||||||
|
]
|
||||||
|
)
|
||||||
27
src/frontend/config.py
Normal file
27
src/frontend/config.py
Normal file
@@ -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"
|
||||||
0
src/frontend/layouts/__init__.py
Normal file
0
src/frontend/layouts/__init__.py
Normal file
121
src/frontend/layouts/layout.py
Normal file
121
src/frontend/layouts/layout.py
Normal file
@@ -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"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
36
src/frontend/main.py
Normal file
36
src/frontend/main.py
Normal file
@@ -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
|
||||||
|
)
|
||||||
0
src/frontend/pages/__init__.py
Normal file
0
src/frontend/pages/__init__.py
Normal file
164
src/frontend/pages/auth.py
Normal file
164
src/frontend/pages/auth.py
Normal file
@@ -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")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
185
src/frontend/pages/home.py
Normal file
185
src/frontend/pages/home.py
Normal file
@@ -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"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user