email wiring

This commit is contained in:
debu-2 2026-10-04 20:50:47 +05:30
parent ee23b71f0f
commit 42cd937f0a
12 changed files with 494 additions and 45 deletions

View file

@ -16,6 +16,7 @@ from app.routers import (
groups,
org,
people,
sections,
sticky_notes,
)
from app.services.domain_provisioning import ensure_platform_domain
@ -57,6 +58,12 @@ async def migrate_user_org_columns(connection) -> None:
"DEFAULT FALSE"
)
)
await connection.execute(
text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS section_id UUID "
"REFERENCES org_sections(id)"
)
)
async def migrate_legacy_user_tables(connection) -> None:
@ -170,6 +177,7 @@ app.include_router(sticky_notes.router, prefix=settings.api_prefix)
app.include_router(auth.router, prefix=settings.api_prefix)
app.include_router(domains.router, prefix=settings.api_prefix)
app.include_router(people.router, prefix=settings.api_prefix)
app.include_router(sections.router, prefix=settings.api_prefix)
app.include_router(groups.router, prefix=settings.api_prefix)
app.include_router(aliases.router, prefix=settings.api_prefix)
app.include_router(org.router, prefix=settings.api_prefix)

View file

@ -50,7 +50,13 @@ class Account(Base):
)
invitation_email: Mapped[str | None] = mapped_column(String(320), nullable=True)
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
section_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("org_sections.id"),
nullable=True,
)
password_hash: Mapped[str] = mapped_column(String(255))
section: Mapped["OrgSection | None"] = relationship()
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
@ -138,6 +144,22 @@ class SmtpCredential(Base):
domain: Mapped[MailDomain] = relationship(back_populates="smtp_credentials")
class OrgSection(Base):
__tablename__ = "org_sections"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
company_domain: Mapped[str] = mapped_column(String(253), index=True)
name: Mapped[str] = mapped_column(String(120))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
)
class OrgSecuritySettings(Base):
__tablename__ = "org_security_settings"

View file

@ -0,0 +1,48 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_postgres_session
from app.deps.org_manager import require_org_manager
from app.models import Account
from app.schemas import OrgSectionCreate, OrgSectionRead
from app.services.org_sections import create_org_section, list_org_sections
router = APIRouter(prefix="/sections", tags=["sections"])
@router.get("", response_model=list[OrgSectionRead])
async def get_sections(
manager: Account = Depends(require_org_manager),
session: AsyncSession = Depends(get_postgres_session),
) -> list[OrgSectionRead]:
sections = await list_org_sections(session, manager.company_domain)
await session.commit()
return sections
@router.post("", response_model=OrgSectionRead, status_code=status.HTTP_201_CREATED)
async def add_section(
payload: OrgSectionCreate,
manager: Account = Depends(require_org_manager),
session: AsyncSession = Depends(get_postgres_session),
) -> OrgSectionRead:
try:
section = await create_org_section(
session,
manager.company_domain,
payload,
)
await session.commit()
return section
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(error),
) from error
except IntegrityError as error:
await session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="That section could not be created.",
) from error

View file

@ -57,6 +57,7 @@ class OrgMemberCreate(BaseModel):
last_name: str = Field(min_length=1, max_length=80)
email: EmailStr
invitation_email: EmailStr | None = None
section_id: UUID | None = None
org_role: Literal["user", "admin"] = "user"
send_invitation: bool = True
force_password_change: bool = False
@ -73,7 +74,21 @@ class OrgMemberRead(BaseModel):
member_status: str
must_change_password: bool = False
is_domain_owner: bool = False
groups: list[str] = Field(default_factory=list)
section_id: UUID | None = None
section_name: str | None = None
invitation_sent: bool = False
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class OrgSectionCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
class OrgSectionRead(BaseModel):
id: UUID
name: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)

View file

@ -8,11 +8,13 @@ from datetime import date
from pwdlib import PasswordHash
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import Account
from app.schemas import OrgMemberCreate, OrgMemberRead
from app.services.domain_provisioning import account_is_domain_owner
from app.services.org_invitations import send_employee_invitation
from app.services.org_sections import get_org_section
password_hasher = PasswordHash.recommended()
PLACEHOLDER_BIRTHDATE = date(1990, 1, 1)
@ -38,6 +40,8 @@ async def can_manage_org_members(
async def member_read(
session: AsyncSession,
member: Account,
*,
invitation_sent: bool = False,
) -> OrgMemberRead:
is_owner = await account_is_domain_owner(
session,
@ -55,7 +59,9 @@ async def member_read(
member_status=member.member_status,
must_change_password=member.must_change_password,
is_domain_owner=is_owner,
groups=[],
section_id=member.section_id,
section_name=member.section.name if member.section else None,
invitation_sent=invitation_sent,
created_at=member.created_at,
)
@ -66,6 +72,7 @@ async def list_org_members(
) -> tuple[list[OrgMemberRead], dict[str, int]]:
result = await session.scalars(
select(Account)
.options(selectinload(Account.section))
.where(
Account.account_type == "company",
Account.company_domain == company_domain,
@ -124,6 +131,12 @@ async def create_org_member(
if existing:
raise ValueError("That email address is already registered.")
section = None
if payload.section_id:
section = await get_org_section(session, company_domain, payload.section_id)
if section is None:
raise ValueError("Selected section was not found.")
phone_number = _placeholder_phone()
while await session.scalar(
select(Account).where(Account.phone_number == phone_number)
@ -142,22 +155,32 @@ async def create_org_member(
member_status=member_status,
invitation_email=invitation_email,
must_change_password=payload.force_password_change,
section_id=section.id if section else None,
created_by_id=manager.id,
password_hash=password_hasher.hash(payload.password),
)
session.add(member)
await session.flush()
if section:
member.section = section
invitation_sent = False
if payload.send_invitation:
await send_employee_invitation(
session,
member=member,
manager=manager,
initial_password=payload.password,
force_password_change=payload.force_password_change,
)
try:
await send_employee_invitation(
session,
member=member,
manager=manager,
initial_password=payload.password,
force_password_change=payload.force_password_change,
)
invitation_sent = True
except Exception as error:
raise ValueError(
f"Could not send invitation email: {error}"
) from error
return await member_read(session, member)
return await member_read(session, member, invitation_sent=invitation_sent)
async def update_org_member_role(

View file

@ -0,0 +1,77 @@
"""Department sections within an organization."""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import OrgSection
from app.schemas import OrgSectionCreate, OrgSectionRead
DEFAULT_SECTIONS = ["Admin", "HR", "Engineering", "Finance"]
async def ensure_default_sections(
session: AsyncSession,
company_domain: str,
) -> None:
existing = await session.scalar(
select(OrgSection.id)
.where(OrgSection.company_domain == company_domain)
.limit(1)
)
if existing:
return
for name in DEFAULT_SECTIONS:
session.add(OrgSection(company_domain=company_domain, name=name))
await session.flush()
async def list_org_sections(
session: AsyncSession,
company_domain: str,
) -> list[OrgSectionRead]:
await ensure_default_sections(session, company_domain)
result = await session.scalars(
select(OrgSection)
.where(OrgSection.company_domain == company_domain)
.order_by(OrgSection.name.asc())
)
return [OrgSectionRead.model_validate(section) for section in result.all()]
async def create_org_section(
session: AsyncSession,
company_domain: str,
payload: OrgSectionCreate,
) -> OrgSectionRead:
name = payload.name.strip()
if not name:
raise ValueError("Section name is required.")
existing = await session.scalar(
select(OrgSection).where(
OrgSection.company_domain == company_domain,
OrgSection.name.ilike(name),
)
)
if existing:
raise ValueError("That section already exists.")
section = OrgSection(company_domain=company_domain, name=name)
session.add(section)
await session.flush()
return OrgSectionRead.model_validate(section)
async def get_org_section(
session: AsyncSession,
company_domain: str,
section_id,
) -> OrgSection | None:
return await session.scalar(
select(OrgSection).where(
OrgSection.id == section_id,
OrgSection.company_domain == company_domain,
)
)