Merge pull request 'Backend_CODEBASE' (#3) from Backend_CODEBASE into main
Reviewed-on: #3Backend_CODEBASE^2
commit
938cfc8d65
|
|
@ -2,41 +2,22 @@ from fastapi import APIRouter,Depends
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from db_setup import get_session
|
from db_setup import get_session
|
||||||
|
from job.candidate.views import FileRead
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from pydantic import BaseModel, model_validator
|
|
||||||
from users.permissions import PermissionTag, require_permission
|
from users.permissions import PermissionTag, require_permission
|
||||||
from job.job_post.views import JobPost
|
from job.job_post.views import JobPost,JobPostCreate
|
||||||
|
import logging
|
||||||
from job.job_post.plugins import PlatformAlias
|
from job.job_post.plugins import PlatformAlias
|
||||||
|
from fastapi import UploadFile, File
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from datetime import datetime, time, timezone
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
class JobPostCreate(BaseModel):
|
|
||||||
title: str
|
|
||||||
experience_min: int | None = None
|
|
||||||
experience_max: int | None = None
|
|
||||||
requirements: list[str] = []
|
|
||||||
optional_skills: list[str] = []
|
|
||||||
salary: str = "Anonymous"
|
|
||||||
location: str | None = None
|
|
||||||
employment_type: str | None = None
|
|
||||||
platform: str = "linkedin"
|
|
||||||
description: str | None = None
|
|
||||||
platform: str | None = None
|
|
||||||
channel_id: str | None = None
|
|
||||||
mode: str = "addToQueue"
|
|
||||||
due_at: str | None = None
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def validate_mode_and_due_at(self):
|
|
||||||
allowed = {"addToQueue", "shareNow", "customScheduled"}
|
|
||||||
if self.mode not in allowed:
|
|
||||||
raise ValueError(f"mode must be one of {sorted(allowed)}")
|
|
||||||
if self.mode == "customScheduled" and not self.due_at:
|
|
||||||
raise ValueError("due_at is required when mode is customScheduled")
|
|
||||||
return self
|
|
||||||
|
|
||||||
@router.get("/jobs/alias")
|
@router.get("/jobs/alias")
|
||||||
async def get_job_alias():
|
async def get_job_alias():
|
||||||
|
|
@ -51,11 +32,17 @@ async def get_job_alias():
|
||||||
|
|
||||||
@router.post("/candidate/cv_upload")
|
@router.post("/candidate/cv_upload")
|
||||||
async def cv_upload(
|
async def cv_upload(
|
||||||
|
file: UploadFile = File(...),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
pass
|
file_content = await file.read()
|
||||||
|
logger.info(f"Received file: {file.filename} ({len(file_content)} bytes)")
|
||||||
|
service=FileRead(session=session,filename=file.filename,file=file_content)
|
||||||
|
data=await service.read_file(file_content, file.filename)
|
||||||
|
|
||||||
|
return JSONResponse(content={"data":data,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -70,8 +57,15 @@ async def post_job(
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=JobPost(session=session)
|
service=JobPost(session=session)
|
||||||
data=await service.post_job(payload.model_dump(),current_user)
|
data=payload.model_dump()
|
||||||
return JSONResponse(content={"data":data,"status_code":200})
|
if data['mode']=="customScheduled":
|
||||||
|
data['due_at']=datetime.combine(
|
||||||
|
data['scheduler_date'],
|
||||||
|
data['scheduler_time'] or time(0, 0, 0),
|
||||||
|
tzinfo=timezone.utc,
|
||||||
|
).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||||
|
result=await service.post_job(data,current_user)
|
||||||
|
return JSONResponse(content={"data":result,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""Text-cleanup decorators for `plugins.normalize_spaced_text`.
|
||||||
|
|
||||||
|
Pure module: no FastAPI imports, no HTTPException, and no module-level state.
|
||||||
|
Each helper carries its own lookup tables and thresholds, so a caller can tune
|
||||||
|
one call site without moving a shared constant that every other caller reads.
|
||||||
|
|
||||||
|
`normalize_unicode` and `despace_line` are stacked onto the formatter and run
|
||||||
|
outermost-first, so the text arrives already folded and already rebuilt:
|
||||||
|
|
||||||
|
raw -> normalize_unicode -> despace_line -> normalize_spaced_text
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
|
||||||
|
def is_letter_spaced(line, *, min_tokens=4, single_char_ratio=0.6) -> bool:
|
||||||
|
"""True when the line looks glyph-padded rather than normally typed.
|
||||||
|
|
||||||
|
Both gates have to pass: a short line like "Next.js 3 A B" clears the ratio
|
||||||
|
on its own, so the token count is what keeps it out.
|
||||||
|
"""
|
||||||
|
tokens = line.split()
|
||||||
|
if len(tokens) < min_tokens:
|
||||||
|
return False
|
||||||
|
singles = sum(1 for token in tokens if len(token) == 1)
|
||||||
|
return singles / len(tokens) >= single_char_ratio
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_unicode(func):
|
||||||
|
"""Fold ligatures, drop invisibles, flatten every space variant to U+0020.
|
||||||
|
|
||||||
|
Runs before the spacing heuristics so they only ever see one kind of gap.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(text, *args, **kwargs):
|
||||||
|
text = text or ""
|
||||||
|
ligatures = {"ff": "ff", "fi": "fi", "fl": "fl", "ffi": "ffi", "ffl": "ffl"}
|
||||||
|
# Zero-width and soft-hyphen glyphs pypdf emits; they break word matching.
|
||||||
|
invisible = dict.fromkeys(map(ord, ""), None)
|
||||||
|
for ligature, plain in ligatures.items():
|
||||||
|
text = text.replace(ligature, plain)
|
||||||
|
text = text.translate(invisible)
|
||||||
|
folded = "".join(
|
||||||
|
" " if char == "\t" or unicodedata.category(char) == "Zs" else char
|
||||||
|
for char in text
|
||||||
|
)
|
||||||
|
return func(folded, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def despace_line(func):
|
||||||
|
"""Rebuild every glyph-padded line: 2+ spaces are word gaps, single spaces are noise.
|
||||||
|
|
||||||
|
Lines that fail `is_letter_spaced` are passed through untouched, because the
|
||||||
|
same rule applied to normally typed text would glue its words together.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(text, *args, **kwargs):
|
||||||
|
lines = []
|
||||||
|
for line in (text or "").splitlines():
|
||||||
|
if is_letter_spaced(line):
|
||||||
|
words = re.split(r" {2,}", line.strip())
|
||||||
|
line = " ".join(word.replace(" ", "") for word in words if word.strip())
|
||||||
|
lines.append(line)
|
||||||
|
return func("\n".join(lines), *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""CV text cleanup helpers for the PDF extractor.
|
||||||
|
|
||||||
|
Pure module: no FastAPI imports and no HTTPException.
|
||||||
|
|
||||||
|
Designer-made resumes position every glyph individually, so pypdf hands back
|
||||||
|
"S K I L L S" instead of "SKILLS". In that layout a single space is glyph
|
||||||
|
padding and a run of two or more spaces is the real word gap, which is what
|
||||||
|
the `despace_line` decorator keys off to rebuild readable lines.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from job.candidate.decorators import despace_line, normalize_unicode
|
||||||
|
|
||||||
|
|
||||||
|
@normalize_unicode
|
||||||
|
@despace_line
|
||||||
|
def normalize_spaced_text(text) -> str:
|
||||||
|
"""Turn raw pypdf output into readable text, leaving normal lines untouched.
|
||||||
|
|
||||||
|
The decorators have already folded the unicode and rebuilt the glyph-padded
|
||||||
|
lines; what is left is the whitespace tidy-up that every line wants.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
lines = [re.sub(r" {2,}", " ", line).strip() for line in text.splitlines()]
|
||||||
|
return re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip()
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
import os,logging,io
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from pypdf import PdfReader
|
||||||
|
from sqlalchemy import select
|
||||||
|
from job.candidate.plugins import normalize_spaced_text
|
||||||
|
|
||||||
|
class FileRead:
|
||||||
|
def __init__(self,session:AsyncSession,filename=None,file=None):
|
||||||
|
self.session=session
|
||||||
|
self.filename=filename
|
||||||
|
self.file=file
|
||||||
|
|
||||||
|
async def read_file(self,file=None,filename=None):
|
||||||
|
try:
|
||||||
|
reader = PdfReader(io.BytesIO(self.file))
|
||||||
|
if reader.is_encrypted:
|
||||||
|
raise HTTPException(400, "PDF is password protected")
|
||||||
|
pages = [(page.extract_text() or "") for page in reader.pages]
|
||||||
|
return {
|
||||||
|
"filename": self.filename,
|
||||||
|
"num_pages": len(reader.pages),
|
||||||
|
"text": normalize_spaced_text("\n".join(pages)),
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
|
from datetime import date, time
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from pydantic import BaseModel, model_validator
|
||||||
from job.job_post.models import JobPosts
|
from job.job_post.models import JobPosts
|
||||||
from job.job_post.plugins import (
|
from job.job_post.plugins import (
|
||||||
BufferError,
|
BufferError,
|
||||||
|
|
@ -21,6 +22,33 @@ from job.job_post.serializers import serialize_job_post
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class JobPostCreate(BaseModel):
|
||||||
|
title: str
|
||||||
|
experience_min: int | None = None
|
||||||
|
experience_max: int | None = None
|
||||||
|
requirements: list[str] = []
|
||||||
|
optional_skills: list[str] = []
|
||||||
|
salary: str = "Anonymous"
|
||||||
|
location: str | None = None
|
||||||
|
employment_type: str | None = None
|
||||||
|
platform: str = "linkedin"
|
||||||
|
description: str | None = None
|
||||||
|
platform: str | None = None
|
||||||
|
channel_id: str | None = None
|
||||||
|
mode: str = "addToQueue"
|
||||||
|
scheduler_time: time | None = time(0, 0, 0)
|
||||||
|
scheduler_date: date | None = None
|
||||||
|
due_at: str | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_mode_and_due_at(self):
|
||||||
|
allowed = {"addToQueue", "shareNow", "customScheduled"}
|
||||||
|
if self.mode not in allowed:
|
||||||
|
raise ValueError(f"mode must be one of {sorted(allowed)}")
|
||||||
|
if self.mode == "customScheduled" and not self.due_at:
|
||||||
|
raise ValueError("due_at is required when mode is customScheduled")
|
||||||
|
return self
|
||||||
|
|
||||||
class JobPost:
|
class JobPost:
|
||||||
def __init__(self,session:AsyncSession):
|
def __init__(self,session:AsyncSession):
|
||||||
self.session=session
|
self.session=session
|
||||||
|
|
@ -84,15 +112,10 @@ class JobPost:
|
||||||
due_at=payload.get("due_at"),
|
due_at=payload.get("due_at"),
|
||||||
)
|
)
|
||||||
except (httpx.HTTPError,BufferError,RuntimeError) as e:
|
except (httpx.HTTPError,BufferError,RuntimeError) as e:
|
||||||
# Include the reason: Buffer's rejections are actionable (duplicate text,
|
|
||||||
# daily limit, disconnected channel) and an opaque 502 sends the caller
|
|
||||||
# digging through job_posts.buffer_error to find out.
|
|
||||||
await JobPosts.mark_failed(self.session,str(row.id),str(e))
|
await JobPosts.mark_failed(self.session,str(row.id),str(e))
|
||||||
raise HTTPException(status_code=502,detail=f"Failed to publish job post to Buffer: {e}") from e
|
raise HTTPException(status_code=502,detail=f"Failed to publish job post to Buffer: {e}") from e
|
||||||
|
|
||||||
# Buffer accepting the mutation is not the same as the network publishing it:
|
|
||||||
# the default addToQueue mode returns `scheduled`, so the row only reads
|
|
||||||
# "published" once Buffer reports `sent`.
|
|
||||||
saved=await JobPosts.mark_buffer_result(
|
saved=await JobPosts.mark_buffer_result(
|
||||||
self.session,
|
self.session,
|
||||||
str(row.id),
|
str(row.id),
|
||||||
|
|
|
||||||
|
|
@ -26,3 +26,6 @@ python-multipart==0.0.20 # required by OAuth2PasswordRequestForm in users/app.
|
||||||
# --- other -----------------------------------------------------------------
|
# --- other -----------------------------------------------------------------
|
||||||
httpx==0.28.1 # Graph email (inbox) + Teams mail send (forget_password/plugins.py)
|
httpx==0.28.1 # Graph email (inbox) + Teams mail send (forget_password/plugins.py)
|
||||||
bcrypt==5.0.0 # password hashing in users/plugins.py
|
bcrypt==5.0.0 # password hashing in users/plugins.py
|
||||||
|
|
||||||
|
# --- PDF extraction --------------------------------------------------------
|
||||||
|
pypdf==5.1.0
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue