email wiring
This commit is contained in:
parent
ee23b71f0f
commit
42cd937f0a
12 changed files with 494 additions and 45 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
48
backend/app/routers/sections.py
Normal file
48
backend/app/routers/sections.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
77
backend/app/services/org_sections.py
Normal file
77
backend/app/services/org_sections.py
Normal 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,
|
||||
)
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue