batch import csv
This commit is contained in:
parent
a679775f57
commit
dd0ceef665
20 changed files with 1614 additions and 190 deletions
|
|
@ -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.")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
208
backend/app/services/org_member_import.py
Normal file
208
backend/app/services/org_member_import.py
Normal 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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue