batch import csv

This commit is contained in:
debu-2 2026-10-05 04:53:45 +05:30
parent a679775f57
commit dd0ceef665
20 changed files with 1614 additions and 190 deletions

View file

@ -26,6 +26,24 @@ settings = get_settings()
inbound_server = InboundSMTPServer()
async def migrate_mail_group_columns(connection) -> None:
await connection.execute(
text(
"ALTER TABLE mail_groups ADD COLUMN IF NOT EXISTS owner_id UUID "
"REFERENCES users(id)"
)
)
await connection.execute(
text(
"ALTER TABLE mail_groups ADD COLUMN IF NOT EXISTS send_permission "
"VARCHAR(30) DEFAULT 'org_members'"
)
)
await connection.execute(
text("ALTER TABLE mail_groups ADD COLUMN IF NOT EXISTS description TEXT")
)
async def migrate_user_org_columns(connection) -> None:
await connection.execute(
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS org_role VARCHAR(20)")
@ -96,6 +114,7 @@ async def lifespan(_: FastAPI):
await migrate_legacy_user_tables(connection)
await connection.run_sync(Base.metadata.create_all)
await migrate_user_org_columns(connection)
await migrate_mail_group_columns(connection)
async with SessionFactory() as session:
await ensure_platform_domain(session)
await session.commit()

View file

@ -198,11 +198,19 @@ class MailGroup(Base):
name: Mapped[str] = mapped_column(String(120))
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
group_type: Mapped[str] = mapped_column(String(20), default="distribution")
owner_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True,
)
send_permission: Mapped[str] = mapped_column(String(30), default="org_members")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True,
)
owner: Mapped["Account | None"] = relationship(foreign_keys=[owner_id])
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),

View file

@ -5,8 +5,12 @@ 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 MailAliasCreate, MailAliasListResponse, MailAliasRead
from app.services.mail_aliases import create_mail_alias, list_mail_aliases
from app.schemas import MailAliasCreate, MailAliasListResponse, MailAliasRead, MailGroupEmailCheck
from app.services.mail_aliases import (
check_alias_email_available,
create_mail_alias,
list_mail_aliases,
)
router = APIRouter(prefix="/aliases", tags=["aliases"])
@ -20,6 +24,24 @@ async def get_aliases(
return MailAliasListResponse(items=items, counts=counts)
@router.get("/check-email", response_model=MailGroupEmailCheck)
async def check_email(
email: str,
manager: Account = Depends(require_org_manager),
session: AsyncSession = Depends(get_postgres_session),
) -> MailGroupEmailCheck:
if manager.company_domain is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Company domain is required.",
)
return await check_alias_email_available(
session,
manager.company_domain,
email,
)
@router.post("", response_model=MailAliasRead, status_code=status.HTTP_201_CREATED)
async def add_alias(
payload: MailAliasCreate,

View file

@ -57,14 +57,6 @@ async def verify_owned_domain_dns(
domain: MailDomain,
) -> DnsVerificationResponse:
settings = get_settings()
if domain.status == "active":
return DnsVerificationResponse(
domain=domain.domain_name,
status=domain.status,
verified=True,
checks=[],
)
verification_token = await ensure_ownership_dns_record(session, domain)
checks = verify_company_dns(
domain_name=domain.domain_name,
@ -75,12 +67,15 @@ async def verify_owned_domain_dns(
verification_token=verification_token,
)
verified = all(check["verified"] for check in checks)
if verified:
if domain.status != "active" and verified:
await activate_company_domain(session, domain)
await session.commit()
status = "active"
else:
status = domain.status
return DnsVerificationResponse(
domain=domain.domain_name,
status=domain.status if not verified else "active",
status=status,
verified=verified,
checks=checks,
)

View file

@ -5,8 +5,12 @@ 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 MailGroupCreate, MailGroupListResponse, MailGroupRead
from app.services.mail_groups import create_mail_group, list_mail_groups
from app.schemas import MailGroupCreate, MailGroupEmailCheck, MailGroupListResponse, MailGroupRead
from app.services.mail_groups import (
check_group_email_available,
create_mail_group,
list_mail_groups,
)
router = APIRouter(prefix="/groups", tags=["groups"])
@ -20,6 +24,25 @@ async def get_groups(
return MailGroupListResponse(items=items, counts=counts)
@router.get("/check-email", response_model=MailGroupEmailCheck)
async def check_email(
email: str,
manager: Account = Depends(require_org_manager),
session: AsyncSession = Depends(get_postgres_session),
) -> MailGroupEmailCheck:
if manager.company_domain is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Company domain is required.",
)
result = await check_group_email_available(
session,
manager.company_domain,
email,
)
return result
@router.post("", response_model=MailGroupRead, status_code=status.HTTP_201_CREATED)
async def add_group(
payload: MailGroupCreate,

View file

@ -10,10 +10,13 @@ from app.models import Account
from app.routers.auth import validate_password
from app.schemas import (
OrgMemberCreate,
OrgMemberImportRequest,
OrgMemberImportResponse,
OrgMemberListResponse,
OrgMemberRead,
OrgMemberRoleUpdate,
)
from app.services.org_member_import import import_org_members
from app.services.org_members import (
create_org_member,
list_org_members,
@ -62,6 +65,23 @@ async def add_person(
) from error
@router.post("/import", response_model=OrgMemberImportResponse)
async def import_people(
payload: OrgMemberImportRequest,
manager: Account = Depends(require_org_manager),
session: AsyncSession = Depends(get_postgres_session),
) -> OrgMemberImportResponse:
try:
result = await import_org_members(session, manager=manager, payload=payload)
await session.commit()
return result
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(error),
) from error
@router.patch("/{member_id}/role", response_model=OrgMemberRead)
async def update_member_role(
member_id: UUID,

View file

@ -104,10 +104,48 @@ class OrgMemberListResponse(BaseModel):
counts: dict[str, int]
class OrgMemberImportRow(BaseModel):
first_name: str = Field(min_length=1, max_length=80)
last_name: str = Field(min_length=1, max_length=80)
work_email: str = Field(min_length=1, max_length=320)
invitation_email: EmailStr | None = None
password: str = Field(min_length=8, max_length=128)
class OrgMemberImportRequest(BaseModel):
org_role: Literal["user", "admin"] = "user"
send_invitation: bool = True
force_password_change: bool = True
rows: list[OrgMemberImportRow] = Field(min_length=1, max_length=300)
class OrgMemberImportRowResult(BaseModel):
row: int
email: str
success: bool
message: str
invitation_sent: bool = False
class OrgMemberImportResponse(BaseModel):
created: list[OrgMemberRead]
errors: list[OrgMemberImportRowResult]
created_count: int
error_count: int
class MailGroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
email: EmailStr
group_type: Literal["distribution", "dynamic"] = "distribution"
owner_id: UUID
send_permission: Literal[
"org_members",
"group_members",
"owners_managers",
"anyone",
] = "org_members"
description: str | None = Field(default=None, max_length=500)
member_ids: list[UUID] = Field(default_factory=list)
@ -116,12 +154,20 @@ class MailGroupRead(BaseModel):
name: str
email: EmailStr
group_type: str
owner_id: UUID | None = None
send_permission: str = "org_members"
member_count: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class MailGroupEmailCheck(BaseModel):
email: EmailStr
available: bool
conflict_type: str | None = None
class MailGroupListResponse(BaseModel):
items: list[MailGroupRead]
counts: dict[str, int]

View file

@ -6,7 +6,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Account, MailAlias, MailGroup
from app.schemas import MailAliasCreate, MailAliasRead
from app.schemas import MailAliasCreate, MailAliasRead, MailGroupEmailCheck
def _alias_read(alias: MailAlias) -> MailAliasRead:
@ -39,6 +39,55 @@ async def list_mail_aliases(
return items, counts
async def check_alias_email_available(
session: AsyncSession,
company_domain: str,
email: str,
) -> MailGroupEmailCheck:
normalized = str(email).lower()
if not normalized.endswith(f"@{company_domain}"):
return MailGroupEmailCheck(
email=normalized,
available=False,
conflict_type="invalid_domain",
)
account = await session.scalar(
select(Account).where(func.lower(Account.email) == normalized)
)
if account:
return MailGroupEmailCheck(
email=normalized,
available=False,
conflict_type="user",
)
group = await session.scalar(
select(MailGroup).where(func.lower(MailGroup.email) == normalized)
)
if group:
return MailGroupEmailCheck(
email=normalized,
available=False,
conflict_type="group",
)
alias = await session.scalar(
select(MailAlias).where(
MailAlias.company_domain == company_domain,
func.lower(MailAlias.alias_address) == normalized,
)
)
if alias:
return MailGroupEmailCheck(
email=normalized,
available=False,
conflict_type="alias",
)
return MailGroupEmailCheck(email=normalized, available=True)
async def _destination_exists(
session: AsyncSession,
company_domain: str,
@ -80,26 +129,14 @@ async def create_mail_alias(
if alias_address == forwards_to:
raise ValueError("Alias cannot forward to itself.")
existing = await session.scalar(
select(MailAlias).where(
func.lower(MailAlias.alias_address) == alias_address
)
availability = await check_alias_email_available(
session,
company_domain,
alias_address,
)
if existing:
if not availability.available:
raise ValueError("That alias address is already in use.")
account_conflict = await session.scalar(
select(Account).where(func.lower(Account.email) == alias_address)
)
if account_conflict:
raise ValueError("That address is already a user mailbox.")
group_conflict = await session.scalar(
select(MailGroup).where(func.lower(MailGroup.email) == alias_address)
)
if group_conflict:
raise ValueError("That address is already a group mailbox.")
if not await _destination_exists(session, company_domain, forwards_to):
raise ValueError("Forwarding address must be an existing user or group.")

View file

@ -6,8 +6,8 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import Account, MailGroup, MailGroupMember
from app.schemas import MailGroupCreate, MailGroupRead
from app.models import Account, MailAlias, MailGroup, MailGroupMember
from app.schemas import MailGroupCreate, MailGroupEmailCheck, MailGroupRead
def _group_read(group: MailGroup) -> MailGroupRead:
@ -16,11 +16,62 @@ def _group_read(group: MailGroup) -> MailGroupRead:
name=group.name,
email=group.email,
group_type=group.group_type,
owner_id=group.owner_id,
send_permission=group.send_permission,
member_count=len(group.members),
created_at=group.created_at,
)
async def check_group_email_available(
session: AsyncSession,
company_domain: str,
email: str,
) -> MailGroupEmailCheck:
normalized = str(email).lower()
if not normalized.endswith(f"@{company_domain}"):
return MailGroupEmailCheck(
email=normalized,
available=False,
conflict_type="invalid_domain",
)
account = await session.scalar(
select(Account).where(func.lower(Account.email) == normalized)
)
if account:
return MailGroupEmailCheck(
email=normalized,
available=False,
conflict_type="user",
)
group = await session.scalar(
select(MailGroup).where(func.lower(MailGroup.email) == normalized)
)
if group:
return MailGroupEmailCheck(
email=normalized,
available=False,
conflict_type="group",
)
alias = await session.scalar(
select(MailAlias).where(
MailAlias.company_domain == company_domain,
func.lower(MailAlias.alias_address) == normalized,
)
)
if alias:
return MailGroupEmailCheck(
email=normalized,
available=False,
conflict_type="alias",
)
return MailGroupEmailCheck(email=normalized, available=True)
async def list_mail_groups(
session: AsyncSession,
company_domain: str,
@ -81,6 +132,20 @@ async def create_mail_group(
if existing:
raise ValueError("That group email address is already in use.")
owner = await session.scalar(
select(Account).where(
Account.id == payload.owner_id,
Account.company_domain == company_domain,
Account.member_status != "suspended",
)
)
if owner is None:
raise ValueError("Group owner must be an active member of your organization.")
availability = await check_group_email_available(session, company_domain, email)
if not availability.available:
raise ValueError("That group email address is already in use.")
members = await _validate_member_ids(
session,
company_domain,
@ -92,6 +157,9 @@ async def create_mail_group(
name=payload.name.strip(),
email=email,
group_type=payload.group_type,
owner_id=owner.id,
send_permission=payload.send_permission,
description=payload.description.strip() if payload.description else None,
created_by_id=manager.id,
)
session.add(group)

View file

@ -0,0 +1,208 @@
"""Bulk CSV import for organization members.
TODO: Move large imports to an async batch job with webhook completion callbacks.
"""
from __future__ import annotations
import re
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Account
from app.schemas import (
OrgMemberCreate,
OrgMemberImportRequest,
OrgMemberImportResponse,
OrgMemberImportRowResult,
OrgMemberRead,
)
from app.services.org_members import create_org_member
MAX_IMPORT_ROWS = 300
EMAIL_LOCAL_PATTERN = re.compile(
r"^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$"
)
def _password_error(value: str) -> str | None:
if len(value) < 8:
return "Password must be at least 8 characters."
if not (
re.search(r"[a-z]", value)
and re.search(r"[A-Z]", value)
and re.search(r"\d", value)
):
return "Password must include uppercase, lowercase, and a number."
return None
def _normalize_work_email(raw: str, company_domain: str) -> str:
value = raw.strip().lower()
if "@" in value:
return value
return f"{value}@{company_domain}"
async def import_org_members(
session: AsyncSession,
*,
manager: Account,
payload: OrgMemberImportRequest,
) -> OrgMemberImportResponse:
company_domain = manager.company_domain
if company_domain is None:
raise ValueError("Company domain is required.")
rows = payload.rows
if not rows:
raise ValueError("Add at least one person to import.")
if len(rows) > MAX_IMPORT_ROWS:
raise ValueError(f"You can import up to {MAX_IMPORT_ROWS} people at a time.")
errors: list[OrgMemberImportRowResult] = []
normalized_rows: list[tuple[int, OrgMemberCreate]] = []
seen_emails: set[str] = set()
seen_invites: set[str] = set()
for index, row in enumerate(rows, start=1):
row_errors: list[str] = []
first_name = row.first_name.strip()
last_name = row.last_name.strip()
if not first_name:
row_errors.append("First name is required.")
if not last_name:
row_errors.append("Last name is required.")
work_email = _normalize_work_email(row.work_email, company_domain)
local_part = work_email.split("@", 1)[0]
if not work_email.endswith(f"@{company_domain}"):
row_errors.append(f"Work email must use @{company_domain}.")
elif not EMAIL_LOCAL_PATTERN.match(local_part):
row_errors.append("Work email format is invalid.")
if work_email in seen_emails:
row_errors.append("Duplicate work email in this file.")
seen_emails.add(work_email)
password = row.password.strip()
password_issue = _password_error(password)
if password_issue:
row_errors.append(password_issue)
invitation_email = (
str(row.invitation_email).lower().strip()
if row.invitation_email
else None
)
if payload.send_invitation:
if not invitation_email:
row_errors.append(
"Invitation email is required when sending invitations."
)
elif invitation_email.endswith(f"@{company_domain}"):
row_errors.append(
"Use a personal email for invitations, not your company domain."
)
elif invitation_email in seen_invites:
row_errors.append("Duplicate invitation email in this file.")
else:
seen_invites.add(invitation_email)
if row_errors:
errors.append(
OrgMemberImportRowResult(
row=index,
email=work_email,
success=False,
message="; ".join(row_errors),
)
)
continue
normalized_rows.append(
(
index,
OrgMemberCreate(
first_name=first_name,
last_name=last_name,
email=work_email,
invitation_email=invitation_email,
org_role=payload.org_role,
send_invitation=payload.send_invitation,
force_password_change=payload.force_password_change,
password=password,
),
)
)
if normalized_rows:
existing = await session.scalars(
select(Account.email).where(
func.lower(Account.email).in_(
[item[1].email.lower() for item in normalized_rows]
)
)
)
existing_emails = {email.lower() for email in existing.all()}
if existing_emails:
filtered: list[tuple[int, OrgMemberCreate]] = []
for row_number, member_payload in normalized_rows:
if member_payload.email.lower() in existing_emails:
errors.append(
OrgMemberImportRowResult(
row=row_number,
email=member_payload.email,
success=False,
message="That work email is already registered.",
)
)
else:
filtered.append((row_number, member_payload))
normalized_rows = filtered
if errors and not normalized_rows:
return OrgMemberImportResponse(
created=[],
errors=errors,
created_count=0,
error_count=len(errors),
)
created: list[OrgMemberRead] = []
for row_number, member_payload in normalized_rows:
try:
member = await create_org_member(
session,
manager=manager,
payload=member_payload,
)
created.append(member)
errors.append(
OrgMemberImportRowResult(
row=row_number,
email=member.email,
success=True,
message="Created",
invitation_sent=member.invitation_sent,
)
)
except ValueError as error:
errors.append(
OrgMemberImportRowResult(
row=row_number,
email=member_payload.email,
success=False,
message=str(error),
)
)
created_count = len(created)
error_count = sum(1 for item in errors if not item.success)
return OrgMemberImportResponse(
created=created,
errors=errors,
created_count=created_count,
error_count=error_count,
)