feat: Add organization member support with memberType field
Implements support for both individual persons and organizations as members. Changes: - Added MemberType enum (INDIVIDUAL, ORGANIZATION) to distinguish member types - Added member_type column to database (defaults to INDIVIDUAL for backward compatibility) - Added company_name field for organizations - Made first_name nullable (required for individuals, optional for organizations) - Implemented conditional validation: - INDIVIDUAL members require first_name - ORGANIZATION members require company_name - Updated GraphQL schema with new memberType and companyName fields - Updated all resolvers to handle new fields and validation - Added comprehensive unit tests for validation logic - Updated existing tests to work with new fields - All 53 tests passing Technical notes: - Using memberType instead of 'kind' to avoid GraphQL introspection conflicts - Using native_enum=False for SQLite compatibility - Using batch_alter_table for SQLite ALTER COLUMN compatibility - Backward compatible: existing members automatically become INDIVIDUAL type 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+26
-4
@@ -1,12 +1,19 @@
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import String, func
|
||||
from sqlalchemy import Enum, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.database import Base
|
||||
|
||||
|
||||
class MemberType(str, enum.Enum):
|
||||
"""Member type enumeration."""
|
||||
INDIVIDUAL = "INDIVIDUAL"
|
||||
ORGANIZATION = "ORGANIZATION"
|
||||
|
||||
|
||||
class Member(Base):
|
||||
"""Member model for club/society management."""
|
||||
|
||||
@@ -15,8 +22,19 @@ class Member(Base):
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Required field
|
||||
first_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
# Member type (default to INDIVIDUAL for backward compatibility)
|
||||
member_type: Mapped[MemberType] = mapped_column(
|
||||
Enum(MemberType, native_enum=False, length=20),
|
||||
nullable=False,
|
||||
default=MemberType.INDIVIDUAL,
|
||||
server_default="INDIVIDUAL"
|
||||
)
|
||||
|
||||
# First name (required for individuals, optional for organizations)
|
||||
first_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
|
||||
# Organization name (required for organizations)
|
||||
company_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||
|
||||
# Optional fields
|
||||
last_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
@@ -40,4 +58,8 @@ class Member(Base):
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Member(id={self.id}, first_name='{self.first_name}', last_name='{self.last_name}')>"
|
||||
if self.member_type == MemberType.ORGANIZATION:
|
||||
name = self.company_name
|
||||
else:
|
||||
name = f"{self.first_name} {self.last_name or ''}".strip()
|
||||
return f"<Member(id={self.id}, type={self.member_type.value}, name='{name}')>"
|
||||
|
||||
+34
-6
@@ -6,7 +6,7 @@ from sqlalchemy import select
|
||||
from src.database import async_session_maker
|
||||
from src.models.member import Member as MemberModel
|
||||
from src.schemas.member import CreateMemberInput, Member, UpdateMemberInput
|
||||
from src.validation import validate_email, validate_first_name, validate_phone
|
||||
from src.validation import validate_email, validate_phone, validate_member_type_requirements
|
||||
|
||||
|
||||
class MemberNotFoundError(Exception):
|
||||
@@ -31,7 +31,9 @@ class Query:
|
||||
|
||||
return Member(
|
||||
id=db_member.id,
|
||||
member_type=db_member.member_type,
|
||||
first_name=db_member.first_name,
|
||||
company_name=db_member.company_name,
|
||||
last_name=db_member.last_name,
|
||||
street=db_member.street,
|
||||
apartment_number=db_member.apartment_number,
|
||||
@@ -58,7 +60,9 @@ class Query:
|
||||
return [
|
||||
Member(
|
||||
id=m.id,
|
||||
member_type=m.member_type,
|
||||
first_name=m.first_name,
|
||||
company_name=m.company_name,
|
||||
last_name=m.last_name,
|
||||
street=m.street,
|
||||
apartment_number=m.apartment_number,
|
||||
@@ -79,8 +83,12 @@ class Mutation:
|
||||
@strawberry.mutation
|
||||
async def create_member(self, input: CreateMemberInput) -> Member:
|
||||
"""Create a new member with validation."""
|
||||
# Validate required field
|
||||
validate_first_name(input.first_name)
|
||||
# Validate conditional requirements based on member type
|
||||
validate_member_type_requirements(
|
||||
member_type=input.member_type.value,
|
||||
first_name=input.first_name,
|
||||
company_name=input.company_name
|
||||
)
|
||||
|
||||
# Conditional validation (only if fields are provided)
|
||||
validate_email(input.email)
|
||||
@@ -89,7 +97,9 @@ class Mutation:
|
||||
async with async_session_maker() as session:
|
||||
# Create new member
|
||||
db_member = MemberModel(
|
||||
member_type=input.member_type,
|
||||
first_name=input.first_name,
|
||||
company_name=input.company_name,
|
||||
last_name=input.last_name,
|
||||
street=input.street,
|
||||
apartment_number=input.apartment_number,
|
||||
@@ -106,7 +116,9 @@ class Mutation:
|
||||
|
||||
return Member(
|
||||
id=db_member.id,
|
||||
member_type=db_member.member_type,
|
||||
first_name=db_member.first_name,
|
||||
company_name=db_member.company_name,
|
||||
last_name=db_member.last_name,
|
||||
street=db_member.street,
|
||||
apartment_number=db_member.apartment_number,
|
||||
@@ -132,15 +144,29 @@ class Mutation:
|
||||
if db_member is None:
|
||||
raise MemberNotFoundError(f"Member with ID {input.id} not found")
|
||||
|
||||
# Validate if fields are being updated
|
||||
if input.first_name is not None:
|
||||
validate_first_name(input.first_name)
|
||||
# Determine final values for validation (combining existing and new values)
|
||||
final_member_type = input.member_type.value if input.member_type is not None else db_member.member_type.value
|
||||
final_first_name = input.first_name if input.first_name is not None else db_member.first_name
|
||||
final_company_name = input.company_name if input.company_name is not None else db_member.company_name
|
||||
|
||||
# Validate conditional requirements with final values
|
||||
validate_member_type_requirements(
|
||||
member_type=final_member_type,
|
||||
first_name=final_first_name,
|
||||
company_name=final_company_name
|
||||
)
|
||||
|
||||
# Conditional validation (only if fields are provided)
|
||||
validate_email(input.email)
|
||||
validate_phone(input.phone)
|
||||
|
||||
# Update fields if provided
|
||||
if input.member_type is not None:
|
||||
db_member.member_type = input.member_type
|
||||
if input.first_name is not None:
|
||||
db_member.first_name = input.first_name
|
||||
if input.company_name is not None:
|
||||
db_member.company_name = input.company_name
|
||||
if input.last_name is not None:
|
||||
db_member.last_name = input.last_name
|
||||
if input.street is not None:
|
||||
@@ -163,7 +189,9 @@ class Mutation:
|
||||
|
||||
return Member(
|
||||
id=db_member.id,
|
||||
member_type=db_member.member_type,
|
||||
first_name=db_member.first_name,
|
||||
company_name=db_member.company_name,
|
||||
last_name=db_member.last_name,
|
||||
street=db_member.street,
|
||||
apartment_number=db_member.apartment_number,
|
||||
|
||||
+17
-3
@@ -1,15 +1,25 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
import strawberry
|
||||
|
||||
|
||||
@strawberry.enum
|
||||
class MemberType(str, Enum):
|
||||
"""Member type enumeration."""
|
||||
INDIVIDUAL = "INDIVIDUAL"
|
||||
ORGANIZATION = "ORGANIZATION"
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Member:
|
||||
"""GraphQL Member type."""
|
||||
|
||||
id: int
|
||||
first_name: str
|
||||
member_type: MemberType
|
||||
first_name: Optional[str]
|
||||
company_name: Optional[str]
|
||||
last_name: Optional[str]
|
||||
street: Optional[str]
|
||||
apartment_number: Optional[str]
|
||||
@@ -24,9 +34,11 @@ class Member:
|
||||
|
||||
@strawberry.input
|
||||
class CreateMemberInput:
|
||||
"""Input type for creating a member - only firstName required."""
|
||||
"""Input type for creating a member - conditional requirements based on memberType."""
|
||||
|
||||
first_name: str
|
||||
member_type: MemberType = MemberType.INDIVIDUAL
|
||||
first_name: Optional[str] = None
|
||||
company_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
street: Optional[str] = None
|
||||
apartment_number: Optional[str] = None
|
||||
@@ -42,7 +54,9 @@ class UpdateMemberInput:
|
||||
"""Input type for updating a member - all fields optional except id."""
|
||||
|
||||
id: int
|
||||
member_type: Optional[MemberType] = None
|
||||
first_name: Optional[str] = None
|
||||
company_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
street: Optional[str] = None
|
||||
apartment_number: Optional[str] = None
|
||||
|
||||
+14
-4
@@ -13,10 +13,20 @@ EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
|
||||
PHONE_PATTERN = re.compile(r"^\+?[1-9]\d{1,14}$") # E.164 format
|
||||
|
||||
|
||||
def validate_first_name(first_name: str) -> None:
|
||||
"""Ensure firstName is provided and non-empty."""
|
||||
if not first_name or first_name.strip() == "":
|
||||
raise ValidationError("firstName cannot be empty")
|
||||
def validate_member_type_requirements(
|
||||
member_type: str,
|
||||
first_name: Optional[str],
|
||||
company_name: Optional[str]
|
||||
) -> None:
|
||||
"""Validate conditional requirements based on member type."""
|
||||
if member_type == "INDIVIDUAL":
|
||||
if not first_name or first_name.strip() == "":
|
||||
raise ValidationError("firstName is required for individual members")
|
||||
elif member_type == "ORGANIZATION":
|
||||
if not company_name or company_name.strip() == "":
|
||||
raise ValidationError("companyName is required for organization members")
|
||||
else:
|
||||
raise ValidationError(f"Invalid member type: {member_type}. Must be INDIVIDUAL or ORGANIZATION")
|
||||
|
||||
|
||||
def validate_email(email: Optional[str]) -> None:
|
||||
|
||||
Reference in New Issue
Block a user