From c47a380d3df81a2068e101050ba62c0d0142777a Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Mon, 3 Aug 2026 19:10:54 +0500 Subject: [PATCH] commiot --- .gitignore | 6 +- backend/db_setup.py | 2 +- backend/inbox/app.py | 37 +++++++-- backend/inbox/file_decoder.py | 151 ++++++++++++++++++++++++++++++++++ backend/inbox/models.py | 73 ++++++++++++++++ backend/inbox/plugins.py | 1 + backend/inbox/views.py | 44 ++++++++++ backend/main.py | 2 +- backend/role/models.py | 30 +++++++ backend/users/app.py | 14 ++++ backend/users/models.py | 30 +++++++ docker-compose.yml | 61 ++++++++++++++ 12 files changed, 442 insertions(+), 9 deletions(-) create mode 100644 backend/inbox/file_decoder.py create mode 100644 backend/inbox/plugins.py create mode 100644 backend/role/models.py create mode 100644 backend/users/app.py create mode 100644 backend/users/models.py create mode 100644 docker-compose.yml diff --git a/.gitignore b/.gitignore index 893658f..4229f9b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,8 @@ .DS_Store .AppleDouble .LSOverride -Icon ? +Icon +? ._* # Editor / IDE @@ -11,7 +12,8 @@ Icon ? *.swp *.swo *~ - +**pycache__/ +**pycache** # Claude / local AI tooling .claude/ .audit.js diff --git a/backend/db_setup.py b/backend/db_setup.py index 7759cf4..2b79826 100644 --- a/backend/db_setup.py +++ b/backend/db_setup.py @@ -55,7 +55,7 @@ class Settings(BaseSettings): db_sslmode: str = "" # e.g. "require" on Azure - db_schemas: Annotated[list[str], NoDecode] = ["app", "ref", "audit", "ai", "staging"] + db_schemas: Annotated[list[str], NoDecode] = "app" db_default_schema: str = "app" # schema for models that declare none db_echo: bool = False db_pool_size: int = 5 diff --git a/backend/inbox/app.py b/backend/inbox/app.py index 5480427..dce4ede 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -1,8 +1,35 @@ -from fastapi import APIRouter -import httpx +from fastapi import APIRouter,Depends, Query +from fastapi.responses import JSONResponse +from fastapi import HTTPException from db_setup import get_session +from sqlalchemy.ext.asyncio import AsyncSession +from inbox.views import Email +from dotenv import load_dotenv +load_dotenv() + router = APIRouter() -@router.get("/inbox") -async def get_inbox(): - return {"message": "Hello, World!"} \ No newline at end of file +@router.get("/email/fetch") +async def fetch_email(top:int=Query(100),skip:int=Query(0,ge=0),token=Query(...),session: AsyncSession = Depends(get_session)): + try: + if not token: + raise HTTPException(status_code=401,detail="Unauthorized") + service=Email(session=session,token=token) + data=await service.service_email(top,skip) + value=data.get("value") + items_lst=[] + for item in value: + message_id=item.get("id") + service_per_email=await service.get_email_by_id(message_id) + items_lst.append({"message_id":message_id,"email_contents":service_per_email}) + + + return JSONResponse(content={"data":items_lst,"status_code":200}) + + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/inbox/fetch") +async def fetch_inbox(session: AsyncSession = Depends(get_session)): + pass diff --git a/backend/inbox/file_decoder.py b/backend/inbox/file_decoder.py new file mode 100644 index 0000000..b642b98 --- /dev/null +++ b/backend/inbox/file_decoder.py @@ -0,0 +1,151 @@ +"""Decode Graph fileAttachment contentBytes into PDF / DOC / DOCX files.""" + +from __future__ import annotations + +import asyncio +import base64 +import binascii +import io +import zipfile +from pathlib import Path +from typing import Any + + +class AttachmentDecodeError(ValueError): + """Raised when contentBytes is malformed or is not the expected format.""" + + +_DEFAULT_OUT_DIR = Path(__file__).resolve().parent / "decoded_attachments" + + +def _decode_bytes(attachment: dict) -> bytes: + """base64 -> raw bytes, with an integrity check against declared size.""" + b64 = attachment.get("contentBytes") + if not b64: + raise AttachmentDecodeError(f"{attachment.get('name')!r}: no contentBytes") + + try: + raw = base64.b64decode(b64, validate=True) + except binascii.Error as exc: + raise AttachmentDecodeError( + f"{attachment.get('name')!r}: bad base64: {exc}" + ) from exc + + declared = attachment.get("size") + if declared is not None and len(raw) != declared: + raise AttachmentDecodeError( + f"{attachment.get('name')!r}: declared {declared} B, decoded {len(raw)} B" + ) + return raw + + +def _write(out_dir: Path, name: str, raw: bytes) -> Path: + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + dest = out_dir / Path(name).name # basename only — strip path traversal + dest.write_bytes(raw) + return dest + + +def decode_pdf(attachment: dict, out_dir: str | Path) -> Path: + """Decode a PDF attachment and write it under out_dir.""" + raw = _decode_bytes(attachment) + name = attachment.get("name") + if not raw.startswith(b"%PDF-"): + raise AttachmentDecodeError(f"{name!r}: not a PDF (missing %PDF- header)") + if b"%%EOF" not in raw[-2048:]: + raise AttachmentDecodeError(f"{name!r}: not a PDF (missing %%EOF trailer)") + return _write(Path(out_dir), name or "attachment.pdf", raw) + + +def decode_docx(attachment: dict, out_dir: str | Path) -> Path: + """Decode a DOCX attachment and write it under out_dir.""" + raw = _decode_bytes(attachment) + name = attachment.get("name") + if not raw.startswith(b"PK\x03\x04"): + raise AttachmentDecodeError(f"{name!r}: not a DOCX (missing ZIP signature)") + + bio = io.BytesIO(raw) + if not zipfile.is_zipfile(bio): + raise AttachmentDecodeError(f"{name!r}: not a DOCX (invalid ZIP)") + bio.seek(0) + with zipfile.ZipFile(bio) as zf: + if not any(member.startswith("word/") for member in zf.namelist()): + raise AttachmentDecodeError(f"{name!r}: not a DOCX (no word/ entry)") + + return _write(Path(out_dir), name or "attachment.docx", raw) + + +def decode_doc(attachment: dict, out_dir: str | Path) -> Path: + """Decode a legacy DOC (OLE2) attachment and write it under out_dir.""" + raw = _decode_bytes(attachment) + name = attachment.get("name") + if raw.startswith(b"PK\x03\x04"): + raise AttachmentDecodeError( + f"{name!r}: named .doc but content is DOCX — use decode_docx" + ) + ole2 = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + if not raw.startswith(ole2): + raise AttachmentDecodeError(f"{name!r}: not a DOC (missing OLE2 signature)") + return _write(Path(out_dir), name or "attachment.doc", raw) + + +_DECODERS = { + ".pdf": decode_pdf, + ".docx": decode_docx, + ".doc": decode_doc, +} + + +def _decode_one(attachment: dict, out_dir: str | Path) -> Path: + """Route on the file extension to the right decoder.""" + ext = Path(attachment.get("name", "")).suffix.lower() + if ext not in _DECODERS: + raise AttachmentDecodeError(f"unsupported extension {ext!r}") + return _DECODERS[ext](attachment, out_dir) + + +def _normalize_attachments(attachments: Any) -> list[dict]: + """Accept None, a single dict, or a list; return only dict items.""" + if attachments is None: + return [] + if isinstance(attachments, dict): + return [attachments] + if isinstance(attachments, list): + return [a for a in attachments if isinstance(a, dict)] + return [] + + +def _decode_attachments_sync( + attachments: Any, + out_dir: str | Path | None = None, +) -> list[str]: + """Decode supported file attachments; skip empty / non-file / unsupported.""" + dest_dir = Path(out_dir) if out_dir is not None else _DEFAULT_OUT_DIR + paths: list[str] = [] + + for attachment in _normalize_attachments(attachments): + # Graph itemAttachment / referenceAttachment have no contentBytes + if not attachment.get("contentBytes"): + continue + ext = Path(attachment.get("name") or "").suffix.lower() + if ext not in _DECODERS: + continue + path = _decode_one(attachment, dest_dir) + paths.append(str(path)) + + return paths + + +async def decode_attachment( + attachments: Any, + out_dir: str | Path | None = None, +) -> list[str]: + """ + Decode Graph attachments into files under out_dir. + + Designed for views: ``await decode_attachment(data.get("attachments"))``. + Accepts None, a single attachment dict, or a list of attachment dicts. + Returns absolute/relative path strings for successfully written files. + """ + return await asyncio.to_thread(_decode_attachments_sync, attachments, out_dir) diff --git a/backend/inbox/models.py b/backend/inbox/models.py index e69de29..85ab894 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -0,0 +1,73 @@ +from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Boolean, UUID +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import relationship +from sqlmodel import SQLModel +from datetime import datetime +from users.models import Users +from sqlalchemy.ext.asyncio import AsyncSession +import uuid + +class Inbox(SQLModel,table=True): + id=Column(Integer,primary_key=True,autoincrement=True) + user_id = Column(UUID,ForeignKey("users.id")) + + alert_id=Column(UUID,ForeignKey("inbox_alerts.id")) + + alerts=relationship("Inbox_Alerts",back_populates="inbox",foreign_keys=[alert_id]) + + message_id=Column(UUID,ForeignKey("inbox_messages.id")) + + messages=relationship("Inbox_Messages",back_populates="inbox",foreign_keys=[message_id]) + + created_at=Column(DateTime,default=datetime.now) + updated_at=Column(DateTime,default=datetime.now) + # is_active=Column(Boolean,default=True) + # is_deleted=Column(Boolean,default=False) + # user=relationship("Users",back_populates="inbox",foreign_keys=[user_id]) + +class Inbox_Alerts(SQLModel,table=True): + id=Column(UUID,primary_key=True,default=uuid.uuid4) + alert_sender_name=Column(String,nullable=False) + alert_sender_email=Column(String,nullable=False) + is_read=Column(Boolean,default=False) + recieve_time=Column(DateTime,default=datetime.now) + +class Inbox_Messages(SQLModel,table=True): + id=Column(UUID,primary_key=True,default=uuid.uuid4) + full_email_response=Column(JSONB,nullable=True) + message_subject=Column(String,nullable=False) + message_body=Column(String,nullable=False) + message_sent_time=Column(String,nullable=False) + # message_status=Column(String,nullable=False) + message_received_time=Column(String,nullable=False) + message_from=Column(String,nullable=False) + message_to=Column(String,nullable=False) + message_cc=Column(String,nullable=True) + message_bcc=Column(String,nullable=True) + message_attachments=Column(JSONB,nullable=True) + message_read=Column(Boolean,default=False) + attachment=Column(Boolean,default=False) + message_reply=Column(String,nullable=True) + file_path=Column(String,nullable=True) + + @classmethod + async def insert_email(cls,session:AsyncSession,email_data:dict): + email=cls( + message_subject=email_data.get("subject"), + message_body=email_data.get("body"), + # message_type=email_data.get("type"), + message_sent_time=email_data.get("sentDateTime"), + message_read=email_data.get("isRead"), + message_received_time=email_data.get("receivedDateTime"), + message_from=email_data.get("from").get("emailAddress").get("address"), + message_to=",".join([r["emailAddress"]["address"] for r in email_data.get("toRecipients", [])]), + message_cc=",".join([r["emailAddress"]["address"] for r in email_data.get("ccRecipients", [])]), + message_bcc=",".join([r["emailAddress"]["address"] for r in email_data.get("bccRecipients", [])]), + message_attachments=email_data.get("attachments"), + attachment=email_data.get("hasAttachments"), + message_reply=",".join([r["emailAddress"]["address"] for r in email_data.get("replyTo", [])]), + full_email_response=email_data, + ) + session.add(email) + await session.commit() + return email \ No newline at end of file diff --git a/backend/inbox/plugins.py b/backend/inbox/plugins.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/inbox/plugins.py @@ -0,0 +1 @@ + diff --git a/backend/inbox/views.py b/backend/inbox/views.py index e69de29..2913bc1 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -0,0 +1,44 @@ +import httpx,os +from fastapi import HTTPException +from inbox.models import Inbox_Messages +from inbox.file_decoder import decode_attachment, AttachmentDecodeError +from dotenv import load_dotenv +load_dotenv() +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel + +class Email: + def __init__(self,session:AsyncSession,token): + self.session=session + self.get_url=os.getenv("EMAIL_URL") + self.token=token + + async def service_email(self,top,skip): + async with httpx.AsyncClient() as client: + try: + response=await client.get(f"{self.get_url}/emails", + params={"skip":skip,"top":top}, + headers={"Authorization":f"Bearer {self.token}"} + ) + if response.status_code==200: + return response.json() + else: + raise HTTPException(status_code=response.status_code,detail=response.text) + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + async def get_email_by_id(self,message_id): + async with httpx.AsyncClient() as client: + try: + response=await client.get(f"{self.get_url}/emails/{message_id}", + headers={"Authorization":f"Bearer {self.token}"} + ) + if response.status_code==200: + data=response.json() + re_create_file=await decode_attachment(data.get("attachments")) + insert_func=await Inbox_Messages.insert_email(session=self.session,email_data=data) + return response.json() + else: + raise HTTPException(status_code=response.status_code,detail=response.text) + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index a45afd8..f97a521 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,7 +6,7 @@ from pydantic import BaseModel from fastapi import FastAPI,APIRouter from db_setup import lifespan from inbox.app import router as inbox_router - +from users.app import router as users_router # Without this the db/migration logs have no handler and are swallowed under uvicorn. logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(name)s: %(message)s") diff --git a/backend/role/models.py b/backend/role/models.py new file mode 100644 index 0000000..337cc3d --- /dev/null +++ b/backend/role/models.py @@ -0,0 +1,30 @@ +from sqlalchemy import Column, Integer, String, DateTime, Boolean, Enum as SAEnum +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import relationship +from sqlmodel import SQLModel +from datetime import datetime +from enum import Enum + + +class EnumRoles(Enum): + SYSTEM_ADMINISTRATOR = "system_administrator" + HR_ADMINISTRATOR = "hr_administrator" + RECRUITER = "recruiter" + HIRING_MANAGER = "hiring_manager" + DEPARTMENT_HEAD = "department_head" + INTERVIEWER = "interviewer" + CEO = "ceo" + CANDIDATE = "candidate" + + +class Roles(SQLModel,table=True): + __tablename__ = "roles" + id = Column(Integer, primary_key=True,autoincrement=True) + role_name = Column(SAEnum(EnumRoles),nullable=False,unique=True,default=EnumRoles.SYSTEM_ADMINISTRATOR) + description = Column(String,nullable=True,default="System Administrator") + permissions = Column(JSONB,nullable=True) + created_at = Column(DateTime,default=datetime.now) + updated_at = Column(DateTime,default=datetime.now) + is_active = Column(Boolean,default=True) + is_deleted = Column(Boolean,default=False) + users = relationship("Users", back_populates="role") \ No newline at end of file diff --git a/backend/users/app.py b/backend/users/app.py new file mode 100644 index 0000000..b7813c2 --- /dev/null +++ b/backend/users/app.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter,Depends, Query +from fastapi.responses import JSONResponse +from fastapi import HTTPException +from db_setup import get_session +from sqlalchemy.ext.asyncio import AsyncSession +from inbox.views import Email +from dotenv import load_dotenv +load_dotenv() + +router = APIRouter() + +@router.post("/users/create") +async def create_user(user: User, session: AsyncSession = Depends(get_session)): + pass \ No newline at end of file diff --git a/backend/users/models.py b/backend/users/models.py new file mode 100644 index 0000000..5ac610a --- /dev/null +++ b/backend/users/models.py @@ -0,0 +1,30 @@ +from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Boolean, UUID +from sqlalchemy.orm import relationship +import uuid +from datetime import datetime +from sqlmodel import SQLModel +from role.models import Roles + +class Users(SQLModel,table=True): + __tablename__ = "users" + id = Column(UUID, primary_key=True,default=uuid.uuid4) + name = Column(String,nullable=False) + email = Column(String,nullable=False,unique=True) + role_id = Column(Integer,ForeignKey("roles.id")) + role = relationship("Roles",back_populates="users",foreign_keys=[role_id]) + password = Column(String,nullable=False) + created_at = Column(DateTime,default=datetime.now) + updated_at = Column(DateTime,default=datetime.now) + is_active = Column(Boolean,default=True) + is_deleted = Column(Boolean,default=False) + + + # role = Column(String,nullable=False) + # company_id = Column(UUID,ForeignKey("companies.id")) + # company = relationship("Companies",back_populates="users") + # created_by = Column(UUID,ForeignKey("users.id")) + # created_by_user = relationship("Users",back_populates="users") + # updated_by = Column(UUID,ForeignKey("users.id")) + # updated_by_user = relationship("Users",back_populates="users") + # deleted_by = Column(UUID,ForeignKey("users.id")) + # deleted_by_user = relationship("Users",back_populates="users") \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b2b1c23 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,61 @@ +services: + minio: + image: minio/minio:RELEASE.2025-04-22T22-12-26Z + container_name: hrms-minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + ports: + - "9000:9000" # S3 API + - "9001:9001" # web console + volumes: + - minio-data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + restart: unless-stopped + + # One-shot: creates the attachments bucket, then exits. + minio-init: + image: minio/mc:RELEASE.2025-04-16T18-13-26Z + container_name: hrms-minio-init + depends_on: + minio: + condition: service_healthy + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + MINIO_BUCKET: ${MINIO_BUCKET:-hrms-attachments} + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 \"$$MINIO_ROOT_USER\" \"$$MINIO_ROOT_PASSWORD\" && + mc mb --ignore-existing local/\"$$MINIO_BUCKET\" && + mc version enable local/\"$$MINIO_BUCKET\" && + echo 'bucket ready: '\"$$MINIO_BUCKET\" + " + + postgres: + image: postgres:16-alpine + container_name: hrms-postgres + environment: + POSTGRES_USER: ${DB_USERNAME:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + POSTGRES_DB: ${DB_NAME:-hrms} + ports: + - "${DB_PORT:-5432}:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_NAME:-hrms}"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + minio-data: + postgres-data: