commiot
parent
bb935d4708
commit
c47a380d3d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -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))
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
@ -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:
|
||||
Loading…
Reference in New Issue