- Fix Member Directory to include staff- Implement Member Tiers settings endpoints

This commit is contained in:
Koncept Kit
2026-01-27 16:15:44 +07:00
parent ea87b3f6ee
commit ab0f098f99
3 changed files with 235 additions and 57 deletions

284
server.py
View File

@@ -17,7 +17,7 @@ import csv
import io
from database import engine, get_db, Base
from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus, StorageUsage, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument, Permission, RolePermission, Role, UserInvitation, InvitationStatus, ImportJob, ImportJobStatus, ImportRollbackAudit, Donation, DonationType, DonationStatus
from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus, StorageUsage, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument, Permission, RolePermission, Role, UserInvitation, InvitationStatus, ImportJob, ImportJobStatus, ImportRollbackAudit, Donation, DonationType, DonationStatus, SystemSettings
from auth import (
get_password_hash,
verify_password,
@@ -976,32 +976,53 @@ async def update_profile(
# Member Directory Routes
@api_router.get("/members/directory")
async def get_member_directory(
search: Optional[str] = None,
current_user: User = Depends(get_active_member),
db: Session = Depends(get_db)
):
"""Get list of all members who opted into the directory"""
directory_members = db.query(User).filter(
"""
Get list of all active users (members and staff) who opted into the directory.
Includes members, admins, finance, and superadmins who have:
- show_in_directory = True
- status = active
"""
query = db.query(User).filter(
User.show_in_directory == True,
User.role == UserRole.member,
User.status == UserStatus.active
).all()
)
# Optional search filter
if search:
search_term = f"%{search}%"
query = query.filter(
or_(
User.first_name.ilike(search_term),
User.last_name.ilike(search_term),
User.directory_bio.ilike(search_term)
)
)
directory_members = query.order_by(User.first_name, User.last_name).all()
return [{
"id": str(member.id),
"first_name": member.first_name,
"last_name": member.last_name,
"role": member.role.value if member.role else None,
"profile_photo_url": member.profile_photo_url,
"directory_email": member.directory_email,
"directory_bio": member.directory_bio,
"directory_address": member.directory_address,
"directory_phone": member.directory_phone,
"directory_dob": member.directory_dob,
"directory_dob": member.directory_dob.isoformat() if member.directory_dob else None,
"directory_partner_name": member.directory_partner_name,
"volunteer_interests": member.volunteer_interests or [],
"social_media_facebook": member.social_media_facebook,
"social_media_instagram": member.social_media_instagram,
"social_media_twitter": member.social_media_twitter,
"social_media_linkedin": member.social_media_linkedin,
"member_since": member.member_since.isoformat() if member.member_since else None,
"created_at": member.created_at.isoformat() if member.created_at else None
} for member in directory_members]
@@ -1011,11 +1032,10 @@ async def get_directory_member_profile(
current_user: User = Depends(get_active_member),
db: Session = Depends(get_db)
):
"""Get public directory profile of a specific member"""
"""Get public directory profile of a specific member or staff"""
member = db.query(User).filter(
User.id == user_id,
User.show_in_directory == True,
User.role == UserRole.member,
User.status == UserStatus.active
).first()
@@ -1026,18 +1046,20 @@ async def get_directory_member_profile(
"id": str(member.id),
"first_name": member.first_name,
"last_name": member.last_name,
"role": member.role.value if member.role else None,
"profile_photo_url": member.profile_photo_url,
"directory_email": member.directory_email,
"directory_bio": member.directory_bio,
"directory_address": member.directory_address,
"directory_phone": member.directory_phone,
"directory_dob": member.directory_dob,
"directory_dob": member.directory_dob.isoformat() if member.directory_dob else None,
"directory_partner_name": member.directory_partner_name,
"volunteer_interests": member.volunteer_interests or [],
"social_media_facebook": member.social_media_facebook,
"social_media_instagram": member.social_media_instagram,
"social_media_twitter": member.social_media_twitter,
"social_media_linkedin": member.social_media_linkedin,
"member_since": member.member_since.isoformat() if member.member_since else None,
"created_at": member.created_at.isoformat() if member.created_at else None
}
@@ -1269,50 +1291,6 @@ async def get_calendar_events(
return result
# Members Directory Route
@api_router.get("/members/directory")
async def get_members_directory(
search: Optional[str] = None,
current_user: User = Depends(get_active_member),
db: Session = Depends(get_db)
):
"""Get members directory - only shows active members who opted in"""
query = db.query(User).filter(
User.show_in_directory == True,
User.status == UserStatus.active
)
if search:
search_term = f"%{search}%"
query = query.filter(
or_(
User.first_name.ilike(search_term),
User.last_name.ilike(search_term),
User.directory_bio.ilike(search_term)
)
)
members = query.order_by(User.first_name, User.last_name).all()
return [
{
"id": str(member.id),
"first_name": member.first_name,
"last_name": member.last_name,
"profile_photo_url": member.profile_photo_url,
"directory_email": member.directory_email,
"directory_bio": member.directory_bio,
"directory_address": member.directory_address,
"directory_phone": member.directory_phone,
"directory_partner_name": member.directory_partner_name,
"social_media_facebook": member.social_media_facebook,
"social_media_instagram": member.social_media_instagram,
"social_media_twitter": member.social_media_twitter,
"social_media_linkedin": member.social_media_linkedin
}
for member in members
]
# Admin Calendar Sync Routes
@api_router.post("/admin/calendar/sync/{event_id}")
async def sync_event_to_microsoft(
@@ -7032,6 +7010,206 @@ async def update_stripe_settings(
)
# ============================================================================
# Member Tiers Settings
# ============================================================================
# Default tier configuration
DEFAULT_MEMBER_TIERS = {
"tiers": [
{
"id": "new_member",
"label": "New Member",
"minYears": 0,
"maxYears": 0.999,
"iconKey": "sparkle",
"badgeClass": "bg-blue-100 text-blue-800 border-blue-200"
},
{
"id": "member_1_year",
"label": "1 Year Member",
"minYears": 1,
"maxYears": 2.999,
"iconKey": "star",
"badgeClass": "bg-green-100 text-green-800 border-green-200"
},
{
"id": "member_3_year",
"label": "3+ Year Member",
"minYears": 3,
"maxYears": 4.999,
"iconKey": "award",
"badgeClass": "bg-purple-100 text-purple-800 border-purple-200"
},
{
"id": "veteran",
"label": "Veteran Member",
"minYears": 5,
"maxYears": 999,
"iconKey": "crown",
"badgeClass": "bg-amber-100 text-amber-800 border-amber-200"
}
]
}
class MemberTier(BaseModel):
"""Single tier definition"""
id: str = Field(..., min_length=1, max_length=50)
label: str = Field(..., min_length=1, max_length=100)
minYears: float = Field(..., ge=0)
maxYears: float = Field(..., gt=0)
iconKey: str = Field(..., min_length=1, max_length=50)
badgeClass: str = Field(..., min_length=1, max_length=200)
class MemberTiersConfig(BaseModel):
"""Member tiers configuration"""
tiers: List[MemberTier] = Field(..., min_length=1, max_length=10)
@validator('tiers')
def validate_tiers_no_overlap(cls, tiers):
"""Ensure tiers are sorted and don't overlap"""
sorted_tiers = sorted(tiers, key=lambda t: t.minYears)
for i in range(len(sorted_tiers) - 1):
current = sorted_tiers[i]
next_tier = sorted_tiers[i + 1]
if current.maxYears >= next_tier.minYears:
raise ValueError(
f"Tier '{current.label}' (max: {current.maxYears}) overlaps with "
f"'{next_tier.label}' (min: {next_tier.minYears})"
)
return sorted_tiers
@api_router.get("/settings/member-tiers")
async def get_member_tiers_public(
current_user: User = Depends(get_active_member),
db: Session = Depends(get_db)
):
"""
Get member tier configuration (for active members/staff).
Returns the tier definitions used to display membership badges.
"""
import json
tiers_json = get_setting(db, 'member_tiers')
if tiers_json:
try:
return json.loads(tiers_json)
except json.JSONDecodeError:
# Fall back to default if stored JSON is invalid
return DEFAULT_MEMBER_TIERS
return DEFAULT_MEMBER_TIERS
@api_router.get("/admin/settings/member-tiers")
async def get_member_tiers_admin(
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""
Get member tier configuration (admin view).
Returns the tier definitions along with metadata about last update.
"""
import json
tiers_json = get_setting(db, 'member_tiers')
# Get the setting record for metadata
setting = db.query(SystemSettings).filter(
SystemSettings.setting_key == 'member_tiers'
).first()
config = DEFAULT_MEMBER_TIERS
if tiers_json:
try:
config = json.loads(tiers_json)
except json.JSONDecodeError:
pass
return {
"config": config,
"is_default": tiers_json is None,
"updated_at": setting.updated_at.isoformat() if setting else None,
"updated_by": f"{setting.updater.first_name} {setting.updater.last_name}" if setting and setting.updater else None
}
@api_router.put("/admin/settings/member-tiers")
async def update_member_tiers(
request: MemberTiersConfig,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""
Update member tier configuration (admin only).
Validates tier definitions to ensure:
- No overlapping year ranges
- All required fields present
- Tiers are sorted by minYears
"""
import json
try:
# Convert to dict for JSON storage
tiers_dict = {"tiers": [tier.dict() for tier in request.tiers]}
tiers_json = json.dumps(tiers_dict)
# Store using set_setting helper
set_setting(
db=db,
key='member_tiers',
value=tiers_json,
user_id=str(current_user.id),
setting_type='json',
description='Member tier badge configuration',
is_sensitive=False
)
return {
"success": True,
"message": "Member tiers updated successfully",
"config": tiers_dict,
"updated_at": datetime.now(timezone.utc).isoformat(),
"updated_by": f"{current_user.first_name} {current_user.last_name}"
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to update member tiers: {str(e)}"
)
@api_router.post("/admin/settings/member-tiers/reset")
async def reset_member_tiers(
current_user: User = Depends(get_current_superadmin),
db: Session = Depends(get_db)
):
"""
Reset member tiers to default configuration (superadmin only).
"""
# Delete the setting to revert to defaults
db.query(SystemSettings).filter(
SystemSettings.setting_key == 'member_tiers'
).delete()
db.commit()
return {
"success": True,
"message": "Member tiers reset to defaults",
"config": DEFAULT_MEMBER_TIERS
}
# Include the router in the main app
app.include_router(api_router)