diff --git a/backend/alembic_setup.py b/backend/alembic_setup.py index f2ea2cf..fd4c52c 100644 --- a/backend/alembic_setup.py +++ b/backend/alembic_setup.py @@ -103,6 +103,7 @@ from typing import Sequence, Union from alembic import op import sqlalchemy as sa +import sqlmodel # SQLModel renders AutoString() into migrations but adds no import ${imports if imports else ""} revision: str = ${repr(up_revision)} diff --git a/backend/db_setup.py b/backend/db_setup.py index 2b79826..5ce0337 100644 --- a/backend/db_setup.py +++ b/backend/db_setup.py @@ -31,6 +31,7 @@ from sqlalchemy.ext.asyncio import ( create_async_engine, ) from sqlalchemy.orm import DeclarativeBase +from sqlmodel import SQLModel from dotenv import load_dotenv load_dotenv() @@ -124,6 +125,13 @@ class Base(DeclarativeBase): ) +# SQLModel keeps its own registry, so `SQLModel` tables would be invisible to the +# Alembic autogenerate in alembic_setup.py, which diffs `Base.metadata` alone. +# Pointing SQLModel at the same MetaData gives both styles one registry, and lets +# SQLModel tables inherit the naming convention and the default schema above. +SQLModel.metadata = Base.metadata + + _engine: AsyncEngine | None = None _sessionmaker: async_sessionmaker[AsyncSession] | None = None diff --git a/backend/inbox/decoded_attachments/Farman.pdf b/backend/inbox/decoded_attachments/Farman.pdf new file mode 100644 index 0000000..839269d Binary files /dev/null and b/backend/inbox/decoded_attachments/Farman.pdf differ diff --git a/backend/inbox/file_decoder.py b/backend/inbox/file_decoder.py index b642b98..39f1c84 100644 --- a/backend/inbox/file_decoder.py +++ b/backend/inbox/file_decoder.py @@ -19,25 +19,22 @@ _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.""" + """base64 -> raw bytes. + + Graph's ``size`` often includes MIME/encoding overhead and may not equal + ``len(contentBytes)`` after decode, so it is not treated as a hard check. + """ b64 = attachment.get("contentBytes") if not b64: raise AttachmentDecodeError(f"{attachment.get('name')!r}: no contentBytes") try: - raw = base64.b64decode(b64, validate=True) + return 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) diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 85ab894..09b583a 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -1,73 +1,103 @@ -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 +from datetime import datetime +from typing import Any, Optional -class Inbox(SQLModel,table=True): - id=Column(Integer,primary_key=True,autoincrement=True) - user_id = Column(UUID,ForeignKey("users.id")) +from sqlalchemy import Column +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import Field, Relationship, SQLModel - 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]) +from users.models import Users -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) +class Inbox(SQLModel, table=True): + __tablename__ = "inbox" + + id: int | None = Field(default=None, primary_key=True) + user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") + + alert_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_alerts.id") + alerts: Optional["Inbox_Alerts"] = Relationship(back_populates="inbox") + + message_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_messages.id") + messages: Optional["Inbox_Messages"] = Relationship(back_populates="inbox") + + created_at: datetime = Field(default_factory=datetime.now) + updated_at: datetime = Field(default_factory=datetime.now) + # is_active: bool = Field(default=True) + # is_deleted: bool = Field(default=False) + # user: Users | None = Relationship(back_populates="inbox") + + +class Inbox_Alerts(SQLModel, table=True): + __tablename__ = "inbox_alerts" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + alert_sender_name: str + alert_sender_email: str + is_read: bool = Field(default=False) + recieve_time: datetime = Field(default_factory=datetime.now) + + inbox: list[Inbox] = Relationship(back_populates="alerts") + + +class Inbox_Messages(SQLModel, table=True): + __tablename__ = "inbox_messages" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + full_email_response: dict[str, Any] | None = Field( + default=None, sa_column=Column(JSONB) + ) + message_subject: str + message_body: str + message_sent_time: str + message_received_time: str + message_from: str + message_to: str + message_cc: str | None = Field(default=None) + message_bcc: str | None = Field(default=None) + message_read: bool = Field(default=False) + attachment: bool = Field(default=False) + message_reply: str | None = Field(default=None) + file_path: str | None = Field(default=None) + + inbox: list[Inbox] = Relationship(back_populates="messages") + + @staticmethod + def _body_text(email_data: dict) -> str: + body = email_data.get("body") + if isinstance(body, dict): + return body.get("content") or "" + if isinstance(body, str): + return body + return email_data.get("bodyPreview") or "" @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", [])]), + async def insert_email(cls, session: AsyncSession, email_data: dict): + email = cls( + message_subject=email_data.get("subject") or "", + message_body=cls._body_text(email_data), + message_sent_time=email_data.get("sentDateTime") or "", + message_read=bool(email_data.get("isRead")), + message_received_time=email_data.get("receivedDateTime") or "", + 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", [])] + ), + attachment=bool(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 + return email diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako index e2c743f..76f46f7 100644 --- a/backend/migrations/script.py.mako +++ b/backend/migrations/script.py.mako @@ -11,6 +11,7 @@ from typing import Sequence, Union from alembic import op import sqlalchemy as sa +import sqlmodel # SQLModel renders AutoString() into migrations but adds no import ${imports if imports else ""} revision: str = ${repr(up_revision)} diff --git a/backend/migrations/versions/20260803_1416-817ded7e4f55_initial_roles_users_inbox.py b/backend/migrations/versions/20260803_1416-817ded7e4f55_initial_roles_users_inbox.py new file mode 100644 index 0000000..696e860 --- /dev/null +++ b/backend/migrations/versions/20260803_1416-817ded7e4f55_initial_roles_users_inbox.py @@ -0,0 +1,104 @@ +"""initial roles users inbox + +Revision ID: 817ded7e4f55 +Revises: +Create Date: 2026-08-03 14:16:25.739300+00:00 +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +from sqlalchemy.dialects import postgresql + +revision: str = '817ded7e4f55' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('inbox_alerts', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('alert_sender_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('alert_sender_email', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('is_read', sa.Boolean(), nullable=False), + sa.Column('recieve_time', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_inbox_alerts')), + schema='app' + ) + op.create_table('inbox_messages', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('full_email_response', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('message_subject', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('message_body', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('message_sent_time', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('message_received_time', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('message_from', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('message_to', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('message_cc', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('message_bcc', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('message_attachments', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('message_read', sa.Boolean(), nullable=False), + sa.Column('attachment', sa.Boolean(), nullable=False), + sa.Column('message_reply', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('file_path', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('pk_inbox_messages')), + schema='app' + ) + op.create_table('roles', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('role_name', sa.Enum('SYSTEM_ADMINISTRATOR', 'HR_ADMINISTRATOR', 'RECRUITER', 'HIRING_MANAGER', 'DEPARTMENT_HEAD', 'INTERVIEWER', 'CEO', 'CANDIDATE', name='enumroles'), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('permissions', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('is_deleted', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_roles')), + sa.UniqueConstraint('role_name', name=op.f('uq_roles_role_name')), + schema='app' + ) + op.create_table('users', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('role_id', sa.Integer(), nullable=True), + sa.Column('password', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('is_deleted', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['role_id'], ['app.roles.id'], name=op.f('fk_users_role_id_roles')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_users')), + sa.UniqueConstraint('email', name=op.f('uq_users_email')), + schema='app' + ) + op.create_table('inbox', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=True), + sa.Column('alert_id', sa.Uuid(), nullable=True), + sa.Column('message_id', sa.Uuid(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['alert_id'], ['app.inbox_alerts.id'], name=op.f('fk_inbox_alert_id_inbox_alerts')), + sa.ForeignKeyConstraint(['message_id'], ['app.inbox_messages.id'], name=op.f('fk_inbox_message_id_inbox_messages')), + sa.ForeignKeyConstraint(['user_id'], ['app.users.id'], name=op.f('fk_inbox_user_id_users')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_inbox')), + schema='app' + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('inbox', schema='app') + op.drop_table('users', schema='app') + op.drop_table('roles', schema='app') + op.drop_table('inbox_messages', schema='app') + op.drop_table('inbox_alerts', schema='app') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/20260803_1452-ea9c09868aff_remove_message_attachments.py b/backend/migrations/versions/20260803_1452-ea9c09868aff_remove_message_attachments.py new file mode 100644 index 0000000..534fa38 --- /dev/null +++ b/backend/migrations/versions/20260803_1452-ea9c09868aff_remove_message_attachments.py @@ -0,0 +1,32 @@ +"""remove_message_attachments + +Revision ID: ea9c09868aff +Revises: 817ded7e4f55 +Create Date: 2026-08-03 14:52:42.420029+00:00 +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel # SQLModel renders AutoString() into migrations but adds no import +from sqlalchemy.dialects import postgresql + +revision: str = 'ea9c09868aff' +down_revision: Union[str, None] = '817ded7e4f55' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('inbox_messages', 'message_attachments', schema='app') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('inbox_messages', sa.Column('message_attachments', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True), schema='app') + # ### end Alembic commands ### diff --git a/backend/role/models.py b/backend/role/models.py index 337cc3d..bad6c01 100644 --- a/backend/role/models.py +++ b/backend/role/models.py @@ -1,12 +1,13 @@ -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 +from typing import Any + +from sqlalchemy import Column, Enum as SAEnum +from sqlalchemy.dialects.postgresql import JSONB +from sqlmodel import Field, Relationship, SQLModel -class EnumRoles(Enum): +class EnumRoles(str, Enum): SYSTEM_ADMINISTRATOR = "system_administrator" HR_ADMINISTRATOR = "hr_administrator" RECRUITER = "recruiter" @@ -17,14 +18,19 @@ class EnumRoles(Enum): CANDIDATE = "candidate" -class Roles(SQLModel,table=True): +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 + + id: int | None = Field(default=None, primary_key=True) + role_name: EnumRoles = Field( + default=EnumRoles.SYSTEM_ADMINISTRATOR, + sa_column=Column(SAEnum(EnumRoles), nullable=False, unique=True), + ) + description: str | None = Field(default="System Administrator") + permissions: dict[str, Any] | None = Field(default=None, sa_column=Column(JSONB)) + created_at: datetime = Field(default_factory=datetime.now) + updated_at: datetime = Field(default_factory=datetime.now) + is_active: bool = Field(default=True) + is_deleted: bool = Field(default=False) + + users: list["Users"] = Relationship(back_populates="role") diff --git a/backend/users/app.py b/backend/users/app.py index b7813c2..aafb0ce 100644 --- a/backend/users/app.py +++ b/backend/users/app.py @@ -3,12 +3,12 @@ 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 users.models import Users from dotenv import load_dotenv load_dotenv() router = APIRouter() @router.post("/users/create") -async def create_user(user: User, session: AsyncSession = Depends(get_session)): +async def create_user(user: Users, session: AsyncSession = Depends(get_session)): pass \ No newline at end of file diff --git a/backend/users/models.py b/backend/users/models.py index 5ac610a..b201aeb 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -1,30 +1,21 @@ -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 sqlmodel import Field, Relationship, SQLModel + from role.models import Roles -class Users(SQLModel,table=True): + +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 + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + name: str + email: str = Field(unique=True) + role_id: int | None = Field(default=None, foreign_key="roles.id") + role: Roles | None = Relationship(back_populates="users") + password: str + created_at: datetime = Field(default_factory=datetime.now) + updated_at: datetime = Field(default_factory=datetime.now) + is_active: bool = Field(default=True) + is_deleted: bool = Field(default=False)