batch import csv
This commit is contained in:
parent
a679775f57
commit
dd0ceef665
20 changed files with 1614 additions and 190 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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