pull/1/head
ahmed.mujtaba 2026-08-03 19:10:54 +05:00
parent bb935d4708
commit c47a380d3d
12 changed files with 442 additions and 9 deletions

6
.gitignore vendored
View File

@ -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

View File

@ -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

View File

@ -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!"}
@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

View File

@ -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)

View File

@ -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

1
backend/inbox/plugins.py Normal file
View File

@ -0,0 +1 @@

View File

@ -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))

View File

@ -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")

30
backend/role/models.py Normal file
View File

@ -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")

14
backend/users/app.py Normal file
View File

@ -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

30
backend/users/models.py Normal file
View File

@ -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")

61
docker-compose.yml Normal file
View File

@ -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: