forked from andika/membership-be
- 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
|
return current_user
|
||||||
|
|
||||||
async def get_active_member(current_user: User = Depends(get_current_user)) -> 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
|
from models import UserStatus
|
||||||
|
|
||||||
if current_user.status != UserStatus.active:
|
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)
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Member access only"
|
detail="Member access only"
|
||||||
|
|||||||
@@ -530,7 +530,7 @@ CREATE TABLE IF NOT EXISTS storage_usage (
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
total_bytes_used BIGINT NOT NULL DEFAULT 0,
|
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
|
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
|
SELECT
|
||||||
gen_random_uuid(),
|
gen_random_uuid(),
|
||||||
0,
|
0,
|
||||||
10737418240, -- 10GB
|
1073741824, -- 1GB
|
||||||
CURRENT_TIMESTAMP
|
CURRENT_TIMESTAMP
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM storage_usage);
|
WHERE NOT EXISTS (SELECT 1 FROM storage_usage);
|
||||||
|
|
||||||
|
|||||||
284
server.py
284
server.py
@@ -17,7 +17,7 @@ import csv
|
|||||||
import io
|
import io
|
||||||
|
|
||||||
from database import engine, get_db, Base
|
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 (
|
from auth import (
|
||||||
get_password_hash,
|
get_password_hash,
|
||||||
verify_password,
|
verify_password,
|
||||||
@@ -976,32 +976,53 @@ async def update_profile(
|
|||||||
# Member Directory Routes
|
# Member Directory Routes
|
||||||
@api_router.get("/members/directory")
|
@api_router.get("/members/directory")
|
||||||
async def get_member_directory(
|
async def get_member_directory(
|
||||||
|
search: Optional[str] = None,
|
||||||
current_user: User = Depends(get_active_member),
|
current_user: User = Depends(get_active_member),
|
||||||
db: Session = Depends(get_db)
|
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.show_in_directory == True,
|
||||||
User.role == UserRole.member,
|
|
||||||
User.status == UserStatus.active
|
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 [{
|
return [{
|
||||||
"id": str(member.id),
|
"id": str(member.id),
|
||||||
"first_name": member.first_name,
|
"first_name": member.first_name,
|
||||||
"last_name": member.last_name,
|
"last_name": member.last_name,
|
||||||
|
"role": member.role.value if member.role else None,
|
||||||
"profile_photo_url": member.profile_photo_url,
|
"profile_photo_url": member.profile_photo_url,
|
||||||
"directory_email": member.directory_email,
|
"directory_email": member.directory_email,
|
||||||
"directory_bio": member.directory_bio,
|
"directory_bio": member.directory_bio,
|
||||||
"directory_address": member.directory_address,
|
"directory_address": member.directory_address,
|
||||||
"directory_phone": member.directory_phone,
|
"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,
|
"directory_partner_name": member.directory_partner_name,
|
||||||
"volunteer_interests": member.volunteer_interests or [],
|
"volunteer_interests": member.volunteer_interests or [],
|
||||||
"social_media_facebook": member.social_media_facebook,
|
"social_media_facebook": member.social_media_facebook,
|
||||||
"social_media_instagram": member.social_media_instagram,
|
"social_media_instagram": member.social_media_instagram,
|
||||||
"social_media_twitter": member.social_media_twitter,
|
"social_media_twitter": member.social_media_twitter,
|
||||||
"social_media_linkedin": member.social_media_linkedin,
|
"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
|
"created_at": member.created_at.isoformat() if member.created_at else None
|
||||||
} for member in directory_members]
|
} for member in directory_members]
|
||||||
|
|
||||||
@@ -1011,11 +1032,10 @@ async def get_directory_member_profile(
|
|||||||
current_user: User = Depends(get_active_member),
|
current_user: User = Depends(get_active_member),
|
||||||
db: Session = Depends(get_db)
|
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(
|
member = db.query(User).filter(
|
||||||
User.id == user_id,
|
User.id == user_id,
|
||||||
User.show_in_directory == True,
|
User.show_in_directory == True,
|
||||||
User.role == UserRole.member,
|
|
||||||
User.status == UserStatus.active
|
User.status == UserStatus.active
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -1026,18 +1046,20 @@ async def get_directory_member_profile(
|
|||||||
"id": str(member.id),
|
"id": str(member.id),
|
||||||
"first_name": member.first_name,
|
"first_name": member.first_name,
|
||||||
"last_name": member.last_name,
|
"last_name": member.last_name,
|
||||||
|
"role": member.role.value if member.role else None,
|
||||||
"profile_photo_url": member.profile_photo_url,
|
"profile_photo_url": member.profile_photo_url,
|
||||||
"directory_email": member.directory_email,
|
"directory_email": member.directory_email,
|
||||||
"directory_bio": member.directory_bio,
|
"directory_bio": member.directory_bio,
|
||||||
"directory_address": member.directory_address,
|
"directory_address": member.directory_address,
|
||||||
"directory_phone": member.directory_phone,
|
"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,
|
"directory_partner_name": member.directory_partner_name,
|
||||||
"volunteer_interests": member.volunteer_interests or [],
|
"volunteer_interests": member.volunteer_interests or [],
|
||||||
"social_media_facebook": member.social_media_facebook,
|
"social_media_facebook": member.social_media_facebook,
|
||||||
"social_media_instagram": member.social_media_instagram,
|
"social_media_instagram": member.social_media_instagram,
|
||||||
"social_media_twitter": member.social_media_twitter,
|
"social_media_twitter": member.social_media_twitter,
|
||||||
"social_media_linkedin": member.social_media_linkedin,
|
"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
|
"created_at": member.created_at.isoformat() if member.created_at else None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1269,50 +1291,6 @@ async def get_calendar_events(
|
|||||||
|
|
||||||
return result
|
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
|
# Admin Calendar Sync Routes
|
||||||
@api_router.post("/admin/calendar/sync/{event_id}")
|
@api_router.post("/admin/calendar/sync/{event_id}")
|
||||||
async def sync_event_to_microsoft(
|
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
|
# Include the router in the main app
|
||||||
app.include_router(api_router)
|
app.include_router(api_router)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user