pull/29/head
ahmed.mujtaba 2026-08-25 20:11:02 +05:00
parent a0ab618ac5
commit 65c189a6db
18 changed files with 840 additions and 488 deletions

View File

@ -31,10 +31,8 @@ frontend/
# Candidate CVs live on the bind mount, not inside an image. # Candidate CVs live on the bind mount, not inside an image.
backend/inbox/decoded_attachments/ backend/inbox/decoded_attachments/
# Alembic revision scripts stay out of images (gitignored; never ship to prod). # Ship revision scripts so `DB_AUTO_MIGRATE=true` can `upgrade head` in Docker.
# Schema drift is applied filelessly at API boot when DB_AUTOGENERATE=true. # Fileless ORM drift (DB_AUTOGENERATE) still covers leftover model gaps.
backend/migrations/versions/*.py
!backend/migrations/versions/.gitkeep
docs/ docs/
tests/ tests/

View File

@ -986,7 +986,7 @@ LLM failures are logged and skipped; the API still comes up.
```bash ```bash
taskiq worker taskiq_management.broker_setup:broker \ taskiq worker taskiq_management.broker_setup:broker \
inbox.tasks inbox.sync_tasks taskiq_management.tasks inbox.tasks inbox.sync_tasks taskiq_management.tasks g_sheet.tasks
``` ```
**CV-upload worker** (isolated stream for manual uploads): **CV-upload worker** (isolated stream for manual uploads):

View File

@ -211,7 +211,10 @@ def context_options() -> dict[str, Any]:
async def _run(fn: Callable[[Connection], Any]) -> Any: async def _run(fn: Callable[[Connection], Any]) -> Any:
"""Run a synchronous Alembic call on the async engine's connection.""" """Run a synchronous Alembic call on the async engine's connection."""
schema = get_settings().db_default_schema or "public"
async with get_engine().connect() as conn: async with get_engine().connect() as conn:
# Unqualified FK targets (REFERENCES users) must resolve in `app`.
await conn.execute(text(f'SET search_path TO "{schema}", public'))
result = await conn.run_sync(fn) result = await conn.run_sync(fn)
await conn.commit() await conn.commit()
return result return result
@ -418,7 +421,12 @@ async def migrate(*, autogen: bool | None = None, message: str = "auto") -> None
else: else:
await upgrade() await upgrade()
if should_autogen: if should_autogen:
await apply_model_drift() try:
await apply_model_drift()
except Exception as exc:
# Drift can still trip on unrelated tables; file revisions already
# ran above. Log and continue so the API can finish booting.
logger.exception("ORM drift apply failed; continuing boot: %s", exc)
await run_manual_sql() await run_manual_sql()
logger.info("database at revision %s", await current()) logger.info("database at revision %s", await current())

View File

@ -174,8 +174,15 @@ def _connect_args(settings: Settings) -> dict:
"""UTC session + SSL for RDS. `require` encrypts without verifying the CA.""" """UTC session + SSL for RDS. `require` encrypts without verifying the CA."""
import ssl as ssl_mod import ssl as ssl_mod
# search_path includes the app schema so unqualified FKs (users.id) resolve
# during fileless ORM drift and normal queries — default is "$user", public.
schema = settings.db_default_schema or "public"
args: dict = { args: dict = {
"server_settings": {"timezone": "UTC", "application_name": settings.app_name} "server_settings": {
"timezone": "UTC",
"application_name": settings.app_name,
"search_path": f"{schema}, public",
}
} }
mode = (settings.db_sslmode or "").strip().lower() mode = (settings.db_sslmode or "").strip().lower()
if mode and mode not in ("disable", "allow", "prefer"): if mode and mode not in ("disable", "allow", "prefer"):

View File

@ -100,13 +100,14 @@ async def fetch_sheet(
@router.post("/sheet/import") @router.post("/sheet/import")
async def import_all_sheets( async def import_all_sheets(
tab: str | None = Query(None),
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_EDIT)), current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_EDIT)),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
"""Enqueue a full-spreadsheet import. Poll GET /sheet/import/fetch for status.""" """No tab -> every tab. With a tab -> that sheet only. Poll GET /sheet/import/fetch."""
try: try:
service=Sheet(session=session) service=Sheet(session=session)
data=await service.start_import(current_user=current_user,tab=None) data=await service.start_import(current_user=current_user,tab=tab)
return JSONResponse(content={"data":data,"total":1,"status_code":200}) return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException: except HTTPException:
raise raise

View File

@ -1,207 +1,193 @@
"""Sheet header aliases, FormData keys, and date/round format mappings. """Sheet header aliases, FormData keys, and date format mappings.
(str, Enum) like inbox/enums.py: members compare to and serialize as plain strings. (str, Enum) like inbox/enums.py: members compare to and serialize as plain strings.
Non-string mappings (month pairs, ordinal slot+pattern) use plain Enum. Non-string mappings (month pairs) use plain Enum.
""" """
from enum import Enum from enum import Enum
class AliasEnum(str, Enum):
"""Member-less base so alias enums share one `has` without 25 copies."""
@classmethod
def has(cls, value) -> bool:
return value in cls._value2member_map_
class FormDataField(str, Enum): class FormDataField(str, Enum):
"""Canonical FormData column keys (plus title, which stays in JSONB only).""" """Canonical FormData column keys for the recruitment screening sheet."""
SERIAL_NO = "serial_no"
ENTRY_YEAR = "entry_year"
ENTRY_MONTH = "entry_month"
ENTRY_DATE = "entry_date"
ENTRY_TIME = "entry_time"
SCREENED_BY = "screened_by"
NAME = "name" NAME = "name"
DEGREE = "degree" HR_COMMENTS = "hr_comments"
EXPERIENCE = "experience" CANDIDATE_NUMBER = "candidate_number"
CANDIDATE_EMAIL = "candidate_email"
PROFILE_LINK = "profile_link"
AREA_OF_EXPERTISE = "area_of_expertise"
REQUISITION_NUMBER = "requisition_number"
POSITION_SUITABLE_FOR = "position_suitable_for"
SOURCE_OF_APPLICATION = "source_of_application"
AGE = "age" AGE = "age"
FAMILY_DETAILS = "family_details" MARITAL_STATUS = "marital_status"
TITLE = "title"
class NameAlias(str, Enum):
NAME = "name"
NAMES = "names"
CANDIDATE_NAME = "candidate name"
CANDIDATE = "candidate"
@classmethod
def has(cls, value) -> bool:
return value in cls._value2member_map_
class DegreeAlias(str, Enum):
EDUCATION = "education"
DEGREE = "degree" DEGREE = "degree"
QUALIFICATION = "qualification" UNIVERSITY = "university"
@classmethod
def has(cls, value) -> bool:
return value in cls._value2member_map_
class ExperienceAlias(str, Enum):
EXPERIENCE = "experience" EXPERIENCE = "experience"
EXP = "exp" EXPERIENCE_DETAILS = "experience_details"
YEARS_OF_EXPERIENCE = "years of experience" AREA_OF_RESIDENCE = "area_of_residence"
TOTAL_EXPERIENCE = "total experience" COMMUNICATION_SKILLS = "communication_skills"
PREFERRED_TIMINGS = "preferred_timings"
@classmethod HO_AVAILABILITY = "ho_availability"
def has(cls, value) -> bool: CURRENT_COMPANY = "current_company"
return value in cls._value2member_map_ REASON_FOR_LEAVING = "reason_for_leaving"
NOTICE_PERIOD = "notice_period"
CURRENT_SALARY = "current_salary"
EXPECTED_SALARY = "expected_salary"
PROS = "pros"
CONS = "cons"
class AgeAlias(str, Enum): # canonical (lowercased, whitespace-collapsed, punctuation-stripped) header -> field.
AGE = "age" # The sheet's own spelling is listed first; the rest are tolerated synonyms.
HEADER_ALIASES: dict[FormDataField, tuple[str, ...]] = {
@classmethod FormDataField.SERIAL_NO: ("um", "sr", "sr no", "s no", "serial", "serial no"),
def has(cls, value) -> bool: FormDataField.ENTRY_YEAR: ("year", "year of graduation"),
return value in cls._value2member_map_ FormDataField.ENTRY_MONTH: ("month",),
FormDataField.ENTRY_DATE: ("date", "entry date", "date of entry", "timestamp", "time stamp"),
FormDataField.ENTRY_TIME: ("time of entry", "entry time", "time"),
class FamilyDetailsAlias(str, Enum): FormDataField.SCREENED_BY: (
FAMILY_DETAILS = "family details" "screened by", "screened", "interviewed by", "conducted by", "recruiter",
MARITAL_STATUS = "marital status" ),
MARITAL = "marital" FormDataField.NAME: (
"candidate name", "full name", "name", "names", "candidate",
@classmethod ),
def has(cls, value) -> bool: FormDataField.HR_COMMENTS: ("hr comments", "hr comment", "comments", "remarks"),
return value in cls._value2member_map_ FormDataField.CANDIDATE_NUMBER: (
"candidate number", "contact number", "phone number", "phone", "mobile", "contact",
),
class TitleAlias(str, Enum): FormDataField.CANDIDATE_EMAIL: ("candidate email", "email", "email address"),
"""No FormData column — recognised so headers are not treated as unknown noise.""" FormDataField.PROFILE_LINK: (
"profile link", "linkedin profile link", "cv link", "resume link",
TITLE = "title" "drop your updated resume", "profile",
DESIGNATION = "designation" ),
ROLE = "role" FormDataField.AREA_OF_EXPERTISE: (
POSITION = "position" "area of expertise", "area of interest", "expertise",
TEAM = "team" ),
JOB_TITLE = "job title" FormDataField.REQUISITION_NUMBER: ("requisition number", "requisition", "req no"),
AREA_OF_EXPERTISE = "area of expertise" FormDataField.POSITION_SUITABLE_FOR: (
DEPARTMENT = "department" "position suitable for", "position applied for", "position",
"designation", "job title", "role", "title",
@classmethod ),
def has(cls, value) -> bool: FormDataField.SOURCE_OF_APPLICATION: (
return value in cls._value2member_map_ "source of application", "source", "application source",
"where did you hear about the position you're applying for",
),
# FormDataField → alias Enum. Order is match priority for overlapping startswith hits. FormDataField.AGE: ("age",),
FIELD_ALIAS_ENUMS = { FormDataField.MARITAL_STATUS: ("marital status", "marital", "family details"),
FormDataField.NAME: NameAlias, FormDataField.DEGREE: (
FormDataField.DEGREE: DegreeAlias, "education", "educational degree", "degree", "qualification",
FormDataField.EXPERIENCE: ExperienceAlias, ),
FormDataField.AGE: AgeAlias, FormDataField.UNIVERSITY: ("university of graduation", "university", "institute", "college"),
FormDataField.FAMILY_DETAILS: FamilyDetailsAlias, FormDataField.EXPERIENCE: ("experience", "total experience", "years of experience", "exp"),
FormDataField.TITLE: TitleAlias, FormDataField.EXPERIENCE_DETAILS: ("experience details", "experience detail"),
FormDataField.AREA_OF_RESIDENCE: (
"area of residence", "residing city", "residing country",
"residence", "location", "address",
),
FormDataField.COMMUNICATION_SKILLS: ("communication skills", "communication"),
FormDataField.PREFERRED_TIMINGS: ("preferred timings", "preferred timing", "shift"),
FormDataField.HO_AVAILABILITY: (
"availability to work in the h.o", "availability to work in the ho",
"ho availability", "availability", "are you willing to relocate",
),
FormDataField.CURRENT_COMPANY: ("current company", "current employer", "company", "employer"),
FormDataField.REASON_FOR_LEAVING: ("reason for leaving", "reason of leaving", "reason"),
FormDataField.NOTICE_PERIOD: (
"how soon can you join us", "how soon can you join",
"notice period", "joining", "availability to join",
),
FormDataField.CURRENT_SALARY: ("current salary", "present salary", "salary"),
FormDataField.EXPECTED_SALARY: ("expected salary", "salary expectation", "expected"),
FormDataField.PROS: ("pros", "strengths"),
FormDataField.CONS: ("cons", "weaknesses"),
} }
class RoundRole(str, Enum): def _build_alias_to_field() -> dict[str, FormDataField]:
"""Interview-round column roles resolved left-to-right into four slots.""" inverted: dict[str, FormDataField] = {}
for field, aliases in HEADER_ALIASES.items():
DATE = "date" for alias in aliases:
BY = "by" if alias in inverted:
STATUS = "status" raise ValueError(
NOTES = "notes" f"duplicate header alias {alias!r}: "
RESULT = "result" f"{inverted[alias].value} and {field.value}"
)
inverted[alias] = field
return inverted
class ConductedByAlias(str, Enum): ALIAS_TO_FIELD: dict[str, FormDataField] = _build_alias_to_field()
"""Header spellings that map to RoundRole.BY."""
CONDUCTED_BY = "conducted by"
INTERVIEWED_BY = "interviewed by"
INTERVIEW_BY = "interview by"
CONDUCTED = "conducted"
BY = "by"
@classmethod
def has(cls, value) -> bool:
return value in cls._value2member_map_
@classmethod
def contained_in(cls, text: str) -> bool:
return any(member.value in text for member in cls if " " in member.value)
class NotesToken(str, Enum): class FormDataColumn(str, Enum):
"""Substrings that classify a header as RoundRole.NOTES.""" """FormData API / ORM field names in serialize order.
NOTE = "note" Broader than FormDataField: includes id, sheet meta, derived parsers
REMARK = "remark" (age_raw, *_salary_value), raw_record, and timestamps.
COMMENT = "comment" """
@classmethod ID = "id"
def contained_in(cls, text: str) -> bool: SHEET = "sheet"
return any(member.value in text for member in cls) JOB_POST_ID = "job_post_id"
ROW_NUMBER = "row_number"
SERIAL_NO = "serial_no"
ENTRY_YEAR = "entry_year"
ENTRY_MONTH = "entry_month"
ENTRY_DATE = "entry_date"
ENTRY_TIME = "entry_time"
SCREENED_BY = "screened_by"
NAME = "name"
HR_COMMENTS = "hr_comments"
CANDIDATE_NUMBER = "candidate_number"
CANDIDATE_EMAIL = "candidate_email"
PROFILE_LINK = "profile_link"
AREA_OF_EXPERTISE = "area_of_expertise"
REQUISITION_NUMBER = "requisition_number"
POSITION_SUITABLE_FOR = "position_suitable_for"
SOURCE_OF_APPLICATION = "source_of_application"
AGE = "age"
AGE_RAW = "age_raw"
MARITAL_STATUS = "marital_status"
DEGREE = "degree"
UNIVERSITY = "university"
EXPERIENCE = "experience"
EXPERIENCE_DETAILS = "experience_details"
AREA_OF_RESIDENCE = "area_of_residence"
COMMUNICATION_SKILLS = "communication_skills"
PREFERRED_TIMINGS = "preferred_timings"
HO_AVAILABILITY = "ho_availability"
CURRENT_COMPANY = "current_company"
REASON_FOR_LEAVING = "reason_for_leaving"
NOTICE_PERIOD = "notice_period"
CURRENT_SALARY = "current_salary"
CURRENT_SALARY_VALUE = "current_salary_value"
EXPECTED_SALARY = "expected_salary"
EXPECTED_SALARY_VALUE = "expected_salary_value"
PROS = "pros"
CONS = "cons"
RAW_RECORD = "raw_record"
IMPORTED_AT = "imported_at"
CREATED_AT = "created_at"
UPDATED_AT = "updated_at"
# -- Round → FormData column names (slot 0..3 = definition order) ------------ # Ordered values for serialize_form_data / model_fields assertions.
FORM_DATA_FIELDS: tuple[str, ...] = tuple(member.value for member in FormDataColumn)
class RoundDateColumn(str, Enum):
R1 = "interview_date"
R2 = "second_interview_date"
R3 = "third_interview_date"
R4 = "fourth_interview_date"
@classmethod
def ordered(cls) -> tuple[str, ...]:
return tuple(member.value for member in cls)
class RoundByColumn(str, Enum):
R1 = "interview_by"
R2 = "second_interview_by"
R3 = "third_interview_by"
R4 = "fourth_interview_by"
@classmethod
def ordered(cls) -> tuple[str, ...]:
return tuple(member.value for member in cls)
class RoundTimeColumn(str, Enum):
R1 = "interview_time"
R2 = "second_interview_time"
R3 = "third_interview_time"
R4 = "fourth_interview_time"
@classmethod
def ordered(cls) -> tuple[str, ...]:
return tuple(member.value for member in cls)
class RoundStatusColumn(str, Enum):
R1 = "interview_status"
R2 = "second_interview_status"
R3 = "third_interview_status"
R4 = "fourth_interview_status"
@classmethod
def ordered(cls) -> tuple[str, ...]:
return tuple(member.value for member in cls)
class RoundNotesColumn(str, Enum):
R1 = "interview_notes"
R2 = "second_interview_notes"
R3 = "third_interview_notes"
R4 = "fourth_interview_notes"
@classmethod
def ordered(cls) -> tuple[str, ...]:
return tuple(member.value for member in cls)
class RoundResultColumn(str, Enum):
R1 = "interview_result"
R2 = "second_interview_result"
R3 = "third_interview_result"
R4 = "fourth_interview_result"
@classmethod
def ordered(cls) -> tuple[str, ...]:
return tuple(member.value for member in cls)
# -- Date parsing ------------------------------------------------------------ # -- Date parsing ------------------------------------------------------------
@ -260,20 +246,3 @@ class MonthNormalisation(Enum):
@property @property
def short(self) -> str: def short(self) -> str:
return self.value[1] return self.value[1]
class RoundOrdinal(Enum):
"""Interview-round ordinal in a header → slot index 0..3. value is (slot, regex)."""
FIRST = (0, r"(?:1st|first|01st)")
SECOND = (1, r"(?:2nd|second|02nd)")
THIRD = (2, r"(?:3rd|third|03rd)")
FOURTH = (3, r"(?:4th|fourth|04th)")
@property
def slot(self) -> int:
return self.value[0]
@property
def pattern(self) -> str:
return self.value[1]

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import Column, DateTime, Index, delete, func, or_ from sqlalchemy import Column, DateTime, Index, delete, func, insert, or_
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, SQLModel, select from sqlmodel import Field, SQLModel, select
@ -15,6 +15,9 @@ def _now() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
_BULK_CHUNK = 1000
class FormData(SQLModel, table=True): class FormData(SQLModel, table=True):
"""One spreadsheet data row. raw_record keeps the full original header→value map.""" """One spreadsheet data row. raw_record keeps the full original header→value map."""
@ -23,45 +26,49 @@ class FormData(SQLModel, table=True):
Index("ix_form_data_sheet_row_number", "sheet", "row_number", unique=True), Index("ix_form_data_sheet_row_number", "sheet", "row_number", unique=True),
) )
id: int | None = Field(default=None, primary_key=True) id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
sheet: str = Field(nullable=False, index=True) sheet: str = Field(nullable=False, index=True)
# Optional link to a job post. DB FK only — no ORM Relationship (avoids
# pulling job_posts into the sheet worker metadata graph).
job_post_id: uuid.UUID | None = Field(default=None, index=True)
row_number: int | None = Field(default=None)
serial_no: str | None = Field(default=None)
entry_year: str | None = Field(default=None)
entry_month: str | None = Field(default=None)
entry_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
entry_time: str | None = Field(default=None)
screened_by: str | None = Field(default=None, index=True)
name: str | None = Field(default=None, index=True) name: str | None = Field(default=None, index=True)
degree: str | None = Field(default=None) hr_comments: str | None = Field(default=None)
experience: str | None = Field(default=None) candidate_number: str | None = Field(default=None)
candidate_email: str | None = Field(default=None, index=True)
profile_link: str | None = Field(default=None)
area_of_expertise: str | None = Field(default=None)
requisition_number: str | None = Field(default=None, index=True)
position_suitable_for: str | None = Field(default=None)
source_of_application: str | None = Field(default=None)
age: int | None = Field(default=None) age: int | None = Field(default=None)
age_raw: str | None = Field(default=None) age_raw: str | None = Field(default=None)
family_details: str | None = Field(default=None) marital_status: str | None = Field(default=None)
degree: str | None = Field(default=None)
interview_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) university: str | None = Field(default=None)
interview_by: str | None = Field(default=None) experience: str | None = Field(default=None)
interview_time: str | None = Field(default=None) experience_details: str | None = Field(default=None)
interview_status: str | None = Field(default=None) area_of_residence: str | None = Field(default=None)
interview_notes: str | None = Field(default=None) communication_skills: int | None = Field(default=None)
interview_result: str | None = Field(default=None) preferred_timings: str | None = Field(default=None)
ho_availability: str | None = Field(default=None)
second_interview_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) current_company: str | None = Field(default=None)
second_interview_by: str | None = Field(default=None) reason_for_leaving: str | None = Field(default=None)
second_interview_time: str | None = Field(default=None) notice_period: str | None = Field(default=None)
second_interview_status: str | None = Field(default=None) current_salary: str | None = Field(default=None)
second_interview_notes: str | None = Field(default=None) current_salary_value: int | None = Field(default=None)
second_interview_result: str | None = Field(default=None) expected_salary: str | None = Field(default=None)
expected_salary_value: int | None = Field(default=None)
third_interview_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) pros: str | None = Field(default=None)
third_interview_by: str | None = Field(default=None) cons: str | None = Field(default=None)
third_interview_time: str | None = Field(default=None)
third_interview_status: str | None = Field(default=None)
third_interview_notes: str | None = Field(default=None)
third_interview_result: str | None = Field(default=None)
fourth_interview_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
fourth_interview_by: str | None = Field(default=None)
fourth_interview_time: str | None = Field(default=None)
fourth_interview_status: str | None = Field(default=None)
fourth_interview_notes: str | None = Field(default=None)
fourth_interview_result: str | None = Field(default=None)
raw_record: dict | None = Field(default=None, sa_column=Column(JSONB)) raw_record: dict | None = Field(default=None, sa_column=Column(JSONB))
row_number: int | None = Field(default=None)
imported_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) imported_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@ -72,19 +79,30 @@ class FormData(SQLModel, table=True):
if sheet: if sheet:
filters.append(cls.sheet == sheet) filters.append(cls.sheet == sheet)
if search: if search:
# Twelve unanchored ILIKEs over ~26k rows is a sequential scan of a few
# tens of ms — acceptable at this size; a pg_trgm GIN index is the
# upgrade if the sheet grows an order of magnitude.
pattern = f"%{search}%" pattern = f"%{search}%"
filters.append(or_( filters.append(or_(
cls.name.ilike(pattern), cls.name.ilike(pattern),
cls.candidate_email.ilike(pattern),
cls.candidate_number.ilike(pattern),
cls.screened_by.ilike(pattern),
cls.degree.ilike(pattern), cls.degree.ilike(pattern),
cls.university.ilike(pattern),
cls.experience.ilike(pattern), cls.experience.ilike(pattern),
cls.interview_by.ilike(pattern), cls.experience_details.ilike(pattern),
cls.current_company.ilike(pattern),
cls.position_suitable_for.ilike(pattern),
cls.area_of_expertise.ilike(pattern),
cls.source_of_application.ilike(pattern),
)) ))
return filters return filters
@classmethod @classmethod
async def get_form_data_by_id(cls, session: AsyncSession, record_id): async def get_form_data_by_id(cls, session: AsyncSession, record_id):
try: try:
rid = int(record_id) rid = uuid.UUID(str(record_id))
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
result = await session.execute(select(cls).where(cls.id == rid)) result = await session.execute(select(cls).where(cls.id == rid))
@ -129,12 +147,28 @@ class FormData(SQLModel, table=True):
return deleted return deleted
@classmethod @classmethod
async def insert_form_data_bulk(cls, session: AsyncSession, records: list[dict], *, commit: bool = True): async def insert_form_data_bulk(
rows = [cls(**fields) for fields in records] cls, session: AsyncSession, records: list[dict], *, commit: bool = True,
session.add_all(rows) ):
# Core insertmanyvalues — building ~26k ORM instances is the slow path.
# default_factory does not run on Core insert, so stamp timestamps here.
now = _now()
total = 0
for start in range(0, len(records), _BULK_CHUNK):
chunk = []
for fields in records[start:start + _BULK_CHUNK]:
row = dict(fields)
row.setdefault("id", uuid.uuid4())
row.setdefault("imported_at", now)
row.setdefault("created_at", now)
row.setdefault("updated_at", now)
chunk.append(row)
if chunk:
await session.execute(insert(cls), chunk)
total += len(chunk)
if commit: if commit:
await session.commit() await session.commit()
return len(rows) return total
@classmethod @classmethod
async def replace_sheet(cls, session: AsyncSession, sheet: str, records: list[dict]): async def replace_sheet(cls, session: AsyncSession, sheet: str, records: list[dict]):
@ -153,7 +187,9 @@ class SheetImportRun(SQLModel, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
status: str = Field(default="queued", index=True) # queued|running|completed|failed status: str = Field(default="queued", index=True) # queued|running|completed|failed
task_id: str | None = Field(default=None) task_id: str | None = Field(default=None)
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id") # Plain UUID — no ORM FK. Importing users.models pulls Users→Inbox relationships
# that the sheet worker does not load; the DB constraint still enforces integrity.
created_by: uuid.UUID | None = Field(default=None)
tab: str | None = Field(default=None) # None = import all tabs tab: str | None = Field(default=None) # None = import all tabs
report: dict | None = Field(default=None, sa_column=Column(JSONB)) report: dict | None = Field(default=None, sa_column=Column(JSONB))
error: str | None = Field(default=None) error: str | None = Field(default=None)

View File

@ -24,21 +24,11 @@ from googleapiclient.discovery import build
from googleapiclient.errors import HttpError from googleapiclient.errors import HttpError
from g_sheet.enums import ( from g_sheet.enums import (
ConductedByAlias, ALIAS_TO_FIELD,
DateFormat, DateFormat,
DateTimeSeparator, DateTimeSeparator,
FIELD_ALIAS_ENUMS,
FormDataField, FormDataField,
MonthNormalisation, MonthNormalisation,
NotesToken,
RoundByColumn,
RoundDateColumn,
RoundNotesColumn,
RoundOrdinal,
RoundResultColumn,
RoundRole,
RoundStatusColumn,
RoundTimeColumn,
) )
load_dotenv() load_dotenv()
@ -223,18 +213,27 @@ def rows_to_records(rows):
Sheets truncates trailing empties, so short rows are padded to header width. Sheets truncates trailing empties, so short rows are padded to header width.
Fully blank rows are dropped rather than emitted as all-empty records. Fully blank rows are dropped rather than emitted as all-empty records.
""" """
return [record for _,record in rows_to_indexed_records(rows)]
def rows_to_indexed_records(rows):
"""Sheet rows -> (1-based sheet row number, record) pairs.
Blank interior rows are skipped but do not shift later row numbers the index
is the true sheet row (header is row 1), which is half of the unique key.
"""
if not rows: if not rows:
return [] return []
headers=normalise_headers(rows[0]) headers=normalise_headers(rows[0])
records=[] indexed=[]
for row in rows[1:]: for offset,row in enumerate(rows[1:]):
values=[str(cell) if cell is not None else "" for cell in row] values=[str(cell) if cell is not None else "" for cell in row]
if not any(value.strip() for value in values): if not any(value.strip() for value in values):
continue continue
if len(values)<len(headers): if len(values)<len(headers):
values=values+[""]*(len(headers)-len(values)) values=values+[""]*(len(headers)-len(values))
records.append(dict(zip(headers,values[:len(headers)]))) indexed.append((offset+2,dict(zip(headers,values[:len(headers)]))))
return records return indexed
def stringify_rows(rows): def stringify_rows(rows):
@ -244,125 +243,62 @@ def stringify_rows(rows):
# -- FormData mapping ------------------------------------------------------ # -- FormData mapping ------------------------------------------------------
BY_FIELDS=RoundByColumn.ordered()
DATE_FIELDS=RoundDateColumn.ordered()
TIME_FIELDS=RoundTimeColumn.ordered()
STATUS_FIELDS=RoundStatusColumn.ordered()
NOTES_FIELDS=RoundNotesColumn.ordered()
RESULT_FIELDS=RoundResultColumn.ordered()
_ORDINAL_PATTERNS=tuple(
(member.slot,re.compile(rf"\b{member.pattern}\b",re.I))
for member in RoundOrdinal
)
_TIME_RE=re.compile(r"(\d{1,2}:\d{2}\s*(?:[AaPp][Mm])?)") _TIME_RE=re.compile(r"(\d{1,2}:\d{2}\s*(?:[AaPp][Mm])?)")
_DAY_ORDINAL_RE=re.compile(r"\b(\d+)(st|nd|rd|th)\b",re.I) _DAY_ORDINAL_RE=re.compile(r"\b(\d+)(st|nd|rd|th)\b",re.I)
_DIGIT_RE=re.compile(r"\d") _DIGIT_RE=re.compile(r"\d")
_AGE_RE=re.compile(r"\d+") _AGE_RE=re.compile(r"\d+")
_SCORE_RE=re.compile(r"\d+")
_SALARY_UNIT_RE=re.compile(
r"(?P<num>\d+(?:[.,]\d+)?)\s*(?P<unit>k|lac|lakh|lacs|lakhs|crore|crores)?\b",
re.I,
)
_CURRENCY_STRIP_RE=re.compile(r"(?:rs\.?|pkr|inr|usd|\$|€|£)",re.I)
# Every typed column key the mapper must emit (uniform dicts for bulk insert).
_FORM_DATA_COLUMN_KEYS=tuple(field.value for field in FormDataField)+(
"age_raw","current_salary_value","expected_salary_value",
)
def canonical_header(h): def canonical_header(h):
"""Lower, collapse whitespace (incl. embedded newlines), strip _N and (tails).""" """Lower, collapse whitespace (incl. embedded newlines), strip _N, (tails), trailing punct."""
text=str(h or "").replace("\n"," ").replace("\r"," ") text=str(h or "").replace("\n"," ").replace("\r"," ")
text=re.sub(r"\s+"," ",text).strip().lower() text=re.sub(r"\s+"," ",text).strip().lower()
text=re.sub(r"_\d+$","",text) text=re.sub(r"_\d+$","",text)
text=re.sub(r"\s*\([^)]*\)\s*$","",text).strip() text=re.sub(r"\s*\([^)]*\)\s*$","",text).strip()
text=text.rstrip("?:.,").strip()
return text return text
def match_field(h): def match_field(h):
"""Map a sheet header to a FormDataField, or None. """Map a sheet header to a FormDataField via exact alias lookup, or None."""
Exact alias first, then startswith. No fuzzy matching dirty headers mislabel
more often than they rescue, and a miss is non-fatal (value stays in JSONB).
"""
canon=canonical_header(h) canon=canonical_header(h)
if not canon: if not canon:
return None return None
for field,alias_enum in FIELD_ALIAS_ENUMS.items(): return ALIAS_TO_FIELD.get(canon)
if alias_enum.has(canon):
return field
for field,alias_enum in FIELD_ALIAS_ENUMS.items():
for alias in alias_enum:
if canon.startswith(alias.value):
return field
return None
def resolve_name(record,headers): def resolve_name(record,headers):
"""Candidate name: alias match, else column A (headers[0]) — always the name.""" """Candidate name: alias match, else first non-meta column (not Timestamp/date)."""
for header in headers: for header in headers:
if match_field(header)==FormDataField.NAME: if match_field(header)==FormDataField.NAME:
value=record.get(header) value=record.get(header)
if value is not None and str(value).strip(): if value is not None and str(value).strip():
return str(value).strip() return str(value).strip()
if headers: # Skip entry/meta columns so Google Form "Timestamp" is never treated as a name.
value=record.get(headers[0]) _skip={
FormDataField.ENTRY_DATE,FormDataField.ENTRY_TIME,
FormDataField.ENTRY_YEAR,FormDataField.ENTRY_MONTH,FormDataField.SERIAL_NO,
}
for header in headers:
if match_field(header) in _skip:
continue
value=record.get(header)
if value is not None and str(value).strip(): if value is not None and str(value).strip():
return str(value).strip() return str(value).strip()
return None return None
def _classify_round_role(canon):
"""RoundRole for a canonical header, or None for unrecognised headers."""
if not canon:
return None
if ConductedByAlias.contained_in(canon) or ConductedByAlias.has(canon):
return RoundRole.BY
if canon.startswith(ConductedByAlias.CONDUCTED.value):
return RoundRole.BY
if RoundRole.RESULT.value in canon:
return RoundRole.RESULT
if RoundRole.STATUS.value in canon:
return RoundRole.STATUS
if NotesToken.contained_in(canon):
return RoundRole.NOTES
if RoundRole.DATE.value in canon:
return RoundRole.DATE
return None
def _extract_ordinal(canon):
for slot,pattern in _ORDINAL_PATTERNS:
if pattern.search(canon):
return slot
return None
def resolve_round_columns(headers):
"""Positional interview-round map: scan left→right into four slots.
Ordinal in the header (`2nd`, `second`) pins the slot; otherwise the first free
slot for that role is taken, never moving backwards. A fifth Results_4 stays
unmapped (JSONB). Literal-date headers like `19-Feb-2026` classify as nothing.
"""
slots=[{role:None for role in RoundRole} for _ in range(4)]
cursor={role:0 for role in RoundRole}
for header in headers:
canon=canonical_header(header)
role=_classify_round_role(canon)
if role is None:
continue
ordinal=_extract_ordinal(canon)
if ordinal is not None:
if slots[ordinal][role] is None:
slots[ordinal][role]=header
continue
start=cursor[role]
chosen=None
for index in range(start,4):
if slots[index][role] is None:
chosen=index
break
if chosen is None:
continue
slots[chosen][role]=header
cursor[role]=chosen+1
return slots
def _normalise_month_spellings(text): def _normalise_month_spellings(text):
"""strptime %b rejects `Sept`; expand common sheet spellings first.""" """strptime %b rejects `Sept`; expand common sheet spellings first."""
lowered=text.lower() lowered=text.lower()
@ -404,7 +340,7 @@ def parse_date(value):
def parse_date_time(value): def parse_date_time(value):
"""(datetime|None, time_string|None) — fills *_time for the cells that carry one.""" """(datetime|None, time_string|None) — fills entry_time when the cell carries one."""
parsed=parse_date(value) parsed=parse_date(value)
if value is None: if value is None:
return parsed,None return parsed,None
@ -430,6 +366,53 @@ def parse_age(value):
return None,raw return None,raw
def parse_score(value):
"""First digit run kept only when 0 <= n <= 10 (communication skills scale)."""
if value is None:
return None
text=str(value).strip()
if not text:
return None
match=_SCORE_RE.search(text)
if not match:
return None
number=int(match.group())
if 0<=number<=10:
return number
return None
def parse_salary(value):
"""Numeric salary in whole currency units, or None for non-numeric cells.
Understands k/K, lac/lakh, crore; on a range takes the first number.
The raw cell text still goes to *_salary a None here loses nothing.
"""
if value is None:
return None
text=str(value).strip()
if not text:
return None
cleaned=_CURRENCY_STRIP_RE.sub(" ",text)
cleaned=cleaned.replace(",","")
match=_SALARY_UNIT_RE.search(cleaned)
if not match:
return None
raw_num=match.group("num").replace(",","")
try:
amount=float(raw_num)
except ValueError:
return None
unit=(match.group("unit") or "").lower()
if unit=="k":
amount*=1000
elif unit in ("lac","lakh","lacs","lakhs"):
amount*=100000
elif unit in ("crore","crores"):
amount*=10000000
return int(amount)
def _blank_to_none(value): def _blank_to_none(value):
if value is None: if value is None:
return None return None
@ -437,100 +420,95 @@ def _blank_to_none(value):
return text if text else None return text if text else None
def map_record_to_form_data(sheet,record,headers,row_number): def _header_field_map(headers):
"""Pure row mapper → kwargs dict for FormData(**...).""" """header -> FormDataField, first header that claims each field wins."""
rounds=resolve_round_columns(headers) claimed={}
mapped={ header_to_field={}
"sheet":sheet, for header in headers:
"row_number":row_number,
"raw_record":dict(record),
"name":_blank_to_none(resolve_name(record,headers)),
"degree":None,
"experience":None,
"age":None,
"age_raw":None,
"family_details":None,
}
for field in BY_FIELDS+TIME_FIELDS+STATUS_FIELDS+NOTES_FIELDS+RESULT_FIELDS:
mapped[field]=None
for field in DATE_FIELDS:
mapped[field]=None
for header,value in record.items():
field=match_field(header) field=match_field(header)
if field==FormDataField.DEGREE: if field is None or field in claimed:
mapped["degree"]=_blank_to_none(value) continue
elif field==FormDataField.EXPERIENCE: claimed[field]=header
mapped["experience"]=_blank_to_none(value) header_to_field[header]=field
elif field==FormDataField.AGE: return header_to_field
def map_record_to_form_data(sheet,record,headers,row_number):
"""Pure row mapper → kwargs dict for FormData (uniform keys for bulk insert)."""
mapped={key:None for key in _FORM_DATA_COLUMN_KEYS}
mapped["sheet"]=sheet
mapped["row_number"]=row_number
mapped["raw_record"]=dict(record)
mapped["name"]=_blank_to_none(resolve_name(record,headers))
for header,field in _header_field_map(headers).items():
value=record.get(header)
key=field.value
if field==FormDataField.AGE:
age,age_raw=parse_age(value) age,age_raw=parse_age(value)
mapped["age"]=age mapped["age"]=age
mapped["age_raw"]=age_raw mapped["age_raw"]=age_raw
elif field==FormDataField.FAMILY_DETAILS: elif field==FormDataField.ENTRY_DATE:
mapped["family_details"]=_blank_to_none(value) dt,tm=parse_date_time(value)
mapped["entry_date"]=dt
for index,slot in enumerate(rounds): if tm and not mapped.get("entry_time"):
if slot.get(RoundRole.DATE): mapped["entry_time"]=tm
dt,tm=parse_date_time(record.get(slot[RoundRole.DATE])) elif field==FormDataField.COMMUNICATION_SKILLS:
mapped[DATE_FIELDS[index]]=dt mapped["communication_skills"]=parse_score(value)
mapped[TIME_FIELDS[index]]=tm elif field==FormDataField.CURRENT_SALARY:
if slot.get(RoundRole.BY): mapped["current_salary"]=_blank_to_none(value)
mapped[BY_FIELDS[index]]=_blank_to_none(record.get(slot[RoundRole.BY])) mapped["current_salary_value"]=parse_salary(value)
if slot.get(RoundRole.STATUS): elif field==FormDataField.EXPECTED_SALARY:
mapped[STATUS_FIELDS[index]]=_blank_to_none(record.get(slot[RoundRole.STATUS])) mapped["expected_salary"]=_blank_to_none(value)
if slot.get(RoundRole.NOTES): mapped["expected_salary_value"]=parse_salary(value)
mapped[NOTES_FIELDS[index]]=_blank_to_none(record.get(slot[RoundRole.NOTES])) elif field==FormDataField.NAME:
if slot.get(RoundRole.RESULT): # resolve_name already set this; keep its column-A fallback behaviour.
mapped[RESULT_FIELDS[index]]=_blank_to_none(record.get(slot[RoundRole.RESULT])) continue
else:
mapped[key]=_blank_to_none(value)
return mapped return mapped
def collect_unmapped_headers(headers): def collect_unmapped_headers(headers):
"""Headers that are neither a typed alias nor claimed by a round slot. """Headers that do not exact-match any alias."""
return [header for header in headers if match_field(header) is None]
`title` aliases are included they have no FormData column and live in JSONB.
"""
rounds=resolve_round_columns(headers)
claimed=set()
for slot in rounds:
for role in RoundRole:
if slot.get(role):
claimed.add(slot[role])
unmapped=[]
for header in headers:
if header in claimed:
continue
field=match_field(header)
if field is None or field==FormDataField.TITLE:
unmapped.append(header)
return unmapped
def import_row_stats(mapped_rows,headers): def import_row_stats(mapped_rows,headers):
"""Aggregate parse diagnostics for an import report.""" """Aggregate parse diagnostics for an import report."""
unmapped=collect_unmapped_headers(headers)
dates_parsed=0 dates_parsed=0
dates_unparsed=0 dates_unparsed=0
ages_parsed=0 ages_parsed=0
salaries_parsed=0
# Find which raw header feeds entry_date (if any) once, not per row.
entry_date_header=None
for header in headers:
if match_field(header)==FormDataField.ENTRY_DATE:
entry_date_header=header
break
for row in mapped_rows: for row in mapped_rows:
raw=row.get("raw_record") or {} if entry_date_header is not None:
rounds=resolve_round_columns(headers) raw=row.get("raw_record") or {}
for index,slot in enumerate(rounds): cell=raw.get(entry_date_header)
header=slot.get(RoundRole.DATE) if cell is not None and str(cell).strip():
if not header: if row.get("entry_date") is not None:
continue dates_parsed+=1
cell=raw.get(header) elif _DIGIT_RE.search(str(cell)):
if cell is None or not str(cell).strip(): dates_unparsed+=1
continue
if row.get(DATE_FIELDS[index]) is not None:
dates_parsed+=1
elif _DIGIT_RE.search(str(cell)):
dates_unparsed+=1
if row.get("age") is not None: if row.get("age") is not None:
ages_parsed+=1 ages_parsed+=1
if (
row.get("current_salary_value") is not None
or row.get("expected_salary_value") is not None
):
salaries_parsed+=1
return { return {
"dates_parsed":dates_parsed, "dates_parsed":dates_parsed,
"dates_unparsed":dates_unparsed, "dates_unparsed":dates_unparsed,
"ages_parsed":ages_parsed, "ages_parsed":ages_parsed,
"unmapped_headers":collect_unmapped_headers(headers), "salaries_parsed":salaries_parsed,
"unmapped_headers":unmapped,
} }

View File

@ -2,6 +2,11 @@
from __future__ import annotations from __future__ import annotations
import uuid
from datetime import datetime
from g_sheet.enums import FORM_DATA_FIELDS
def serialize_metadata(payload: dict) -> dict: def serialize_metadata(payload: dict) -> dict:
"""spreadsheets.get response -> the spreadsheet header the UI renders.""" """spreadsheets.get response -> the spreadsheet header the UI renders."""
@ -99,45 +104,16 @@ def _iso(value):
def serialize_form_data(row) -> dict: def serialize_form_data(row) -> dict:
"""FormData ORM row → API dict, including raw_record.""" """FormData ORM row → API dict, including raw_record."""
return { out = {}
"id": row.id, for key in FORM_DATA_FIELDS:
"sheet": row.sheet, value = getattr(row, key)
"name": row.name, if isinstance(value, datetime):
"degree": row.degree, out[key] = _iso(value)
"experience": row.experience, elif isinstance(value, uuid.UUID):
"age": row.age, out[key] = str(value)
"age_raw": row.age_raw, else:
"family_details": row.family_details, out[key] = value
"interview_date": _iso(row.interview_date), return out
"interview_by": row.interview_by,
"interview_time": row.interview_time,
"interview_status": row.interview_status,
"interview_notes": row.interview_notes,
"interview_result": row.interview_result,
"second_interview_date": _iso(row.second_interview_date),
"second_interview_by": row.second_interview_by,
"second_interview_time": row.second_interview_time,
"second_interview_status": row.second_interview_status,
"second_interview_notes": row.second_interview_notes,
"second_interview_result": row.second_interview_result,
"third_interview_date": _iso(row.third_interview_date),
"third_interview_by": row.third_interview_by,
"third_interview_time": row.third_interview_time,
"third_interview_status": row.third_interview_status,
"third_interview_notes": row.third_interview_notes,
"third_interview_result": row.third_interview_result,
"fourth_interview_date": _iso(row.fourth_interview_date),
"fourth_interview_by": row.fourth_interview_by,
"fourth_interview_time": row.fourth_interview_time,
"fourth_interview_status": row.fourth_interview_status,
"fourth_interview_notes": row.fourth_interview_notes,
"fourth_interview_result": row.fourth_interview_result,
"raw_record": row.raw_record,
"row_number": row.row_number,
"imported_at": _iso(row.imported_at),
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
}
def serialize_import(report: dict) -> dict: def serialize_import(report: dict) -> dict:
@ -150,6 +126,7 @@ def serialize_import(report: dict) -> dict:
"dates_parsed": report.get("dates_parsed", 0), "dates_parsed": report.get("dates_parsed", 0),
"dates_unparsed": report.get("dates_unparsed", 0), "dates_unparsed": report.get("dates_unparsed", 0),
"ages_parsed": report.get("ages_parsed", 0), "ages_parsed": report.get("ages_parsed", 0),
"salaries_parsed": report.get("salaries_parsed", 0),
"unmapped_headers": report.get("unmapped_headers") or [], "unmapped_headers": report.get("unmapped_headers") or [],
"error": report.get("error"), "error": report.get("error"),
} }

View File

@ -1,4 +1,4 @@
"""Google Sheet → FormData import Taskiq tasks (shared inbox worker stream).""" """Google Sheet → FormData import Taskiq tasks (dedicated sheet_import stream)."""
from __future__ import annotations from __future__ import annotations
@ -12,7 +12,8 @@ from dotenv import load_dotenv
from db_setup import session_scope from db_setup import session_scope
from g_sheet.models import SheetImportRun from g_sheet.models import SheetImportRun
from g_sheet.views import Sheet from g_sheet.views import Sheet
from taskiq_management.broker_setup import MAX_RETRIES,RETRY_DELAY,broker from taskiq_management.broker_setup import MAX_RETRIES,RETRY_DELAY
from taskiq_management.g_sheet_broker_setup import sheet_broker
from taskiq_management.middleware import PermanentTaskError from taskiq_management.middleware import PermanentTaskError
load_dotenv() load_dotenv()
@ -33,7 +34,7 @@ async def _fail(run_id:str,error:str) -> dict:
return {"status":"failed","error":error} return {"status":"failed","error":error}
@broker.task( @sheet_broker.task(
task_name="g_sheet.import_sheets", task_name="g_sheet.import_sheets",
retry_on_error=True, retry_on_error=True,
max_retries=MAX_RETRIES, max_retries=MAX_RETRIES,

View File

@ -24,7 +24,9 @@ from g_sheet.plugins import (
import_row_stats, import_row_stats,
load_credentials, load_credentials,
map_record_to_form_data, map_record_to_form_data,
normalise_headers,
quote_tab, quote_tab,
rows_to_indexed_records,
rows_to_records, rows_to_records,
stringify_rows, stringify_rows,
) )
@ -202,17 +204,21 @@ class Sheet:
if not tab or not str(tab).strip(): if not tab or not str(tab).strip():
raise HTTPException(status_code=422,detail="tab is required") raise HTTPException(status_code=422,detail="tab is required")
tab=str(tab).strip() tab=str(tab).strip()
data=await self.read_records(tab) data=await self.read_range(tab)
records=data["records"] rows=data["rows"]
headers=data["headers"] if not rows:
mapped=[] return serialize_import({"tab":tab,"rows_read":0,"inserted":0,"deleted":0})
for index,record in enumerate(records): headers=normalise_headers(rows[0])
mapped.append(map_record_to_form_data(tab,record,headers,index+2)) indexed=rows_to_indexed_records(rows)
mapped=[
map_record_to_form_data(tab,record,headers,row_number)
for row_number,record in indexed
]
result=await FormData.replace_sheet(session,tab,mapped) result=await FormData.replace_sheet(session,tab,mapped)
stats=import_row_stats(mapped,headers) stats=import_row_stats(mapped,headers)
return serialize_import({ return serialize_import({
"tab":tab, "tab":tab,
"rows_read":len(records), "rows_read":len(indexed),
"inserted":result["inserted"], "inserted":result["inserted"],
"deleted":result["deleted"], "deleted":result["deleted"],
**stats, **stats,
@ -290,10 +296,11 @@ class Sheet:
}) })
from g_sheet.tasks import import_sheets from g_sheet.tasks import import_sheets
from taskiq_management.g_sheet_broker_setup import SHEET_QUEUE_NAME
task=await import_sheets.kicker().with_labels( task=await import_sheets.kicker().with_labels(
created_at=datetime.now(timezone.utc).isoformat(), created_at=datetime.now(timezone.utc).isoformat(),
correlation_id=str(row.id), correlation_id=str(row.id),
queue="inbox", queue=SHEET_QUEUE_NAME,
).kiq(str(row.id)) ).kiq(str(row.id))
row=await SheetImportRun.update_run(session,row.id,{"task_id":task.task_id}) row=await SheetImportRun.update_run(session,row.id,{"task_id":task.task_id})
return serialize_import_run(row) return serialize_import_run(row)

View File

@ -92,21 +92,8 @@ class Email:
async def triage_round(self,message_ids): async def triage_round(self,message_ids):
"""Fetch and classify a whole /email/fetch page, bounded by a semaphore. """Fetch and classify a whole /email/fetch page, bounded by a semaphore."""
Returns {message_id: decision}. The caller replays the page in upstream order,
so pending_match_ids and pending_confirmation_emails keep the exact sequence
they have today.
Only the upstream GET and the OpenAI call run concurrently, and nothing inside
the gather touches self.session Depends(get_session) yields ONE AsyncSession,
which cannot be shared across tasks. All DB work stays in the serial replay.
Two pre-filters run first and cost no tokens: a message already in
inbox_messages was judged an application once, and a message already in
inbox_message_triage has a stored verdict to replay. That is what makes a
repeated fetch free.
"""
ids=[str(m) for m in message_ids or [] if m] ids=[str(m) for m in message_ids or [] if m]
decisions={} decisions={}
if not ids: if not ids:

View File

@ -22,6 +22,7 @@ from search.app import router as search_router
from interview.app import router as interview_router from interview.app import router as interview_router
from talent.app import router as talent_router from talent.app import router as talent_router
from candidate_forms.app import router as candidate_forms_router from candidate_forms.app import router as candidate_forms_router
from g_sheet.app import router as g_sheet_router
logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s") logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s")
logger=logging.getLogger("main") logger=logging.getLogger("main")
@ -32,12 +33,14 @@ async def lifespan(app):
async with db_lifespan(app): async with db_lifespan(app):
broker_ready=False broker_ready=False
cv_broker_ready=False cv_broker_ready=False
sheet_broker_ready=False
llm_ready=False llm_ready=False
agent_ready=False agent_ready=False
close_llm=None close_llm=None
close_agent=None close_agent=None
broker=None broker=None
cv_broker=None cv_broker=None
sheet_broker=None
try: try:
from taskiq_management.broker_setup import broker as _broker from taskiq_management.broker_setup import broker as _broker
broker=_broker broker=_broker
@ -52,6 +55,13 @@ async def lifespan(app):
cv_broker_ready=True cv_broker_ready=True
except Exception as exc: except Exception as exc:
logger.warning("taskiq cv broker startup skipped: %s",exc) logger.warning("taskiq cv broker startup skipped: %s",exc)
try:
from taskiq_management.g_sheet_broker_setup import sheet_broker as _sheet_broker
sheet_broker=_sheet_broker
await sheet_broker.startup()
sheet_broker_ready=True
except Exception as exc:
logger.warning("taskiq sheet broker startup skipped: %s",exc)
try: try:
from llm_setup import init_llm,close_llm as _close_llm from llm_setup import init_llm,close_llm as _close_llm
from agent.agent_setup import init_agent,close_agent as _close_agent from agent.agent_setup import init_agent,close_agent as _close_agent
@ -77,6 +87,8 @@ async def lifespan(app):
logger.warning("classifier close skipped: %s",exc) logger.warning("classifier close skipped: %s",exc)
if llm_ready and close_llm is not None: if llm_ready and close_llm is not None:
await close_llm() await close_llm()
if sheet_broker_ready and sheet_broker is not None:
await sheet_broker.shutdown()
if cv_broker_ready and cv_broker is not None: if cv_broker_ready and cv_broker is not None:
await cv_broker.shutdown() await cv_broker.shutdown()
if broker_ready and broker is not None: if broker_ready and broker is not None:
@ -116,3 +128,4 @@ app.include_router(search_router)
app.include_router(interview_router) app.include_router(interview_router)
app.include_router(talent_router) app.include_router(talent_router)
app.include_router(candidate_forms_router) app.include_router(candidate_forms_router)
app.include_router(g_sheet_router)

View File

@ -45,3 +45,8 @@ langgraph==1.2.10 # StateGraph agent framework in agent/agent_setup.py
# pip install -e .. # pip install -e ..
# Its dependencies are already satisfied by the pins above. # Its dependencies are already satisfied by the pins above.
openpyxl==3.1.5 openpyxl==3.1.5
# --- Google Sheets (g_sheet/) ----------------------------------------------
google-api-python-client==2.198.0 # Sheets v4 client in g_sheet/plugins.py
google-auth==2.56.3 # ADC + refresh in g_sheet/plugins.py
google-auth-httplib2==0.4.1 # transport used by googleapiclient

View File

@ -0,0 +1,52 @@
"""Taskiq Google Sheet import broker — isolated Redis stream so sheet imports
never sit behind inbox sync / Outlook / CV work.
Worker: taskiq worker taskiq_management.g_sheet_broker_setup:sheet_broker g_sheet.tasks
"""
from __future__ import annotations
import os
from dotenv import load_dotenv
from taskiq.middlewares import SmartRetryMiddleware
from taskiq_redis import (
ListRedisScheduleSource,
RedisAsyncResultBackend,
RedisStreamBroker,
)
from taskiq_management.broker_setup import MAX_RETRIES,RETRY_DELAY
from taskiq_management.middleware import DeadLetterMiddleware
load_dotenv()
REDIS_URL=os.getenv("REDIS_URL","redis://localhost:6379/0")
SHEET_QUEUE_NAME=os.getenv("TASKIQ_SHEET_QUEUE_NAME","sheet_import")
result_backend=RedisAsyncResultBackend(redis_url=REDIS_URL)
sheet_schedule_source=ListRedisScheduleSource(
url=REDIS_URL,prefix="taskiq:schedule:sheet",
)
sheet_broker=(
RedisStreamBroker(
url=REDIS_URL,
queue_name=SHEET_QUEUE_NAME,
consumer_group_name=os.getenv("TASKIQ_CONSUMER_GROUP","taskiq"),
idle_timeout=int(os.getenv("TASKIQ_IDLE_TIMEOUT_MS","600000")),
)
.with_result_backend(result_backend)
.with_middlewares(
DeadLetterMiddleware(redis_url=REDIS_URL),
SmartRetryMiddleware(
default_retry_count=MAX_RETRIES,
default_retry_label=True,
default_delay=RETRY_DELAY,
use_jitter=True,
use_delay_exponent=True,
max_delay_exponent=float(os.getenv("TASKIQ_MAX_DELAY","120")),
schedule_source=sheet_schedule_source,
),
)
)

View File

@ -0,0 +1,290 @@
"""Unit tests for g_sheet/plugins.py — mapping against the real 32-column sheet.
No DB, no network. Pins header aliases, parsers, and the Bilal sample row.
"""
from __future__ import annotations
from datetime import datetime, timezone
from g_sheet import plugins
from g_sheet.enums import FORM_DATA_FIELDS, HEADER_ALIASES, FormDataField
from g_sheet.models import FormData
# Real header row from "PK - Recruitment Tracking Sheet" (32 columns).
HEADERS = [
"um",
"Year",
"Month",
"Date",
"Time of Entry",
"Screened By",
"Candidate Name",
"HR comments",
"Candidate Number",
"Candidate Email",
"Profile Link",
"Area of Expertise",
"Requisition Number",
"Position Suitable For",
"Source of Application",
"Age",
"Marital Status",
"Education",
"University of Graduation ",
"Experience",
"Experience Details",
"Area of Residence",
"Communication Skills\n(01 to 10)",
"Preferred Timings?",
"Availability to work in the H.O",
"Current Company",
"Reason for Leaving",
"How soon can you join",
"Current Salary",
"Expected Salary",
"Pros",
"Cons",
]
EXPECTED_FIELDS = (
FormDataField.SERIAL_NO,
FormDataField.ENTRY_YEAR,
FormDataField.ENTRY_MONTH,
FormDataField.ENTRY_DATE,
FormDataField.ENTRY_TIME,
FormDataField.SCREENED_BY,
FormDataField.NAME,
FormDataField.HR_COMMENTS,
FormDataField.CANDIDATE_NUMBER,
FormDataField.CANDIDATE_EMAIL,
FormDataField.PROFILE_LINK,
FormDataField.AREA_OF_EXPERTISE,
FormDataField.REQUISITION_NUMBER,
FormDataField.POSITION_SUITABLE_FOR,
FormDataField.SOURCE_OF_APPLICATION,
FormDataField.AGE,
FormDataField.MARITAL_STATUS,
FormDataField.DEGREE,
FormDataField.UNIVERSITY,
FormDataField.EXPERIENCE,
FormDataField.EXPERIENCE_DETAILS,
FormDataField.AREA_OF_RESIDENCE,
FormDataField.COMMUNICATION_SKILLS,
FormDataField.PREFERRED_TIMINGS,
FormDataField.HO_AVAILABILITY,
FormDataField.CURRENT_COMPANY,
FormDataField.REASON_FOR_LEAVING,
FormDataField.NOTICE_PERIOD,
FormDataField.CURRENT_SALARY,
FormDataField.EXPECTED_SALARY,
FormDataField.PROS,
FormDataField.CONS,
)
SAMPLE_RECORD = {
"um": "1",
"Year": "2021",
"Month": "June",
"Date": "23-Jun-2021",
"Time of Entry": "10:30 AM",
"Screened By": "Sara",
"Candidate Name": "Muhammad Bilal Khan",
"HR comments": "Good profile",
"Candidate Number": "0303-2892503",
"Candidate Email": "bilal_kf@yahoo.com",
"Profile Link": "https://example.com/bilal",
"Area of Expertise": "Software Development",
"Requisition Number": "REQ-1",
"Position Suitable For": "Backend Engineer",
"Source of Application": "Referral",
"Age": "33",
"Marital Status": "Married",
"Education": "BS CS",
"University of Graduation ": "NUST",
"Experience": "11 Years",
"Experience Details": "Software development related experience",
"Area of Residence": "Islamabad",
"Communication Skills\n(01 to 10)": "7",
"Preferred Timings?": "Morning",
"Availability to work in the H.O": "Yes",
"Current Company": "Acme",
"Reason for Leaving": "Growth",
"How soon can you join": "1 month",
"Current Salary": "110k",
"Expected Salary": "150k",
"Pros": "Strong backend",
"Cons": "Limited cloud",
}
def test_every_real_header_maps_and_none_are_unmapped():
assert len(HEADERS) == 32
for header, field in zip(HEADERS, EXPECTED_FIELDS):
assert plugins.match_field(header) == field, header
assert plugins.collect_unmapped_headers(HEADERS) == []
def test_experience_details_does_not_overwrite_experience():
mapped = plugins.map_record_to_form_data("tab", SAMPLE_RECORD, HEADERS, 2)
assert mapped["experience"] == "11 Years"
assert mapped["experience_details"] == "Software development related experience"
def test_candidate_number_and_email_land_in_own_columns():
mapped = plugins.map_record_to_form_data("tab", SAMPLE_RECORD, HEADERS, 2)
assert mapped["candidate_number"] == "0303-2892503"
assert mapped["candidate_email"] == "bilal_kf@yahoo.com"
assert mapped["name"] == "Muhammad Bilal Khan"
def test_date_maps_to_entry_date_not_interview_round():
mapped = plugins.map_record_to_form_data("tab", SAMPLE_RECORD, HEADERS, 2)
assert mapped["entry_date"] == datetime(2021, 6, 23, tzinfo=timezone.utc)
assert "interview_date" not in mapped
def test_marital_status_only_lands_in_marital_status():
mapped = plugins.map_record_to_form_data("tab", SAMPLE_RECORD, HEADERS, 2)
assert mapped["marital_status"] == "Married"
assert "family_details" not in mapped
assert "interview_status" not in mapped
def test_canonical_header_strips_parens_question_and_trailing_space():
assert plugins.canonical_header("Communication Skills\n(01 to 10)") == "communication skills"
assert plugins.canonical_header("Preferred Timings?") == "preferred timings"
assert plugins.canonical_header("University of Graduation ") == "university of graduation"
def test_parse_salary_and_score():
assert plugins.parse_salary("110k") == 110000
assert plugins.parse_salary("60k-70k") == 60000
assert plugins.parse_salary("Negotiable") is None
assert plugins.parse_score("7") == 7
assert plugins.parse_score("15") is None
def test_rows_to_indexed_records_keeps_true_sheet_row_across_blank():
rows = [
["Name", "Age"],
["Ada", "30"],
["", ""],
["Bob", "40"],
]
indexed = plugins.rows_to_indexed_records(rows)
assert indexed == [
(2, {"Name": "Ada", "Age": "30"}),
(4, {"Name": "Bob", "Age": "40"}),
]
# read API contract still drops blanks without exposing indices
assert plugins.rows_to_records(rows) == [
{"Name": "Ada", "Age": "30"},
{"Name": "Bob", "Age": "40"},
]
def test_no_alias_string_appears_under_two_fields():
seen: dict[str, FormDataField] = {}
for field, aliases in HEADER_ALIASES.items():
for alias in aliases:
assert alias not in seen, f"{alias!r} under {seen[alias]} and {field}"
seen[alias] = field
def test_form_data_fields_match_model():
assert set(FORM_DATA_FIELDS) == set(FormData.model_fields)
def test_sample_row_end_to_end():
mapped = plugins.map_record_to_form_data(
"PK - Recruitment Tracking Sheet", SAMPLE_RECORD, HEADERS, 2,
)
assert mapped["experience"] == "11 Years"
assert mapped["experience_details"] == "Software development related experience"
assert mapped["candidate_email"] == "bilal_kf@yahoo.com"
assert mapped["age"] == 33
assert mapped["current_salary_value"] == 110000
assert mapped["communication_skills"] == 7
assert mapped["pros"] == "Strong backend"
assert mapped["cons"] == "Limited cloud"
assert mapped["row_number"] == 2
assert mapped["sheet"] == "PK - Recruitment Tracking Sheet"
assert mapped.get("job_post_id") is None
# Google Form Responses tab — headers differ from the PK screening sheet.
FORM_RESPONSE_HEADERS = [
"Timestamp",
"Email",
"Full Name",
"Gender",
"Marital Status",
"University",
"Educational Degree",
"Year of Graduation",
"Position Applied For",
"LinkedIn Profile Link",
"Drop your updated resume",
"How soon can you join us?",
"Phone number (03XX-XXXXXXX)",
"Are you willing to relocate?",
"Residing City",
"Residing Country",
"Area of Interest",
"Recruiter",
"Where did you hear about the position you're applying for?",
]
FORM_RESPONSE_RECORD = {
"Timestamp": "7/2/2026 17:50:19",
"Email": "nusratazra@gmail.com",
"Full Name": "Nusrat Azra",
"Gender": "Female",
"Marital Status": "Single",
"University": "Karachi University",
"Educational Degree": "Masters",
"Year of Graduation": "12/2/2007",
"Position Applied For": "Executive Secretary",
"LinkedIn Profile Link": "https://www.linkedin.com/in/nusrat-azra-executive-manager-to-c-suite-3bb86719/",
"Drop your updated resume": "https://drive.google.com/open?id=1l5HOY5R6KiL6270A_sV56FkdX6EIGfI4",
"How soon can you join us?": "1 - 2 weeks",
"Phone number (03XX-XXXXXXX)": "03312464228",
"Are you willing to relocate?": "Yes",
"Residing City": "Karachi",
"Residing Country": "Pakistan",
"Area of Interest": "",
"Recruiter": "",
"Where did you hear about the position you're applying for?": "Indeed",
}
def test_form_response_headers_map_to_typed_columns():
mapped = plugins.map_record_to_form_data(
"Form Responses - Candidate Database Sheet 2026",
FORM_RESPONSE_RECORD,
FORM_RESPONSE_HEADERS,
9,
)
assert mapped["name"] == "Nusrat Azra"
assert mapped["candidate_email"] == "nusratazra@gmail.com"
assert mapped["candidate_number"] == "03312464228"
assert mapped["degree"] == "Masters"
assert mapped["university"] == "Karachi University"
assert mapped["position_suitable_for"] == "Executive Secretary"
assert mapped["notice_period"] == "1 - 2 weeks"
assert mapped["source_of_application"] == "Indeed"
assert mapped["ho_availability"] == "Yes"
assert mapped["marital_status"] == "Single"
assert mapped["area_of_residence"] == "Karachi" # first matching residence header wins
assert mapped["profile_link"] == (
"https://www.linkedin.com/in/nusrat-azra-executive-manager-to-c-suite-3bb86719/"
)
assert mapped["entry_year"] == "12/2/2007"
assert mapped["entry_date"] is not None
assert mapped["entry_time"] == "17:50:19"
# Timestamp must never be used as the candidate name.
assert mapped["name"] != "7/2/2026 17:50:19"
assert mapped["job_post_id"] is None
assert mapped["raw_record"]["Full Name"] == "Nusrat Azra"

View File

@ -72,3 +72,8 @@ services:
- ./backend:/app - ./backend:/app
- ./app:/app/app - ./app:/app/app
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments - ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments
taskiq-sheet-worker:
volumes:
- ./backend:/app
- ./app:/app/app

View File

@ -285,6 +285,24 @@ services:
<<: *backend-env <<: *backend-env
TASKIQ_CV_QUEUE_NAME: cv_upload TASKIQ_CV_QUEUE_NAME: cv_upload
# Dedicated stream: Google Sheet → FormData import must not block inbox/CV/mailbox.
taskiq-sheet-worker:
<<: *backend-service
container_name: hrms-taskiq-sheet-worker
command:
[
"taskiq",
"worker",
"taskiq_management.g_sheet_broker_setup:sheet_broker",
"g_sheet.tasks",
"--workers",
"1",
]
environment:
<<: *backend-env
TASKIQ_SHEET_QUEUE_NAME: sheet_import
TASKIQ_WORKER_NAME: sheet-worker-01
# Dedicated stream: Outlook pull/triage must not block match/ATS or CV uploads. # Dedicated stream: Outlook pull/triage must not block match/ATS or CV uploads.
taskiq-mailbox-sync-worker: taskiq-mailbox-sync-worker:
<<: *backend-service <<: *backend-service