diff --git a/backend/fix_numeric_slugs.py b/backend/fix_numeric_slugs.py new file mode 100644 index 0000000..acba199 --- /dev/null +++ b/backend/fix_numeric_slugs.py @@ -0,0 +1,89 @@ +from sqlmodel import Session, create_engine, select +import os +from models import Show, Song, Venue, Performance, Tour +from slugify import generate_slug, generate_show_slug, generate_performance_slug + +# Use environment variable or default to local sqlite for testing +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./database.db") +engine = create_engine(DATABASE_URL) + +def fix_numeric_slugs(): + with Session(engine) as session: + # 1. Songs + songs = session.exec(select(Song)).all() + for song in songs: + if song.slug and song.slug.isdigit(): + old_slug = song.slug + new_slug = generate_slug(song.title) + # Check for collisions + base_slug = new_slug + counter = 1 + while session.exec(select(Song).where(Song.slug == new_slug).where(Song.id != song.id)).first(): + new_slug = f"{base_slug}-{counter}" + counter += 1 + + print(f"Updating Song slug: {old_slug} -> {new_slug}") + song.slug = new_slug + session.add(song) + + # 2. Venues + venues = session.exec(select(Venue)).all() + for venue in venues: + if venue.slug and venue.slug.isdigit(): + old_slug = venue.slug + new_slug = generate_slug(venue.name) + # Check for collisions + base_slug = new_slug + counter = 1 + while session.exec(select(Venue).where(Venue.slug == new_slug).where(Venue.id != venue.id)).first(): + new_slug = f"{base_slug}-{counter}" + counter += 1 + + print(f"Updating Venue slug: {old_slug} -> {new_slug}") + venue.slug = new_slug + session.add(venue) + + # 3. Shows + shows = session.exec(select(Show)).all() + for show in shows: + if show.slug and show.slug.isdigit(): + old_slug = show.slug + venue_name = show.venue.name if (show.venue) else "unknown" + new_slug = generate_show_slug(show.date.strftime("%Y-%m-%d"), venue_name) + + # Check for collisions + base_slug = new_slug + counter = 1 + while session.exec(select(Show).where(Show.slug == new_slug).where(Show.id != show.id)).first(): + new_slug = f"{base_slug}-{counter}" + counter += 1 + + print(f"Updating Show slug: {old_slug} -> {new_slug}") + show.slug = new_slug + session.add(show) + + # 4. Performances (checking just in case) + performances = session.exec(select(Performance)).all() + for perf in performances: + if perf.slug and perf.slug.isdigit(): + old_slug = perf.slug + song_title = perf.song.title if perf.song else "unknown" + show_date = perf.show.date.strftime("%Y-%m-%d") if perf.show else "unknown" + new_slug = generate_performance_slug(song_title, show_date) + + # Check for collisions + base_slug = new_slug + counter = 1 + while session.exec(select(Performance).where(Performance.slug == new_slug).where(Performance.id != perf.id)).first(): + new_slug = f"{base_slug}-{counter}" + counter += 1 + + print(f"Updating Performance slug: {old_slug} -> {new_slug}") + perf.slug = new_slug + session.add(perf) + + session.commit() + print("Slug fixation complete.") + +if __name__ == "__main__": + fix_numeric_slugs() diff --git a/backend/inspect_api.py b/backend/inspect_api.py new file mode 100644 index 0000000..52abf01 --- /dev/null +++ b/backend/inspect_api.py @@ -0,0 +1,18 @@ +import requests +import json + +def fetch_sample_setlists(): + url = "https://elgoose.net/api/v2/setlists.json" + try: + response = requests.get(url, params={"page": 1}) + data = response.json() + if 'data' in data: + print(json.dumps(data['data'][:5], indent=2)) + # Stats on setnumber + setnumbers = [x.get('setnumber') for x in data['data']] + print(f"\nUnique setnumbers in page 1: {set(setnumbers)}") + except Exception as e: + print(f"Error: {e}") + +if __name__ == "__main__": + fetch_sample_setlists() diff --git a/backend/routers/performances.py b/backend/routers/performances.py index 6d48d10..8c96938 100644 --- a/backend/routers/performances.py +++ b/backend/routers/performances.py @@ -8,17 +8,11 @@ from auth import get_current_user router = APIRouter(prefix="/performances", tags=["performances"]) -@router.get("/{performance_id_or_slug}", response_model=PerformanceDetailRead) -def read_performance(performance_id_or_slug: str, session: Session = Depends(get_session)): - performance = None - if performance_id_or_slug.isdigit(): - performance = session.get(Performance, int(performance_id_or_slug)) - - if not performance: - # Try slug lookup - performance = session.exec( - select(Performance).where(Performance.slug == performance_id_or_slug) - ).first() +@router.get("/{slug}", response_model=PerformanceDetailRead) +def read_performance(slug: str, session: Session = Depends(get_session)): + performance = session.exec( + select(Performance).where(Performance.slug == slug) + ).first() if not performance: raise HTTPException(status_code=404, detail="Performance not found") @@ -43,13 +37,15 @@ def read_performance(performance_id_or_slug: str, session: Session = Depends(get current_index = i break - prev_id = None - next_id = None + prev_slug = None + next_slug = None gap = 0 times_played = current_index + 1 # 1-based count if current_index > 0: - prev_id = all_perfs_data[current_index - 1][0].id + prev_perf = all_perfs_data[current_index - 1][0] + prev_id = prev_perf.id + prev_slug = prev_perf.slug # Calculate Gap prev_date = all_perfs_data[current_index - 1][1].date @@ -62,7 +58,9 @@ def read_performance(performance_id_or_slug: str, session: Session = Depends(get ).one() if current_index < len(all_perfs_data) - 1: - next_id = all_perfs_data[current_index + 1][0].id + next_perf = all_perfs_data[current_index + 1][0] + next_id = next_perf.id + next_slug = next_perf.slug # Fetch ratings for all performances of this song rating_stats = session.exec( @@ -106,7 +104,9 @@ def read_performance(performance_id_or_slug: str, session: Session = Depends(get perf_dict['song'] = performance.song perf_dict['nicknames'] = performance.nicknames perf_dict['previous_performance_id'] = prev_id + perf_dict['previous_performance_slug'] = prev_slug perf_dict['next_performance_id'] = next_id + perf_dict['next_performance_slug'] = next_slug perf_dict['gap'] = gap perf_dict['times_played'] = times_played perf_dict['other_performances'] = other_performances diff --git a/backend/routers/search.py b/backend/routers/search.py index 45edd32..4e57960 100644 --- a/backend/routers/search.py +++ b/backend/routers/search.py @@ -1,6 +1,7 @@ from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from sqlmodel import Session, select, col +from sqlalchemy.orm import selectinload from database import get_session from models import Show, Song, Venue, Tour, User, Group, Performance, PerformanceNickname, Comment, Review from schemas import ShowRead, SongRead, VenueRead, TourRead, UserRead, GroupRead @@ -19,36 +20,53 @@ def global_search( q_str = f"%{q}%" # Search Songs - songs = session.exec(select(Song).where(col(Song.title).ilike(q_str)).limit(limit)).all() + songs = session.exec( + select(Song) + .where(col(Song.title).ilike(q_str)) + .limit(limit) + ).all() # Search Venues - venues = session.exec(select(Venue).where(col(Venue.name).ilike(q_str)).limit(limit)).all() + venues = session.exec( + select(Venue) + .where(col(Venue.name).ilike(q_str)) + .limit(limit) + ).all() # Search Tours - tours = session.exec(select(Tour).where(col(Tour.name).ilike(q_str)).limit(limit)).all() + tours = session.exec( + select(Tour) + .where(col(Tour.name).ilike(q_str)) + .limit(limit) + ).all() # Search Groups - groups = session.exec(select(Group).where(col(Group.name).ilike(q_str)).limit(limit)).all() + groups = session.exec( + select(Group) + .where(col(Group.name).ilike(q_str)) + .limit(limit) + ).all() # Search Users (by username or email) - users = session.exec(select(User).where(col(User.email).ilike(q_str)).limit(limit)).all() + users = session.exec( + select(User) + .where((col(User.email).ilike(q_str)) | (col(User.username).ilike(q_str))) + .limit(limit) + ).all() # Search Nicknames nicknames = session.exec( select(PerformanceNickname) + .options(selectinload(PerformanceNickname.performance).selectinload(Performance.song)) .where(col(PerformanceNickname.nickname).ilike(q_str)) .where(PerformanceNickname.status == "approved") .limit(limit) ).all() - # Search Performances (by notes, e.g. "unfinished", "slow version") - # We join with Song and Show to provide context in the frontend if needed, - # but for now let's just return the Performance object and let frontend fetch details - # or we can return a custom schema. - # Actually, let's just search notes for now. + # Search Performances performances = session.exec( select(Performance) - .join(Song) + .options(selectinload(Performance.song), selectinload(Performance.show)) .where(col(Performance.notes).ilike(q_str)) .limit(limit) ).all() diff --git a/backend/routers/shows.py b/backend/routers/shows.py index 12c5287..2da4246 100644 --- a/backend/routers/shows.py +++ b/backend/routers/shows.py @@ -48,12 +48,9 @@ def read_recent_shows( shows = session.exec(query).all() return shows -@router.get("/{show_id}", response_model=ShowRead) -def read_show(show_id: str, session: Session = Depends(get_session)): - if show_id.isdigit(): - show = session.get(Show, int(show_id)) - else: - show = session.exec(select(Show).where(Show.slug == show_id)).first() +@router.get("/{slug}", response_model=ShowRead) +def read_show(slug: str, session: Session = Depends(get_session)): + show = session.exec(select(Show).where(Show.slug == slug)).first() if not show: raise HTTPException(status_code=404, detail="Show not found") diff --git a/backend/routers/songs.py b/backend/routers/songs.py index 72698b1..fb4558a 100644 --- a/backend/routers/songs.py +++ b/backend/routers/songs.py @@ -23,16 +23,9 @@ def read_songs(offset: int = 0, limit: int = Query(default=100, le=100), session songs = session.exec(select(Song).offset(offset).limit(limit)).all() return songs -@router.get("/{song_id_or_slug}", response_model=SongReadWithStats) -def read_song(song_id_or_slug: str, session: Session = Depends(get_session)): - # Try to parse as int (ID), otherwise treat as slug - song = None - if song_id_or_slug.isdigit(): - song = session.get(Song, int(song_id_or_slug)) - - if not song: - # Try slug lookup - song = session.exec(select(Song).where(Song.slug == song_id_or_slug)).first() +@router.get("/{slug}", response_model=SongReadWithStats) +def read_song(slug: str, session: Session = Depends(get_session)): + song = session.exec(select(Song).where(Song.slug == slug)).first() if not song: raise HTTPException(status_code=404, detail="Song not found") diff --git a/backend/routers/tours.py b/backend/routers/tours.py index 8a1c279..38e2f5d 100644 --- a/backend/routers/tours.py +++ b/backend/routers/tours.py @@ -29,9 +29,9 @@ def read_tours( tours = session.exec(select(Tour).offset(offset).limit(limit)).all() return tours -@router.get("/{tour_id}", response_model=TourRead) -def read_tour(tour_id: int, session: Session = Depends(get_session)): - tour = session.get(Tour, tour_id) +@router.get("/{slug}", response_model=TourRead) +def read_tour(slug: str, session: Session = Depends(get_session)): + tour = session.exec(select(Tour).where(Tour.slug == slug)).first() if not tour: raise HTTPException(status_code=404, detail="Tour not found") return tour diff --git a/backend/routers/venues.py b/backend/routers/venues.py index ed0d8d2..2d23dea 100644 --- a/backend/routers/venues.py +++ b/backend/routers/venues.py @@ -21,14 +21,9 @@ def read_venues(offset: int = 0, limit: int = Query(default=100, le=100), sessio venues = session.exec(select(Venue).offset(offset).limit(limit)).all() return venues -@router.get("/{venue_id_or_slug}", response_model=VenueRead) -def read_venue(venue_id_or_slug: str, session: Session = Depends(get_session)): - venue = None - if venue_id_or_slug.isdigit(): - venue = session.get(Venue, int(venue_id_or_slug)) - - if not venue: - venue = session.exec(select(Venue).where(Venue.slug == venue_id_or_slug)).first() +@router.get("/{slug}", response_model=VenueRead) +def read_venue(slug: str, session: Session = Depends(get_session)): + venue = session.exec(select(Venue).where(Venue.slug == slug)).first() if not venue: raise HTTPException(status_code=404, detail="Venue not found") diff --git a/backend/services/gamification.py b/backend/services/gamification.py index dcff809..b45dd47 100644 --- a/backend/services/gamification.py +++ b/backend/services/gamification.py @@ -222,24 +222,31 @@ def check_and_award_badges(session: Session, user: User) -> List[Badge]: def get_leaderboard(session: Session, limit: int = 10) -> List[dict]: """Get top users by XP""" - # Test accounts to hide until we have real users - TEST_USER_EMAILS = ["tenwest", "testuser"] - MIN_USERS_TO_SHOW_TEST = 12 - - # Count total real users - total_users = session.exec( - select(func.count(User.id)) - .where(User.is_active == True) - ).one() or 0 + # Filter out test accounts + # Filter out test accounts + TEST_USER_EMAILS = [ + "tenwest", + "testuser", + "rescue@elmeg.xyz", + "admin-rescue@elmeg.xyz", + "admin@elmeg.xyz", + "test@", + "emailtest" + ] # Build query query = select(User).where(User.is_active == True) - # If we don't have enough real users, hide test accounts - if total_users < MIN_USERS_TO_SHOW_TEST: - for test_email in TEST_USER_EMAILS: - query = query.where(~User.email.ilike(f"{test_email}@%")) - query = query.where(~User.email.ilike(f"%{test_email}%")) + # Strictly filter out test accounts + for test_email in TEST_USER_EMAILS: + query = query.where(~User.email.ilike(f"%{test_email}%")) + + # Also honor the user's preference to hide from leaderboards + # We use != False to include None (defaulting to True) or explicit True + query = query.where(User.appear_in_leaderboards != False) + + # Hide users with 0 XP (inactive) + query = query.where(User.xp > 0) users = session.exec( query.order_by(User.xp.desc()).limit(limit) @@ -314,18 +321,18 @@ PURCHASABLE_COLORS = { "Rainbow": {"hex": "gradient", "cost": 2500, "min_level": 10}, } -# Flairs (small text/emoji beside username) +# Flairs (small text/icon name beside username) PURCHASABLE_FLAIRS = { - "⚡": {"cost": 100, "min_level": 1}, - "🎸": {"cost": 100, "min_level": 1}, - "🎵": {"cost": 100, "min_level": 1}, - "🌈": {"cost": 200, "min_level": 3}, - "🔥": {"cost": 200, "min_level": 3}, - "⭐": {"cost": 300, "min_level": 5}, - "👑": {"cost": 500, "min_level": 7}, - "🚀": {"cost": 400, "min_level": 6}, - "💎": {"cost": 750, "min_level": 9}, - "🌟": {"cost": 1000, "min_level": 10}, + "Bolt": {"cost": 100, "min_level": 1}, + "Guitar": {"cost": 100, "min_level": 1}, + "Music": {"cost": 100, "min_level": 1}, + "Rainbow": {"cost": 200, "min_level": 3}, + "Fire": {"cost": 200, "min_level": 3}, + "Star": {"cost": 300, "min_level": 5}, + "Crown": {"cost": 500, "min_level": 7}, + "Rocket": {"cost": 400, "min_level": 6}, + "Diamond": {"cost": 750, "min_level": 9}, + "Sparkles": {"cost": 1000, "min_level": 10}, } # Early adopter perks @@ -333,7 +340,7 @@ EARLY_ADOPTER_PERKS = { "free_title_change": True, # Early adopters can change title for free (once per month) "exclusive_titles": ["Pioneer", "Founding Member", "OG User", "Day One"], "exclusive_colors": {"Pioneer Gold": "#FFB347", "Genesis Green": "#50C878"}, - "exclusive_flair": ["🥇", "🏆"], + "exclusive_flair": ["Medal", "Trophy"], "title_color": "#FFB347", # Default gold color for early adopters "bonus_xp_multiplier": 1.1, # 10% XP bonus } diff --git a/backend/test_smtp_connection.py b/backend/test_smtp_connection.py new file mode 100644 index 0000000..22b5884 --- /dev/null +++ b/backend/test_smtp_connection.py @@ -0,0 +1,110 @@ + +import os +import sys +import logging +from datetime import datetime + +# Ensure the backend directory is in the path +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +try: + from services.email_service import email_service +except ImportError: + # If running from root, adjust path + sys.path.append(os.path.join(os.getcwd(), 'backend')) + from services.email_service import email_service + +def load_env_file(): + """Simple .env loader to avoid extra dependencies for this test script""" + env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env') + if not os.path.exists(env_path): + print(f"[WARN] No .env file found at {env_path}") + return + + print(f"[INFO] Loading environment from {env_path}") + with open(env_path, 'r') as f: + for line in f: + line = line.strip() + if not line or line.startswith('#'): + continue + try: + key, value = line.split('=', 1) + os.environ[key] = value.strip("'").strip('"') + except ValueError: + pass + +def test_smtp_connection(): + # Load .env first + load_env_file() + + # Re-import service to pick up env vars + # We need to re-instantiate or re-import because the original import + # might have happened before env vars were set if this was a module. + # However, since we import inside the function or after setting env in main, + # we should check how it was imported at top level. + # The top level import `from services.email_service import email_service` + # instantiated the class immediately. We need to re-instantiate it. + + from services.email_service import EmailService + email_service = EmailService() + + print("="*60) + print("ELMEG SMTP CONNECTION TEST") + print("="*60) + + # 1. Print Configuration + print("\n[1] Configuration Check:") + print(f" Provider: {email_service.provider}") + print(f" SMTP Host: {email_service.smtp_host}") + print(f" SMTP Port: {email_service.smtp_port}") + print(f" SMTP Username: {email_service.smtp_username}") + print(f" SMTP TLS: {email_service.smtp_use_tls}") + print(f" From Email: {email_service.email_from}") + + if email_service.provider != "smtp": + print("\n[!] ERROR: Email service is NOT configured for SMTP.") + print(f" Current provider is: {email_service.provider}") + print(" Please set SMTP_HOST, SMTP_PORT, SMTP_USERNAME, and SMTP_PASSWORD environment variables.") + return + + # 2. Ask for recipient + if len(sys.argv) > 1: + recipient = sys.argv[1] + else: + recipient = input("\nEnter recipient email address for test (e.g. your@email.com): ").strip() + if not recipient: + print("No recipient provided. Aborting.") + return + + # 3. Send Test Email + print(f"\n[2] Attempting to send test email to: {recipient}") + + subject = f"Elmeg Postal SMTP Test - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" + text_content = f"This is a test email from the Elmeg backend to verify Postal SMTP connectivity.\n\nSent at: {datetime.now()}" + html_content = f""" +
Aloha!
+This email confirms that your Elmeg backend is successfully connected to the Postal SMTP server.
+Server: {email_service.smtp_host}
+Port: {email_service.smtp_port}
+User: {email_service.smtp_username}
+Sent at {datetime.now()}
+- We'll review your submission and get back to you soon. + We'll review your submission and get back to you soon.
+
Report bugs, request features, or ask questions