- Fix Member Directory to include staff- Implement Member Tiers settings endpoints
This commit is contained in:
4
auth.py
4
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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
284
server.py
284
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user