From ab0f098f99f2ef6122ec43e0a8a409cb0c21abd3 Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:15:44 +0700 Subject: [PATCH] - Fix Member Directory to include staff- Implement Member Tiers settings endpoints --- auth.py | 4 +- migrations/000_initial_schema.sql | 4 +- server.py | 284 ++++++++++++++++++++++++------ 3 files changed, 235 insertions(+), 57 deletions(-) diff --git a/auth.py b/auth.py index 3371604..f25a1d6 100644 --- a/auth.py +++ b/auth.py @@ -128,7 +128,7 @@ async def get_current_admin_user(current_user: User = Depends(get_current_user)) return current_user async def get_active_member(current_user: User = Depends(get_current_user)) -> User: - """Require user to be active member with valid payment""" + """Require user to be active member or staff with valid status""" from models import UserStatus if current_user.status != UserStatus.active: @@ -138,7 +138,7 @@ async def get_active_member(current_user: User = Depends(get_current_user)) -> U ) role_code = get_user_role_code(current_user) - if role_code not in ["member", "admin", "superadmin"]: + if role_code not in ["member", "admin", "superadmin", "finance"]: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Member access only" diff --git a/migrations/000_initial_schema.sql b/migrations/000_initial_schema.sql index bf84705..8ed4677 100644 --- a/migrations/000_initial_schema.sql +++ b/migrations/000_initial_schema.sql @@ -530,7 +530,7 @@ CREATE TABLE IF NOT EXISTS storage_usage ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), total_bytes_used BIGINT NOT NULL DEFAULT 0, - max_bytes_allowed BIGINT NOT NULL DEFAULT 10737418240, -- 10GB + max_bytes_allowed BIGINT NOT NULL DEFAULT 1073741824, -- 1GB last_updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); @@ -659,7 +659,7 @@ INSERT INTO storage_usage (id, total_bytes_used, max_bytes_allowed, last_updated SELECT gen_random_uuid(), 0, - 10737418240, -- 10GB + 1073741824, -- 1GB CURRENT_TIMESTAMP WHERE NOT EXISTS (SELECT 1 FROM storage_usage); diff --git a/server.py b/server.py index 3ed669e..ec068e7 100644 --- a/server.py +++ b/server.py @@ -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)