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

@ -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,
)