feat: implement GraphQL member management API
Implement complete member management system with GraphQL API per OpenSpec proposal. Only firstName is required; all other fields are optional with conditional validation. Key features: - GraphQL CRUD operations (create, read, update, delete members) - SQLAlchemy 2.0 async with SQLite database - Alembic database migrations - Conditional validation (email/phone validated only when provided) - Member fields: firstName (required), lastName, address, email, phone (all optional) - Database seed script with sample member - FastAPI with Strawberry GraphQL integration Implementation details: - Python 3.11+ with uv package manager - Async database sessions throughout - Proper error handling and validation - Code quality: black, isort, ruff - Comprehensive end-to-end testing completed All 20 tasks from OpenSpec proposal completed successfully. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
from typing import List, Optional
|
||||
|
||||
import strawberry
|
||||
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
|
||||
|
||||
|
||||
class MemberNotFoundError(Exception):
|
||||
"""Raised when a member is not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Query:
|
||||
@strawberry.field
|
||||
async def member(self, id: int) -> Optional[Member]:
|
||||
"""Get a single member by ID."""
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(MemberModel).where(MemberModel.id == id)
|
||||
)
|
||||
db_member = result.scalar_one_or_none()
|
||||
|
||||
if db_member is None:
|
||||
return None
|
||||
|
||||
return Member(
|
||||
id=db_member.id,
|
||||
first_name=db_member.first_name,
|
||||
last_name=db_member.last_name,
|
||||
street=db_member.street,
|
||||
apartment_number=db_member.apartment_number,
|
||||
zip=db_member.zip,
|
||||
city=db_member.city,
|
||||
country=db_member.country,
|
||||
email=db_member.email,
|
||||
phone=db_member.phone,
|
||||
created_at=db_member.created_at,
|
||||
updated_at=db_member.updated_at,
|
||||
)
|
||||
|
||||
@strawberry.field
|
||||
async def members(self) -> List[Member]:
|
||||
"""List all members sorted by last name (nulls last), then first name."""
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(MemberModel).order_by(
|
||||
MemberModel.last_name.nulls_last(), MemberModel.first_name
|
||||
)
|
||||
)
|
||||
db_members = result.scalars().all()
|
||||
|
||||
return [
|
||||
Member(
|
||||
id=m.id,
|
||||
first_name=m.first_name,
|
||||
last_name=m.last_name,
|
||||
street=m.street,
|
||||
apartment_number=m.apartment_number,
|
||||
zip=m.zip,
|
||||
city=m.city,
|
||||
country=m.country,
|
||||
email=m.email,
|
||||
phone=m.phone,
|
||||
created_at=m.created_at,
|
||||
updated_at=m.updated_at,
|
||||
)
|
||||
for m in db_members
|
||||
]
|
||||
|
||||
|
||||
@strawberry.type
|
||||
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)
|
||||
|
||||
# Conditional validation (only if fields are provided)
|
||||
validate_email(input.email)
|
||||
validate_phone(input.phone)
|
||||
|
||||
async with async_session_maker() as session:
|
||||
# Create new member
|
||||
db_member = MemberModel(
|
||||
first_name=input.first_name,
|
||||
last_name=input.last_name,
|
||||
street=input.street,
|
||||
apartment_number=input.apartment_number,
|
||||
zip=input.zip,
|
||||
city=input.city,
|
||||
country=input.country,
|
||||
email=input.email,
|
||||
phone=input.phone,
|
||||
)
|
||||
|
||||
session.add(db_member)
|
||||
await session.commit()
|
||||
await session.refresh(db_member)
|
||||
|
||||
return Member(
|
||||
id=db_member.id,
|
||||
first_name=db_member.first_name,
|
||||
last_name=db_member.last_name,
|
||||
street=db_member.street,
|
||||
apartment_number=db_member.apartment_number,
|
||||
zip=db_member.zip,
|
||||
city=db_member.city,
|
||||
country=db_member.country,
|
||||
email=db_member.email,
|
||||
phone=db_member.phone,
|
||||
created_at=db_member.created_at,
|
||||
updated_at=db_member.updated_at,
|
||||
)
|
||||
|
||||
@strawberry.mutation
|
||||
async def update_member(self, input: UpdateMemberInput) -> Member:
|
||||
"""Update an existing member."""
|
||||
async with async_session_maker() as session:
|
||||
# Get member
|
||||
result = await session.execute(
|
||||
select(MemberModel).where(MemberModel.id == input.id)
|
||||
)
|
||||
db_member = result.scalar_one_or_none()
|
||||
|
||||
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)
|
||||
validate_email(input.email)
|
||||
validate_phone(input.phone)
|
||||
|
||||
# Update fields if provided
|
||||
if input.first_name is not None:
|
||||
db_member.first_name = input.first_name
|
||||
if input.last_name is not None:
|
||||
db_member.last_name = input.last_name
|
||||
if input.street is not None:
|
||||
db_member.street = input.street
|
||||
if input.apartment_number is not None:
|
||||
db_member.apartment_number = input.apartment_number
|
||||
if input.zip is not None:
|
||||
db_member.zip = input.zip
|
||||
if input.city is not None:
|
||||
db_member.city = input.city
|
||||
if input.country is not None:
|
||||
db_member.country = input.country
|
||||
if input.email is not None:
|
||||
db_member.email = input.email
|
||||
if input.phone is not None:
|
||||
db_member.phone = input.phone
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(db_member)
|
||||
|
||||
return Member(
|
||||
id=db_member.id,
|
||||
first_name=db_member.first_name,
|
||||
last_name=db_member.last_name,
|
||||
street=db_member.street,
|
||||
apartment_number=db_member.apartment_number,
|
||||
zip=db_member.zip,
|
||||
city=db_member.city,
|
||||
country=db_member.country,
|
||||
email=db_member.email,
|
||||
phone=db_member.phone,
|
||||
created_at=db_member.created_at,
|
||||
updated_at=db_member.updated_at,
|
||||
)
|
||||
|
||||
@strawberry.mutation
|
||||
async def delete_member(self, id: int) -> bool:
|
||||
"""Delete a member by ID."""
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(MemberModel).where(MemberModel.id == id)
|
||||
)
|
||||
db_member = result.scalar_one_or_none()
|
||||
|
||||
if db_member is None:
|
||||
raise MemberNotFoundError(f"Member with ID {id} not found")
|
||||
|
||||
await session.delete(db_member)
|
||||
await session.commit()
|
||||
|
||||
return True
|
||||
Reference in New Issue
Block a user