From c726bbacbc0a0be295adff710445a33c737b6c5b Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Tue, 11 Aug 2026 17:48:00 +0500 Subject: [PATCH] pedning testinf --- backend/inbox/models.py | 101 +++++++--- backend/job/activity/serializers.py | 10 + backend/job/activity/views.py | 55 ++++++ backend/job/app.py | 273 ++++++++++++++++++++++++- backend/job/candidate/models.py | 275 ++++++++++++++++++++++---- backend/job/candidate/plugins.py | 43 ++++ backend/job/candidate/serializers.py | 43 +++- backend/job/candidate/views.py | 95 ++++++++- backend/job/feedback/serializers.py | 14 ++ backend/job/feedback/views.py | 63 ++++++ backend/job/interviews/serializers.py | 9 + backend/job/interviews/views.py | 42 ++++ backend/job/job_post/serializers.py | 1 + backend/job/notes/serializers.py | 11 ++ backend/job/notes/views.py | 62 ++++++ backend/users/models.py | 21 +- 16 files changed, 1034 insertions(+), 84 deletions(-) create mode 100644 backend/job/activity/serializers.py create mode 100644 backend/job/activity/views.py create mode 100644 backend/job/feedback/serializers.py create mode 100644 backend/job/feedback/views.py create mode 100644 backend/job/interviews/serializers.py create mode 100644 backend/job/interviews/views.py create mode 100644 backend/job/notes/serializers.py create mode 100644 backend/job/notes/views.py diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 492becb..bb2bb7a 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -1,9 +1,8 @@ -from ast import List import logging import os import uuid from datetime import datetime, timezone -from typing import Any, Optional +from typing import Any, List, Optional from dotenv import load_dotenv from fastapi import HTTPException @@ -16,8 +15,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlmodel import Field, Relationship, SQLModel, select, true -from job.candidate.models import Interviews -from users.models import Users, Activity, Notes +from job.candidate.models import Activity, Feedback, Interviews +from users.models import Users from users.plugins import hash_password load_dotenv() @@ -45,35 +44,24 @@ class Inbox(SQLModel, table=True): created_at: datetime = Field(default_factory=datetime.now) updated_at: datetime = Field(default_factory=datetime.now) - - favorite:Optional[bool] = Field(default=False) - rating:Optional[float] = Field(default=0.0) - notes_id: Optional[uuid.UUID] = Field( - default=None, - foreign_key="notes.id", - ) - notes: List[Notes] = Relationship( - back_populates="inbox", - sa_relationship_kwargs={"lazy": "selectin"} - ) - interview_id: Optional[uuid.UUID] = Field( - default=None, - foreign_key="interviews.id", - ) - interview: List[Interviews] = Relationship( - back_populates="inbox", - sa_relationship_kwargs={"lazy": "selectin"} - ) + favorite: Optional[bool] = Field(default=False) + rating: Optional[float] = Field(default=0.0) - activity_id: Optional[uuid.UUID] = Field( - default=None, - foreign_key="activity.id" - ) - activity: Optional[Activity] = Relationship( + # selectin on one-to-many: joined would repeat the inbox row per child + interviews: List["Interviews"] = Relationship( back_populates="inbox", + sa_relationship_kwargs={"lazy": "selectin"}, ) - Optional[Users] = Relationship( + activity: List["Activity"] = Relationship( + back_populates="inbox", + sa_relationship_kwargs={"lazy": "selectin"}, + ) + feedback: List["Feedback"] = Relationship( + back_populates="inbox", + sa_relationship_kwargs={"lazy": "selectin"}, + ) + user: Optional["Users"] = Relationship( back_populates="inbox", sa_relationship_kwargs={"lazy": "joined"}, ) @@ -86,9 +74,16 @@ class Inbox(SQLModel, table=True): @classmethod async def get_candidate_profile(cls,session:AsyncSession,user_id:uuid.UUID|None=None,limit:int=10,offset:int=0,search:str|None=None): try: + options=[selectinload(cls.messages)] + if user_id: + options.extend([ + selectinload(cls.interviews), + selectinload(cls.activity), + selectinload(cls.feedback), + ]) qry = ( select(cls) - .options(selectinload(cls.messages)) + .options(*options) .join(Users, cls.user_id == Users.id) .join(Roles, Users.role_id == Roles.id) .where(Roles.role_name == EnumRoles.CANDIDATE.value) @@ -126,6 +121,52 @@ class Inbox(SQLModel, table=True): except Exception as e: raise HTTPException(status_code=500,detail=str(e)) + @classmethod + async def get_inbox_by_id(cls,session:AsyncSession,record_id:int|str|None): + if record_id is None: + return None + try: + iid=int(record_id) + except (TypeError,ValueError): + return None + result=await session.execute(select(cls).where(cls.id==iid)) + return result.scalars().first() + + @classmethod + async def get_inbox_by_message_id(cls,session:AsyncSession,message_id): + try: + mid=uuid.UUID(str(message_id)) + except ValueError: + return None + result=await session.execute( + select(cls).where(cls.message_id==mid).order_by(cls.created_at.desc()) + ) + return result.scalars().first() + + @classmethod + async def get_inbox_by_user_id(cls,session:AsyncSession,user_id): + try: + uid=uuid.UUID(str(user_id)) + except ValueError: + return None + result=await session.execute( + select(cls).where(cls.user_id==uid).order_by(cls.created_at.desc()) + ) + return result.scalars().first() + + @classmethod + async def update_inbox(cls,session:AsyncSession,record_id,fields:dict): + row=await cls.get_inbox_by_id(session,record_id) + if not row: + return None + for key,value in fields.items(): + setattr(row,key,value) + row.updated_at=datetime.now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + class Inbox_Alerts(SQLModel, table=True): __tablename__ = "inbox_alerts" diff --git a/backend/job/activity/serializers.py b/backend/job/activity/serializers.py new file mode 100644 index 0000000..c14f962 --- /dev/null +++ b/backend/job/activity/serializers.py @@ -0,0 +1,10 @@ +def serialize_activity(row) -> dict: + return { + "id": str(row.id), + "inbox_id": row.inbox_id, + "activity_type": row.activity_type, + "activity_date": row.activity_date.isoformat() if row.activity_date else None, + "activity_time": row.activity_time.isoformat() if row.activity_time else None, + "activity_status": row.activity_status, + "description": row.description, + } diff --git a/backend/job/activity/views.py b/backend/job/activity/views.py new file mode 100644 index 0000000..7339692 --- /dev/null +++ b/backend/job/activity/views.py @@ -0,0 +1,55 @@ +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from inbox.models import Inbox +from job.candidate.models import Activity +from job.activity.serializers import serialize_activity + + +class ActivityLog: + def __init__(self,session:AsyncSession): + self.session=session + + async def _resolve_inbox(self,payload): + if payload.get("inbox_id") is not None: + row=await Inbox.get_inbox_by_id(self.session,payload["inbox_id"]) + if not row: + raise HTTPException(status_code=404,detail="Inbox not found") + return row + if payload.get("message_id"): + row=await Inbox.get_inbox_by_message_id(self.session,payload["message_id"]) + if not row: + raise HTTPException(status_code=404,detail="Inbox not found for message_id") + return row + if payload.get("user_id"): + row=await Inbox.get_inbox_by_user_id(self.session,payload["user_id"]) + if not row: + raise HTTPException(status_code=404,detail="Inbox not found for user_id") + return row + return None + + async def get_activity(self,activity_id=None,inbox_id=None): + if activity_id: + row=await Activity.get_activity_by_id(self.session,activity_id) + if not row: + raise HTTPException(status_code=404,detail="Activity not found") + return serialize_activity(row) + if inbox_id is None: + raise HTTPException(status_code=400,detail="activity_id or inbox_id is required") + rows=await Activity.get_activity_by_inbox(self.session,int(inbox_id)) + return [serialize_activity(r) for r in rows] + + async def create_activity(self,payload): + link=await self._resolve_inbox(payload) + fields={ + "activity_type":payload.get("activity_type") or "", + "activity_status":payload.get("activity_status") or "", + "description":payload.get("description"), + "inbox_id":link.id if link else None, + } + if payload.get("activity_date") is not None: + fields["activity_date"]=payload["activity_date"] + if payload.get("activity_time") is not None: + fields["activity_time"]=payload["activity_time"] + row=await Activity.insert_activity(self.session,fields) + return serialize_activity(row) diff --git a/backend/job/app.py b/backend/job/app.py index 2477bab..c4ef5e6 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -3,6 +3,10 @@ from fastapi.responses import JSONResponse from fastapi import HTTPException from db_setup import get_session from job.candidate.views import FileRead,CandidateView +from job.interviews.views import Interview +from job.notes.views import Note +from job.activity.views import ActivityLog +from job.feedback.views import FeedbackView from sqlalchemy.ext.asyncio import AsyncSession from users.permissions import PermissionTag, require_permission from job.job_post.views import JobPost,JobPostCreate @@ -11,6 +15,8 @@ from job.job_post.plugins import PlatformAlias from fastapi import UploadFile, File from dotenv import load_dotenv from datetime import datetime, time, timezone +from pydantic import BaseModel +from uuid import UUID load_dotenv() logging.basicConfig(level=logging.INFO) @@ -19,6 +25,65 @@ logger = logging.getLogger(__name__) router = APIRouter() +class CandidateUpdate(BaseModel): + favorite: bool | None = None + rating: float | None = None + + +class InterviewCreate(BaseModel): + inbox_id: int + interview_date: datetime | None = None + interview_time: datetime | None = None + interview_type: str | None = None + interview_status: str | None = None + + +class InterviewUpdate(BaseModel): + interview_date: datetime | None = None + interview_time: datetime | None = None + interview_type: str | None = None + interview_status: str | None = None + inbox_id: int | None = None + + +class NoteCreate(BaseModel): + user_id: UUID + note: str + + +class NoteUpdate(BaseModel): + note: str | None = None + + +class ActivityCreate(BaseModel): + message_id: UUID | None = None + user_id: UUID | None = None + inbox_id: int | None = None + activity_type: str | None = None + activity_status: str | None = None + description: str | None = None + activity_date: datetime | None = None + activity_time: datetime | None = None + + +class FeedbackCreate(BaseModel): + inbox_id: int | None = None + review: str | None = None + financial_status: str | None = None + score: float | None = None + note: str | None = None + reviewed_by: UUID | None = None + + +class FeedbackUpdate(BaseModel): + review: str | None = None + financial_status: str | None = None + score: float | None = None + note: str | None = None + inbox_id: int | None = None + reviewed_by: UUID | None = None + + @router.get("/jobs/alias") async def get_job_alias(): @@ -115,11 +180,215 @@ async def fetch_candidate( try: service=CandidateView(session=session) data=await service.get_candidate(user_id=user_id,limit=limit,offset=offset,search=search) - # total is the RESULT-SET size, not len(data) — a pager cannot be driven - # off the page length. By id stays 1, per the house envelope. + + total=await service.count_candidates(user_id=user_id,search=search) if isinstance(data,list) else 1 return JSONResponse(content={"data":data,"total":total,"status_code":200}) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500,detail=str(e)) + + +@router.patch("/candidate/update") +async def update_candidate( + user_id:str=Query(...), + payload:CandidateUpdate=..., + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)), + session: AsyncSession = Depends(get_session), +): + try: + service=CandidateView(session=session) + data=await service.update_candidate(user_id,payload.model_dump(exclude_unset=True)) + return JSONResponse(content={"data":data,"total":1,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/interview/fetch") +async def fetch_interview( + interview_id:str=Query(None), + inbox_id:int=Query(None), + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=Interview(session=session) + data=await service.get_interview(interview_id=interview_id,inbox_id=inbox_id) + total=1 if isinstance(data,dict) else len(data) + return JSONResponse(content={"data":data,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/interview/create") +async def create_interview( + payload:InterviewCreate, + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)), + session: AsyncSession = Depends(get_session), +): + try: + service=Interview(session=session) + data=await service.create_interview(payload.model_dump(exclude_unset=True)) + return JSONResponse(content={"data":data,"total":1,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.patch("/interview/update") +async def update_interview( + interview_id:str=Query(...), + payload:InterviewUpdate=..., + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)), + session: AsyncSession = Depends(get_session), +): + try: + service=Interview(session=session) + data=await service.update_interview(interview_id,payload.model_dump(exclude_unset=True)) + return JSONResponse(content={"data":data,"total":1,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/notes/fetch") +async def fetch_notes( + note_id:str=Query(None), + user_id:str=Query(None), + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=Note(session=session) + data=await service.get_note(note_id=note_id,user_id=user_id) + total=1 if isinstance(data,dict) else len(data) + return JSONResponse(content={"data":data,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/notes/create") +async def create_note( + payload:NoteCreate, + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)), + session: AsyncSession = Depends(get_session), +): + try: + service=Note(session=session) + data=await service.create_note(payload.model_dump(exclude_unset=True),current_user) + return JSONResponse(content={"data":data,"total":1,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.patch("/notes/update") +async def update_note( + note_id:str=Query(...), + payload:NoteUpdate=..., + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)), + session: AsyncSession = Depends(get_session), +): + try: + service=Note(session=session) + data=await service.update_note(note_id,payload.model_dump(exclude_unset=True)) + return JSONResponse(content={"data":data,"total":1,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/activity/fetch") +async def fetch_activity( + activity_id:str=Query(None), + inbox_id:int=Query(None), + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=ActivityLog(session=session) + data=await service.get_activity(activity_id=activity_id,inbox_id=inbox_id) + total=1 if isinstance(data,dict) else len(data) + return JSONResponse(content={"data":data,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/activity/create") +async def create_activity( + payload:ActivityCreate, + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)), + session: AsyncSession = Depends(get_session), +): + try: + service=ActivityLog(session=session) + data=await service.create_activity(payload.model_dump(exclude_unset=True)) + return JSONResponse(content={"data":data,"total":1,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/feedback/fetch") +async def fetch_feedback( + feedback_id:str=Query(None), + inbox_id:int=Query(None), + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=FeedbackView(session=session) + data=await service.get_feedback(feedback_id=feedback_id,inbox_id=inbox_id) + total=1 if isinstance(data,dict) else len(data) + return JSONResponse(content={"data":data,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/feedback/create") +async def create_feedback( + payload:FeedbackCreate, + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)), + session: AsyncSession = Depends(get_session), +): + try: + service=FeedbackView(session=session) + data=await service.create_feedback(payload.model_dump(exclude_unset=True),current_user) + return JSONResponse(content={"data":data,"total":1,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.patch("/feedback/update") +async def update_feedback( + feedback_id:str=Query(...), + payload:FeedbackUpdate=..., + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)), + session: AsyncSession = Depends(get_session), +): + try: + service=FeedbackView(session=session) + data=await service.update_feedback(feedback_id,payload.model_dump(exclude_unset=True)) + return JSONResponse(content={"data":data,"total":1,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 5312f26..4503977 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -1,67 +1,258 @@ -from time import timezone -from token import OP -from sqlmodel import SQLModel, Field -from uuid import UUID, uuid4 -from datetime import datetime -from enum import Enum import uuid -from typing import Optional,List -from sqlmodel import Relationship -from typing import TYPE_CHECKING +from datetime import datetime, timezone +from typing import TYPE_CHECKING, List, Optional + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import Field, Relationship, SQLModel, select + if TYPE_CHECKING: from inbox.models import Inbox from users.models import Users -class Interviews(SQLModel,table=True): + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class Interviews(SQLModel, table=True): __tablename__ = "interviews" - id: UUID = Field(default_factory=uuid4, primary_key=True) - interview_date: datetime = Field(default_factory=datetime.now) - interview_time: datetime = Field(default_factory=datetime.now) + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + interview_date: datetime = Field(default_factory=_now) + interview_time: datetime = Field(default_factory=_now) interview_type: str = Field(default="") interview_status: str = Field(default="") - inbox_id: Optional[uuid.UUID] = Field(default=None, foreign_key="inbox.id") - inbox: Optional[Inbox] = Relationship(back_populates="interview", - sa_relationship_kwargs={"lazy": "joined"} + inbox_id: int | None = Field(default=None, foreign_key="inbox.id") + inbox: Optional["Inbox"] = Relationship( + back_populates="interviews", + sa_relationship_kwargs={"lazy": "selectin"}, ) + @staticmethod + def _as_uuid(record_id) -> uuid.UUID | None: + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None -class Notes(SQLModel,table=True): + @classmethod + async def get_interview_by_id(cls, session: AsyncSession, record_id): + uid = cls._as_uuid(record_id) + if uid is None: + return None + result = await session.execute(select(cls).where(cls.id == uid)) + return result.scalars().first() + + @classmethod + async def get_interviews_by_inbox(cls, session: AsyncSession, inbox_id: int): + result = await session.execute( + select(cls).where(cls.inbox_id == inbox_id).order_by(cls.interview_date.desc()) + ) + return result.scalars().all() + + @classmethod + async def insert_interview(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return await cls.get_interview_by_id(session, row.id) + + @classmethod + async def update_interview(cls, session: AsyncSession, record_id, fields: dict): + row = await cls.get_interview_by_id(session, record_id) + if not row: + return None + for key, value in fields.items(): + setattr(row, key, value) + session.add(row) + await session.commit() + await session.refresh(row) + return row + + +class Notes(SQLModel, table=True): __tablename__ = "notes" - id: UUID = Field(default_factory=uuid4, primary_key=True) + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) note: str = Field(default="") - created_at: datetime = Field(default_factory=datetime.now) - updated_at: datetime = Field(default_factory=datetime.now) - inbox_id: Optional[uuid.UUID] = Field(default=None, foreign_key="inbox.id") - inbox: Optional[Inbox] = Relationship(back_populates="notes", - sa_relationship_kwargs={"lazy": "joined"} + created_at: datetime = Field(default_factory=_now) + updated_at: datetime = Field(default_factory=_now) + user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") + created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id") + user: Optional["Users"] = Relationship( + back_populates="notes", + sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[Notes.user_id]"}, ) - user_id: Optional[uuid.UUID] = Field(default=None, foreign_key="users.id") - user: Optional[Users] = Relationship(back_populates="notes", - sa_relationship_kwargs={"lazy": "joined"} + author: Optional["Users"] = Relationship( + back_populates="authored_notes", + sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[Notes.created_by]"}, ) -class Activity(SQLModel,table=True): + @staticmethod + def _as_uuid(record_id) -> uuid.UUID | None: + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_note_by_id(cls, session: AsyncSession, record_id): + uid = cls._as_uuid(record_id) + if uid is None: + return None + result = await session.execute(select(cls).where(cls.id == uid)) + return result.scalars().first() + + @classmethod + async def get_notes_by_user(cls, session: AsyncSession, user_id): + uid = cls._as_uuid(user_id) + if uid is None: + return [] + result = await session.execute( + select(cls).where(cls.user_id == uid).order_by(cls.created_at.desc()) + ) + return result.scalars().all() + + @classmethod + async def insert_note(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return await cls.get_note_by_id(session, row.id) + + @classmethod + async def update_note(cls, session: AsyncSession, record_id, fields: dict): + row = await cls.get_note_by_id(session, record_id) + if not row: + return None + for key, value in fields.items(): + setattr(row, key, value) + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + +class Activity(SQLModel, table=True): __tablename__ = "activity" - id: UUID = Field(default_factory=uuid4, primary_key=True) + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) activity_type: str = Field(default="") - activity_date: datetime = Field(default_factory=datetime.now) - activity_time: datetime = Field(default_factory=datetime.now) + activity_date: datetime = Field(default_factory=_now) + activity_time: datetime = Field(default_factory=_now) activity_status: str = Field(default="") - inbox_id: Optional[uuid.UUID] = Field(default=None, foreign_key="inbox.id") - inbox: Optional[Inbox] = Relationship(back_populates="activity", - sa_relationship_kwargs={"lazy": "joined"} + description: str | None = Field(default=None) + inbox_id: int | None = Field(default=None, foreign_key="inbox.id") + inbox: Optional["Inbox"] = Relationship( + back_populates="activity", + sa_relationship_kwargs={"lazy": "selectin"}, ) -class Feedback(SQLModel,table=True): + @staticmethod + def _as_uuid(record_id) -> uuid.UUID | None: + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_activity_by_id(cls, session: AsyncSession, record_id): + uid = cls._as_uuid(record_id) + if uid is None: + return None + result = await session.execute(select(cls).where(cls.id == uid)) + return result.scalars().first() + + @classmethod + async def get_activity_by_inbox(cls, session: AsyncSession, inbox_id: int): + result = await session.execute( + select(cls).where(cls.inbox_id == inbox_id).order_by(cls.activity_date.desc()) + ) + return result.scalars().all() + + @classmethod + async def insert_activity(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return await cls.get_activity_by_id(session, row.id) + + @classmethod + async def update_activity(cls, session: AsyncSession, record_id, fields: dict): + row = await cls.get_activity_by_id(session, record_id) + if not row: + return None + for key, value in fields.items(): + setattr(row, key, value) + session.add(row) + await session.commit() + await session.refresh(row) + return row + + +class Feedback(SQLModel, table=True): __tablename__ = "feedback" - id: UUID = Field(default_factory=uuid4, primary_key=True) + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) review: str = Field(default="") financial_status: str = Field(default="") score: float = Field(default=0.0) - note:str=Field(default=None) - created_at:datetime=Field(default_factory=datetime.now(timezone.utc)) - updated_at:datetime=Field(default_factory=datetime.now(timezone.utc)) - reviewed_by:Optional[uuid.UUID]=Field(default=None,foreign_key="users.id") - user:Optional[Users]=Relationship(back_populates="feedback", - sa_relationship_kwargs={"lazy":"joined"} - ) \ No newline at end of file + note: str | None = Field(default=None) + created_at: datetime = Field(default_factory=_now) + updated_at: datetime = Field(default_factory=_now) + reviewed_by: uuid.UUID | None = Field(default=None, foreign_key="users.id") + inbox_id: int | None = Field(default=None, foreign_key="inbox.id") + user: Optional["Users"] = Relationship( + back_populates="feedback", + sa_relationship_kwargs={"lazy": "selectin"}, + ) + inbox: Optional["Inbox"] = Relationship( + back_populates="feedback", + sa_relationship_kwargs={"lazy": "selectin"}, + ) + + @staticmethod + def _as_uuid(record_id) -> uuid.UUID | None: + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_feedback_by_id(cls, session: AsyncSession, record_id): + uid = cls._as_uuid(record_id) + if uid is None: + return None + result = await session.execute(select(cls).where(cls.id == uid)) + return result.scalars().first() + + @classmethod + async def get_feedback_by_inbox(cls, session: AsyncSession, inbox_id: int): + result = await session.execute( + select(cls).where(cls.inbox_id == inbox_id).order_by(cls.created_at.desc()) + ) + return result.scalars().all() + + @classmethod + async def insert_feedback(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return await cls.get_feedback_by_id(session, row.id) + + @classmethod + async def update_feedback(cls, session: AsyncSession, record_id, fields: dict): + row = await cls.get_feedback_by_id(session, record_id) + if not row: + return None + for key, value in fields.items(): + setattr(row, key, value) + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + +import users.models as _users_models # noqa: E402, F401 diff --git a/backend/job/candidate/plugins.py b/backend/job/candidate/plugins.py index f545b15..94d02fa 100644 --- a/backend/job/candidate/plugins.py +++ b/backend/job/candidate/plugins.py @@ -28,3 +28,46 @@ def normalize_spaced_text(text) -> str: lines = [re.sub(r" {2,}", " ", line).strip() for line in text.splitlines()] return re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip() + +# Mirrors frontend Inbox.jsx sourceFrom — board name is tagged in the To address. +INBOX_SOURCES = ( + "Microsoft Outlook", + "Career Portal", + "Manual CV Upload", + "LinkedIn", + "Indeed", + "Rozee", + "Mustakbil", + "Employee Referral", + "Recruitment Agency", + "Campus Hiring", + "Walk-in", +) + + +def _letters_only(value: str) -> str: + return re.sub(r"[^a-z]", "", (value or "").lower()) + + +def source_from_message_to(message_to: str | None) -> str: + raw = (message_to or "").strip() + if not raw: + return "Unknown" + flat = _letters_only(raw) + for name in INBOX_SOURCES: + if _letters_only(name) and _letters_only(name) in flat: + return name + return raw.split(",")[0].strip() + + +def documents_from_message(file_name: str | None, file_path: str | None) -> list[dict]: + names = [n.strip() for n in (file_name or "").split(",") if n.strip()] + paths = [p.strip() for p in (file_path or "").split(",") if p.strip()] + out = [] + for i, name in enumerate(names): + out.append({"name": name, "path": paths[i] if i < len(paths) else None}) + if not out and paths: + for path in paths: + out.append({"name": path.rsplit("/", 1)[-1].rsplit("\\", 1)[-1], "path": path}) + return out + diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index 79b80fd..d097423 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -1,16 +1,25 @@ from inbox.models import Inbox from typing import Any,List,Dict +from job.candidate.plugins import documents_from_message, source_from_message_to +from job.interviews.serializers import serialize_interview +from job.activity.serializers import serialize_activity +from job.feedback.serializers import serialize_feedback -def serialize_candidate_profile(link:Inbox|List[Inbox]|Dict[str,Any]|List[Dict[str,Any]]) -> Dict[str,Any]|List[Dict[str,Any]]: + +def serialize_candidate_profile( + link:Inbox|List[Inbox]|Dict[str,Any]|List[Dict[str,Any]], + *, + detail:bool=False, +) -> Dict[str,Any]|List[Dict[str,Any]]: if isinstance(link,list): - return [serialize_candidate_profile(item) for item in link] + return [serialize_candidate_profile(item,detail=detail) for item in link] if isinstance(link,dict): return link user = link.user message = link.messages - return { + payload = { "inbox_id": link.id, "user_id": str(link.user_id) if link.user_id else None, "name": user.name if user else None, @@ -30,3 +39,31 @@ def serialize_candidate_profile(link:Inbox|List[Inbox]|Dict[str,Any]|List[Dict[s "matched_at": message.matched_at.isoformat() if message and message.matched_at else None, "job_posts": [], } + if not detail: + return payload + + payload.update({ + "favorite": link.favorite, + "rating": link.rating, + "phone": message.candidate_phone_number if message else None, + "education": message.candidate_education if message else None, + "currentCompany": message.current_employment if message else None, + "stage": message.application_status if message else None, + "source": source_from_message_to(message.message_to if message else None), + "applied": message.message_received_time if message else None, + "documents": documents_from_message( + message.file_name if message else None, + message.file_path if message else None, + ), + "recruiter": None, + "recruiter_id": None, + "job_title": None, + "ai_score": None, + "recommendation": None, + "sub_scores": None, + "interviews": [serialize_interview(r) for r in (link.interviews or [])], + "activity": [serialize_activity(r) for r in (link.activity or [])], + "feedback": [serialize_feedback(r) for r in (link.feedback or [])], + "notes": [], + }) + return payload diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index abdcca3..07997d1 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -4,10 +4,13 @@ from datetime import datetime,timezone from fastapi import HTTPException from pypdf import PdfReader from sqlalchemy import select +from sqlalchemy.orm import selectinload from sqlmodel import true from job.job_post.models import JobPosts from job.job_post.serializers import serialize_job_post from job.candidate.serializers import serialize_candidate_profile +from job.candidate.models import Notes +from job.notes.serializers import serialize_note from inbox.models import Inbox_Messages,Inbox from job.candidate.plugins import normalize_spaced_text @@ -77,7 +80,12 @@ class CandidateView: async def get_candidate(self,user_id=None,limit=10,offset=0,search=None): try: - rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=limit,offset=offset,search=search) + detail=bool(user_id) + # Detail mode must see every application for the candidate, not one page. + fetch_limit=1000 if detail else limit + rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=fetch_limit,offset=offset,search=search) + if detail: + return await self.attach_profile_detail(rows) return await self.attach_job_posts(rows) except HTTPException: raise @@ -92,6 +100,26 @@ class CandidateView: except Exception as e: raise HTTPException(status_code=500,detail=str(e)) + async def update_candidate(self,user_id,payload): + try: + if not user_id: + raise HTTPException(status_code=400,detail="user_id is required") + fields={k:v for k,v in (payload or {}).items() if k in ("favorite","rating") and v is not None} + if not fields: + raise HTTPException(status_code=400,detail="favorite or rating is required") + links=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=100,offset=0) + records=links if isinstance(links,list) else ([links] if links else []) + if not records: + raise HTTPException(status_code=404,detail="Candidate not found") + for link in records: + await Inbox.update_inbox(self.session,link.id,fields) + refreshed=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=100,offset=0) + return await self.attach_profile_detail(refreshed) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + async def get_job_post_by_id(self,record_id,data=None): """Load full job_posts row and optionally append it onto a candidate payload.""" try: @@ -101,6 +129,11 @@ class CandidateView: payload=serialize_job_post(job_post_data) if isinstance(data,dict): data.setdefault("job_posts",[]).append(payload) + if data.get("recruiter") is None and payload.get("created_by_name"): + data["recruiter"]=payload.get("created_by_name") + data["recruiter_id"]=payload.get("created_by") + if data.get("job_title") is None and payload.get("title"): + data["job_title"]=payload.get("title") return payload except Exception as e: raise HTTPException(status_code=500,detail=str(e)) @@ -117,3 +150,63 @@ class CandidateView: await self.get_job_post_by_id(record_id=job_id,data=payload) enriched.append(payload) return enriched[0] if single else enriched + + async def attach_profile_detail(self,data): + """Detail mode: flatten child collections across every Inbox row for the candidate.""" + single=not isinstance(data,list) + records=[data] if single else list(data or []) + if not records: + return {} if single else [] + + interviews=[] + activity=[] + feedback=[] + documents=[] + job_posts=[] + base=None + user_id=None + favorite=None + rating=None + for record in records: + payload=serialize_candidate_profile(record,detail=True) + if base is None: + base=payload + user_id=payload.get("user_id") + favorite=payload.get("favorite") + rating=payload.get("rating") + interviews.extend(payload.get("interviews") or []) + activity.extend(payload.get("activity") or []) + feedback.extend(payload.get("feedback") or []) + documents.extend(payload.get("documents") or []) + for job_id in payload.get("suggested_job_post_ids") or []: + await self.get_job_post_by_id(record_id=job_id,data=payload) + for jp in payload.get("job_posts") or []: + if not any(x.get("id")==jp.get("id") for x in job_posts): + job_posts.append(jp) + if base.get("recruiter") is None and payload.get("recruiter"): + base["recruiter"]=payload.get("recruiter") + base["recruiter_id"]=payload.get("recruiter_id") + if base.get("job_title") is None and payload.get("job_title"): + base["job_title"]=payload.get("job_title") + + notes=[] + uid=Notes._as_uuid(user_id) if user_id else None + if uid is not None: + result=await self.session.execute( + select(Notes) + .options(selectinload(Notes.author)) + .where(Notes.user_id==uid) + .order_by(Notes.created_at.desc()) + ) + notes=[serialize_note(r) for r in result.scalars().all()] + + activity.sort(key=lambda r:(r.get("activity_date") or ""),reverse=True) + base["favorite"]=favorite + base["rating"]=rating + base["interviews"]=interviews + base["activity"]=activity + base["feedback"]=feedback + base["documents"]=documents + base["notes"]=notes + base["job_posts"]=job_posts or base.get("job_posts") or [] + return base diff --git a/backend/job/feedback/serializers.py b/backend/job/feedback/serializers.py new file mode 100644 index 0000000..6248a85 --- /dev/null +++ b/backend/job/feedback/serializers.py @@ -0,0 +1,14 @@ +def serialize_feedback(row) -> dict: + reviewer=getattr(row,"user",None) + return { + "id": str(row.id), + "inbox_id": row.inbox_id, + "review": row.review, + "financial_status": row.financial_status, + "score": row.score, + "note": row.note, + "reviewed_by": str(row.reviewed_by) if row.reviewed_by else None, + "reviewed_by_name": reviewer.name if reviewer else None, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + } diff --git a/backend/job/feedback/views.py b/backend/job/feedback/views.py new file mode 100644 index 0000000..b8b1b61 --- /dev/null +++ b/backend/job/feedback/views.py @@ -0,0 +1,63 @@ +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload +from sqlmodel import select + +from job.candidate.models import Feedback +from job.feedback.serializers import serialize_feedback + + +class FeedbackView: + def __init__(self,session:AsyncSession): + self.session=session + + async def _load(self,record_id): + uid=Feedback._as_uuid(record_id) + if uid is None: + return None + result=await self.session.execute( + select(Feedback).options(selectinload(Feedback.user)).where(Feedback.id==uid) + ) + return result.scalars().first() + + async def get_feedback(self,feedback_id=None,inbox_id=None): + if feedback_id: + row=await self._load(feedback_id) + if not row: + raise HTTPException(status_code=404,detail="Feedback not found") + return serialize_feedback(row) + if inbox_id is None: + raise HTTPException(status_code=400,detail="feedback_id or inbox_id is required") + result=await self.session.execute( + select(Feedback) + .options(selectinload(Feedback.user)) + .where(Feedback.inbox_id==int(inbox_id)) + .order_by(Feedback.created_at.desc()) + ) + return [serialize_feedback(r) for r in result.scalars().all()] + + async def create_feedback(self,payload,current_user): + fields={ + "review":payload.get("review") or "", + "financial_status":payload.get("financial_status") or "", + "score":payload.get("score") if payload.get("score") is not None else 0.0, + "note":payload.get("note"), + "inbox_id":payload.get("inbox_id"), + "reviewed_by":payload.get("reviewed_by") or ( + current_user.get("id") if isinstance(current_user,dict) else None + ), + } + row=await Feedback.insert_feedback(self.session,fields) + row=await self._load(row.id) + return serialize_feedback(row) + + async def update_feedback(self,feedback_id,payload): + allowed=("review","financial_status","score","note","inbox_id","reviewed_by") + fields={k:v for k,v in payload.items() if v is not None and k in allowed} + if not fields: + raise HTTPException(status_code=400,detail="No fields to update") + row=await Feedback.update_feedback(self.session,feedback_id,fields) + if not row: + raise HTTPException(status_code=404,detail="Feedback not found") + row=await self._load(row.id) + return serialize_feedback(row) diff --git a/backend/job/interviews/serializers.py b/backend/job/interviews/serializers.py new file mode 100644 index 0000000..eb319a9 --- /dev/null +++ b/backend/job/interviews/serializers.py @@ -0,0 +1,9 @@ +def serialize_interview(row) -> dict: + return { + "id": str(row.id), + "inbox_id": row.inbox_id, + "interview_date": row.interview_date.isoformat() if row.interview_date else None, + "interview_time": row.interview_time.isoformat() if row.interview_time else None, + "interview_type": row.interview_type, + "interview_status": row.interview_status, + } diff --git a/backend/job/interviews/views.py b/backend/job/interviews/views.py new file mode 100644 index 0000000..6956da4 --- /dev/null +++ b/backend/job/interviews/views.py @@ -0,0 +1,42 @@ +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from job.candidate.models import Interviews +from job.interviews.serializers import serialize_interview + + +class Interview: + def __init__(self,session:AsyncSession): + self.session=session + + async def get_interview(self,interview_id=None,inbox_id=None): + if interview_id: + row=await Interviews.get_interview_by_id(self.session,interview_id) + if not row: + raise HTTPException(status_code=404,detail="Interview not found") + return serialize_interview(row) + if inbox_id is None: + raise HTTPException(status_code=400,detail="interview_id or inbox_id is required") + rows=await Interviews.get_interviews_by_inbox(self.session,int(inbox_id)) + return [serialize_interview(r) for r in rows] + + async def create_interview(self,payload): + fields={ + "interview_date":payload.get("interview_date"), + "interview_time":payload.get("interview_time"), + "interview_type":payload.get("interview_type") or "", + "interview_status":payload.get("interview_status") or "", + "inbox_id":payload.get("inbox_id"), + } + fields={k:v for k,v in fields.items() if v is not None} + row=await Interviews.insert_interview(self.session,fields) + return serialize_interview(row) + + async def update_interview(self,interview_id,payload): + fields={k:v for k,v in payload.items() if v is not None} + if not fields: + raise HTTPException(status_code=400,detail="No fields to update") + row=await Interviews.update_interview(self.session,interview_id,fields) + if not row: + raise HTTPException(status_code=404,detail="Interview not found") + return serialize_interview(row) diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index d58e3ad..53d737a 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -21,6 +21,7 @@ def serialize_job_post(row) -> dict: "status": row.status, "buffer_error": row.buffer_error, "created_by": str(row.created_by) if row.created_by else None, + "created_by_name": row.user.name if getattr(row, "user", None) else None, "created_at": row.created_at.isoformat() if row.created_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None, } diff --git a/backend/job/notes/serializers.py b/backend/job/notes/serializers.py new file mode 100644 index 0000000..9cd2f2f --- /dev/null +++ b/backend/job/notes/serializers.py @@ -0,0 +1,11 @@ +def serialize_note(row) -> dict: + author=getattr(row,"author",None) + return { + "id": str(row.id), + "note": row.note, + "user_id": str(row.user_id) if row.user_id else None, + "created_by": str(row.created_by) if row.created_by else None, + "created_by_name": author.name if author else None, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + } diff --git a/backend/job/notes/views.py b/backend/job/notes/views.py new file mode 100644 index 0000000..433431c --- /dev/null +++ b/backend/job/notes/views.py @@ -0,0 +1,62 @@ +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload +from sqlmodel import select + +from job.candidate.models import Notes +from job.notes.serializers import serialize_note + + +class Note: + def __init__(self,session:AsyncSession): + self.session=session + + async def _load(self,record_id): + uid=Notes._as_uuid(record_id) + if uid is None: + return None + result=await self.session.execute( + select(Notes).options(selectinload(Notes.author)).where(Notes.id==uid) + ) + return result.scalars().first() + + async def get_note(self,note_id=None,user_id=None): + if note_id: + row=await self._load(note_id) + if not row: + raise HTTPException(status_code=404,detail="Note not found") + return serialize_note(row) + if not user_id: + raise HTTPException(status_code=400,detail="note_id or user_id is required") + uid=Notes._as_uuid(user_id) + if uid is None: + raise HTTPException(status_code=400,detail="Invalid user_id") + result=await self.session.execute( + select(Notes) + .options(selectinload(Notes.author)) + .where(Notes.user_id==uid) + .order_by(Notes.created_at.desc()) + ) + return [serialize_note(r) for r in result.scalars().all()] + + async def create_note(self,payload,current_user): + fields={ + "note":payload.get("note") or "", + "user_id":payload.get("user_id"), + "created_by":current_user.get("id") if isinstance(current_user,dict) else None, + } + if not fields["user_id"]: + raise HTTPException(status_code=400,detail="user_id is required") + row=await Notes.insert_note(self.session,fields) + row=await self._load(row.id) + return serialize_note(row) + + async def update_note(self,note_id,payload): + fields={k:v for k,v in payload.items() if v is not None and k in ("note",)} + if not fields: + raise HTTPException(status_code=400,detail="No fields to update") + row=await Notes.update_note(self.session,note_id,fields) + if not row: + raise HTTPException(status_code=404,detail="Note not found") + row=await self._load(row.id) + return serialize_note(row) diff --git a/backend/users/models.py b/backend/users/models.py index d578548..d84b1b6 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -7,13 +7,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlmodel import Field, Relationship, SQLModel, select -from backend.job.candidate.models import Feedback from role.models import Roles from job.job_post.models import JobPosts if TYPE_CHECKING: # runtime import would cycle: inbox.models imports this module from inbox.models import Inbox - from job.candidate.models import Notes + from job.candidate.models import Feedback, Notes + class Users(SQLModel, table=True): __tablename__ = "users" @@ -35,11 +35,17 @@ class Users(SQLModel, table=True): back_populates="user", sa_relationship_kwargs={"lazy": "selectin"}, ) - feedback:Optional[Feedback]=Relationship(back_populates="user", - sa_relationship_kwargs={"lazy": "selectin"}, + feedback: List["Feedback"] = Relationship( + back_populates="user", + sa_relationship_kwargs={"lazy": "selectin"}, ) - notes: Optional[Notes] = Relationship(back_populates="user", - sa_relationship_kwargs={"lazy": "selectin"}, + notes: List["Notes"] = Relationship( + back_populates="user", + sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[Notes.user_id]"}, + ) + authored_notes: List["Notes"] = Relationship( + back_populates="author", + sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[Notes.created_by]"}, ) password: str @@ -143,3 +149,6 @@ class Users(SQLModel, table=True): await session.commit() await session.refresh(user) return user + + +import job.candidate.models as _candidate_models # noqa: E402, F401