feat: enhance organization management; add member registration and validation, update user registration flow, and improve enum handling
Test / test (push) Successful in 16s
Test / test (pull_request) Successful in 14s

This commit is contained in:
k1nq
2025-11-29 08:50:11 +05:00
parent 994b400221
commit e7e3752888
11 changed files with 462 additions and 20 deletions
+23 -10
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from pydantic import BaseModel, EmailStr
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.api.deps import get_auth_service, get_user_repository
@@ -19,7 +20,7 @@ class RegisterRequest(BaseModel):
email: EmailStr
password: str
name: str
organization_name: str
organization_name: str | None = None
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -37,21 +38,33 @@ async def register_user(
if existing is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists")
organization = Organization(name=payload.organization_name)
repo.session.add(organization)
await repo.session.flush()
organization: Organization | None = None
if payload.organization_name:
existing_org = await repo.session.scalar(
select(Organization).where(Organization.name == payload.organization_name)
)
if existing_org is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Organization already exists",
)
organization = Organization(name=payload.organization_name)
repo.session.add(organization)
await repo.session.flush()
user_data = UserCreate(email=payload.email, password=payload.password, name=payload.name)
hashed_password = password_hasher.hash(payload.password)
try:
user = await repo.create(data=user_data, hashed_password=hashed_password)
membership = OrganizationMember(
organization_id=organization.id,
user_id=user.id,
role=OrganizationRole.OWNER,
)
repo.session.add(membership)
if organization is not None:
membership = OrganizationMember(
organization_id=organization.id,
user_id=user.id,
role=OrganizationRole.OWNER,
)
repo.session.add(membership)
await repo.session.commit()
except IntegrityError as exc:
await repo.session.rollback()
+45 -2
View File
@@ -1,16 +1,36 @@
"""Organization-related API endpoints."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr
from app.api.deps import get_current_user, get_organization_repository
from app.api.deps import (
get_current_user,
get_organization_context,
get_organization_repository,
get_organization_service,
get_user_repository,
)
from app.models.organization import OrganizationRead
from app.models.organization_member import OrganizationMemberRead, OrganizationRole
from app.models.user import User
from app.repositories.org_repo import OrganizationRepository
from app.repositories.user_repo import UserRepository
from app.services.organization_service import (
OrganizationContext,
OrganizationForbiddenError,
OrganizationMemberAlreadyExistsError,
OrganizationService,
)
router = APIRouter(prefix="/organizations", tags=["organizations"])
class AddMemberPayload(BaseModel):
email: EmailStr
role: OrganizationRole = OrganizationRole.MEMBER
@router.get("/me", response_model=list[OrganizationRead])
async def list_user_organizations(
current_user: User = Depends(get_current_user),
@@ -20,3 +40,26 @@ async def list_user_organizations(
organizations = await repo.list_for_user(current_user.id)
return [OrganizationRead.model_validate(org) for org in organizations]
@router.post("/members", response_model=OrganizationMemberRead, status_code=status.HTTP_201_CREATED)
async def add_member_to_organization(
payload: AddMemberPayload,
context: OrganizationContext = Depends(get_organization_context),
service: OrganizationService = Depends(get_organization_service),
user_repo: UserRepository = Depends(get_user_repository),
) -> OrganizationMemberRead:
"""Allow owners/admins to add existing users to their organization."""
user = await user_repo.get_by_email(payload.email)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
try:
membership = await service.add_member(context=context, user_id=user.id, role=payload.role)
except OrganizationMemberAlreadyExistsError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except OrganizationForbiddenError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
return OrganizationMemberRead.model_validate(membership)
+4 -2
View File
@@ -11,7 +11,7 @@ from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.types import JSON as GenericJSON, TypeDecorator
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
from app.models.base import Base, enum_values
class ActivityType(StrEnum):
@@ -46,7 +46,9 @@ class Activity(Base):
author_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
type: Mapped[ActivityType] = mapped_column(SqlEnum(ActivityType, name="activity_type"), nullable=False)
type: Mapped[ActivityType] = mapped_column(
SqlEnum(ActivityType, name="activity_type", values_callable=enum_values), nullable=False
)
payload: Mapped[dict[str, Any]] = mapped_column(
JSONBCompat().with_variant(GenericJSON(), "sqlite"),
nullable=False,
+13
View File
@@ -1,6 +1,13 @@
"""Declarative base for SQLAlchemy models."""
from __future__ import annotations
from enum import StrEnum
from typing import TypeVar
from sqlalchemy.orm import DeclarativeBase, declared_attr
EnumT = TypeVar("EnumT", bound=StrEnum)
class Base(DeclarativeBase):
"""Base class that configures naming conventions."""
@@ -8,3 +15,9 @@ class Base(DeclarativeBase):
@declared_attr.directive
def __tablename__(cls) -> str: # type: ignore[misc]
return cls.__name__.lower()
def enum_values(enum_cls: type[EnumT]) -> list[str]:
"""Return enum member values to keep DB representation stable."""
return [member.value for member in enum_cls]
+7 -3
View File
@@ -9,7 +9,7 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import DateTime, Enum as SqlEnum, ForeignKey, Integer, Numeric, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
from app.models.base import Base, enum_values
class DealStatus(StrEnum):
@@ -39,10 +39,14 @@ class Deal(Base):
amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
currency: Mapped[str | None] = mapped_column(String(8), nullable=True)
status: Mapped[DealStatus] = mapped_column(
SqlEnum(DealStatus, name="deal_status"), nullable=False, default=DealStatus.NEW
SqlEnum(DealStatus, name="deal_status", values_callable=enum_values),
nullable=False,
default=DealStatus.NEW,
)
stage: Mapped[DealStage] = mapped_column(
SqlEnum(DealStage, name="deal_stage"), nullable=False, default=DealStage.QUALIFICATION
SqlEnum(DealStage, name="deal_stage", values_callable=enum_values),
nullable=False,
default=DealStage.QUALIFICATION,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
+6 -2
View File
@@ -8,7 +8,7 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import DateTime, Enum as SqlEnum, ForeignKey, Integer, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
from app.models.base import Base, enum_values
class OrganizationRole(StrEnum):
@@ -30,7 +30,11 @@ class OrganizationMember(Base):
organization_id: Mapped[int] = mapped_column(ForeignKey("organizations.id", ondelete="CASCADE"))
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
role: Mapped[OrganizationRole] = mapped_column(
SqlEnum(OrganizationRole, name="organization_role"),
SqlEnum(
OrganizationRole,
name="organization_role",
values_callable=enum_values,
),
nullable=False,
default=OrganizationRole.MEMBER,
)
+30 -1
View File
@@ -24,6 +24,10 @@ class OrganizationForbiddenError(OrganizationServiceError):
"""Raised when a user does not have enough privileges."""
class OrganizationMemberAlreadyExistsError(OrganizationServiceError):
"""Raised when attempting to add a duplicate organization member."""
@dataclass(slots=True, frozen=True)
class OrganizationContext:
"""Resolved organization and membership information for a request."""
@@ -84,4 +88,29 @@ class OrganizationService:
"""Members can only mutate entities they own (contacts/deals/tasks)."""
if context.role == OrganizationRole.MEMBER and owner_id != context.user_id:
raise OrganizationForbiddenError("Members can only modify their own records")
raise OrganizationForbiddenError("Members can only modify their own records")
async def add_member(
self,
*,
context: OrganizationContext,
user_id: int,
role: OrganizationRole,
) -> OrganizationMember:
"""Add a user to the current organization enforced by permissions."""
self.ensure_can_manage_settings(context)
existing = await self._repository.get_membership(context.organization_id, user_id)
if existing is not None:
raise OrganizationMemberAlreadyExistsError("User already belongs to this organization")
membership = OrganizationMember(
organization_id=context.organization_id,
user_id=user_id,
role=role,
)
self._repository.session.add(membership)
await self._repository.session.commit()
await self._repository.session.refresh(membership)
return membership