import requests from dotenv import load_dotenv import os import pandas as pd # Import pandas load_dotenv() BLIZZ_CLIENT_ID = os.getenv("BLIZZ_CLIENT_ID") BLIZZ_CLIENT_SECRET = os.getenv("BLIZZ_CLIENT_SECRET") TOKEN_URL = "https://oauth.battle.net/token" NAMESPACE = "profile-classic-us" LOCALE = "en_US" # Define CSV constants CSV_ACHIEVEMENT_FILE_PATH = 'util/raid_achievements.csv' CSV_ID_COLUMN_NAME = 'ID' CSV_NAME_COLUMN_NAME = 'Name' # Global store for pre-loaded CSV data and any loading error RAID_ACHIEVEMENTS_DATA = None CSV_LOAD_ERROR_MESSAGE = None def _load_raid_achievements_from_csv(): """Loads achievement data from the CSV file into a global variable once.""" global RAID_ACHIEVEMENTS_DATA, CSV_LOAD_ERROR_MESSAGE try: df = pd.read_csv(CSV_ACHIEVEMENT_FILE_PATH) if CSV_ID_COLUMN_NAME not in df.columns or CSV_NAME_COLUMN_NAME not in df.columns: CSV_LOAD_ERROR_MESSAGE = f"Error: Required columns ('{CSV_ID_COLUMN_NAME}', '{CSV_NAME_COLUMN_NAME}') not found in {CSV_ACHIEVEMENT_FILE_PATH}." print(CSV_LOAD_ERROR_MESSAGE) RAID_ACHIEVEMENTS_DATA = [] # Ensure it's an empty list on column error return temp_list = [] for index, row in df.iterrows(): temp_list.append({ 'id': row[CSV_ID_COLUMN_NAME], 'name': row[CSV_NAME_COLUMN_NAME] }) RAID_ACHIEVEMENTS_DATA = temp_list print(f"Successfully loaded {len(RAID_ACHIEVEMENTS_DATA)} achievements from {CSV_ACHIEVEMENT_FILE_PATH} at startup.") except FileNotFoundError: CSV_LOAD_ERROR_MESSAGE = f"Error: CSV file {CSV_ACHIEVEMENT_FILE_PATH} not found at startup." print(CSV_LOAD_ERROR_MESSAGE) RAID_ACHIEVEMENTS_DATA = [] # Ensure it's an empty list on file not found except Exception as e: CSV_LOAD_ERROR_MESSAGE = f"An unexpected error occurred while loading {CSV_ACHIEVEMENT_FILE_PATH} at startup: {e}" print(CSV_LOAD_ERROR_MESSAGE) RAID_ACHIEVEMENTS_DATA = [] # Ensure it's an empty list on other errors # Load CSV data when the module is imported _load_raid_achievements_from_csv() def get_token(): '''Fetches an access token from the Battle.net API.''' if not BLIZZ_CLIENT_ID or not BLIZZ_CLIENT_SECRET: print("Error: CLIENT_ID or CLIENT_SECRET not found in .env file.") print("Please create a .env file in the project root with your credentials:") print("CLIENT_ID=\"YOUR_CLIENT_ID\"") print("CLIENT_SECRET=\"YOUR_CLIENT_SECRET\"") return None data = { "grant_type": "client_credentials" } auth = (BLIZZ_CLIENT_ID, BLIZZ_CLIENT_SECRET) try: response = requests.post(TOKEN_URL, data=data, auth=auth, timeout=10) response.raise_for_status() # Raises an HTTPError for bad responses (4XX or 5XX) token_data = response.json() print("Access token fetched successfully!") # print(f"Token: {token_data.get('access_token')[:20]}...") # Print part of the token for brevity return token_data.get("access_token") except requests.exceptions.HTTPError as http_err: print(f"HTTP error occurred while fetching token: {http_err}") print(f"Response content: {response.content}") except requests.exceptions.RequestException as req_err: print(f"Request error occurred while fetching token: {req_err}") except Exception as e: print(f"An unexpected error occurred: {e}") return None def get_raiders(access_token, realm, guild): '''Fetches a guild's roster from the Battle.net API and returns level 85 characters.''' # Sanitize realm and guild names for URL (simple lowercase and space to hyphen) realm_slug = realm.lower().replace(" ", "-") guild_slug = guild.lower().replace(" ", "-") url = f"https://us.api.blizzard.com/data/wow/guild/{realm_slug}/{guild_slug}/roster?namespace={NAMESPACE}&locale={LOCALE}" headers = { "Authorization": f"Bearer {access_token}" } try: response = requests.get(url, headers=headers, timeout=15) response.raise_for_status() # Raise an exception for bad status codes roster_data = response.json() character_names = [] if roster_data and 'members' in roster_data and isinstance(roster_data['members'], list): for member_entry in roster_data['members']: if isinstance(member_entry, dict) and 'character' in member_entry: character = member_entry['character'] if isinstance(character, dict) and \ 'name' in character and \ 'level' in character and \ character['level'] == 85: character_names.append(character['name']) if not character_names: return "No level 85 characters found in the guild." return character_names else: return "Could not find 'members' list in roster data, or the roster is empty. The guild might not exist or is not accessible." except requests.exceptions.HTTPError as http_err: if http_err.response.status_code == 404: return f"Error: Guild '{guild}' on realm '{realm}' not found (404)." return f"HTTP error occurred while fetching roster: {http_err} - {http_err.response.text}" except requests.exceptions.RequestException as req_err: return f"Request error occurred while fetching roster: {req_err}" except Exception as e: return f"An unexpected error occurred: {e}" def get_char_achievements(access_token, realm, character): ''' Fetches a character's achievements from the Battle.net API, compares them against a predefined, pre-loaded CSV list of achievements, and returns a comprehensive list indicating completion status for CSV achievements. ''' global RAID_ACHIEVEMENTS_DATA, CSV_LOAD_ERROR_MESSAGE # Access globals # Check if CSV loading failed during startup if CSV_LOAD_ERROR_MESSAGE: # If CSV failed to load, we can't provide the comprehensive list. # Return an error reflecting this. The `data: []` part is for consistency. return {"error": True, "message": f"Prerequisite CSV data failed to load: {CSV_LOAD_ERROR_MESSAGE}", "data": []} if RAID_ACHIEVEMENTS_DATA is None or not RAID_ACHIEVEMENTS_DATA: # Should be initialized by _load_raid_achievements_from_csv # This case implies the CSV was empty or another issue post-initial load attempt. return {"error": True, "message": "Raid achievements list from CSV is unavailable or empty.", "data": []} realm_slug = realm.lower().replace(" ", "-") character_name_slug = character.lower() api_url = f"https://us.api.blizzard.com/profile/wow/character/{realm_slug}/{character_name_slug}/achievements?namespace={NAMESPACE}&locale={LOCALE}" headers = {"Authorization": f"Bearer {access_token}"} char_raw_achievements = None try: response = requests.get(api_url, headers=headers, timeout=15) response.raise_for_status() char_raw_achievements = response.json() except requests.exceptions.HTTPError as http_err: msg = f"HTTP error fetching achievements for {character} on {realm}: {http_err}" if http_err.response is not None: msg += f" - {http_err.response.text}" print(msg) # Even if API fails for char, we should still return the CSV list marked as incomplete # Or, indicate this specific character API fetch failed. For now, let's try to return CSV marked as incomplete. # This behavior might need adjustment based on desired user experience. comprehensive_status_list_on_api_error = [] for ach_from_csv in RAID_ACHIEVEMENTS_DATA: comprehensive_status_list_on_api_error.append({ 'id': ach_from_csv['id'], 'name': ach_from_csv['name'], 'completed': False # Mark as false due to API error for this char }) return {"error": True, "message": msg, "status_code": http_err.response.status_code if http_err.response else 500, "data_source": "csv_marked_incomplete", "achievements_list": comprehensive_status_list_on_api_error} except requests.exceptions.RequestException as req_err: msg = f"Request error fetching achievements for {character} on {realm}: {req_err}" print(msg) # Similar handling for request errors comprehensive_status_list_on_api_error = [] for ach_from_csv in RAID_ACHIEVEMENTS_DATA: comprehensive_status_list_on_api_error.append({ 'id': ach_from_csv['id'], 'name': ach_from_csv['name'], 'completed': False }) return {"error": True, "message": msg, "data_source": "csv_marked_incomplete", "achievements_list": comprehensive_status_list_on_api_error} except Exception as e: msg = f"Unexpected error fetching raw achievements for {character} on {realm}: {e}" print(msg) # Similar handling for unexpected errors comprehensive_status_list_on_api_error = [] for ach_from_csv in RAID_ACHIEVEMENTS_DATA: comprehensive_status_list_on_api_error.append({ 'id': ach_from_csv['id'], 'name': ach_from_csv['name'], 'completed': False }) return {"error": True, "message": msg, "data_source": "csv_marked_incomplete", "achievements_list": comprehensive_status_list_on_api_error} completed_char_achievement_ids = set() if char_raw_achievements and 'achievements' in char_raw_achievements and isinstance(char_raw_achievements['achievements'], list): for ach_details in char_raw_achievements['achievements']: if isinstance(ach_details, dict) and ach_details.get('id') is not None: completed_char_achievement_ids.add(ach_details.get('id')) else: print(f"Warning: Could not parse 'achievements' list from API for {character}. Assuming no achievements completed.") comprehensive_status_list = [] for ach_from_csv in RAID_ACHIEVEMENTS_DATA: # Iterate over pre-loaded data is_completed = ach_from_csv['id'] in completed_char_achievement_ids comprehensive_status_list.append({ 'id': ach_from_csv['id'], 'name': ach_from_csv['name'], 'completed': is_completed }) return comprehensive_status_list # Successful return def calculate_achievement_completion_percentages(ach_data_by_character): """ Analyzes a dictionary of character achievement statuses to determine completion percentages and lists of characters who have/haven't completed each achievement. Args: ach_data_by_character: A dictionary where keys are character names and values are lists of achievement statuses (output from get_char_achievements or the 'achievements_list' from an error object). Each status dict: {'id': ach_id, 'name': ach_name, 'completed': True/False}. Returns: A list of dictionaries, where each dictionary contains: {'id': achievement_id, 'name': achievement_name, 'percentage_completed': float, 'completed_by': [char_name1, ...], 'not_completed_by': [char_name2, ...]} """ if not ach_data_by_character or not isinstance(ach_data_by_character, dict): print("Warning: Received invalid or empty input for percentage calculation.") return [] achievement_details = {} # Example: achievement_details = {ach_id: {'name': 'Ach Name', 'completed_count': 0, 'completed_by': [], 'not_completed_by': []}} valid_character_names_for_count = [] for char_name, char_ach_list in ach_data_by_character.items(): if not isinstance(char_ach_list, list): # Skip if a character's data isn't a list print(f"Warning: Skipping non-list achievement data for character '{char_name}'. Type: {type(char_ach_list)}") continue valid_character_names_for_count.append(char_name) # Count this character as processed for denominator for ach_status in char_ach_list: ach_id = ach_status.get('id') ach_name = ach_status.get('name') if ach_id is None or ach_name is None: # Skip malformed entries continue if ach_id not in achievement_details: achievement_details[ach_id] = { 'name': ach_name, 'completed_count': 0, 'completed_by': [], 'not_completed_by': [] } if ach_status.get('completed', False): achievement_details[ach_id]['completed_count'] += 1 achievement_details[ach_id]['completed_by'].append(char_name) else: achievement_details[ach_id]['not_completed_by'].append(char_name) num_valid_characters = len(valid_character_names_for_count) if num_valid_characters == 0: print("Warning: No valid character achievement lists to process for percentages after filtering.") return [] # Ensure all characters are in not_completed_by if they weren't in completed_by for an achievement # This handles cases where an achievement might not have been in a specific character's list for some reason, # though ideally each char_ach_list is comprehensive based on the CSV. # For robustness, we iterate through all known valid characters for each achievement. for ach_id in achievement_details: current_completed_by = set(achievement_details[ach_id]['completed_by']) # Rebuild not_completed_by based on all valid characters minus those who completed it achievement_details[ach_id]['not_completed_by'] = [ name for name in valid_character_names_for_count if name not in current_completed_by ] result_summary = [] for ach_id, details in achievement_details.items(): percentage = (details['completed_count'] / num_valid_characters) * 100.0 if num_valid_characters > 0 else 0.0 result_summary.append({ 'id': ach_id, 'name': details['name'], 'percentage_completed': round(percentage, 2), 'completed_by': sorted(details['completed_by']), # Sort for consistent output 'not_completed_by': sorted(details['not_completed_by']) # Sort for consistent output }) result_summary.sort(key=lambda x: x['id']) # Sort by achievement ID return result_summary def get_char_achievement_stats(access_token, realm, character): ''' Fetches a character's achievements from the Battle.net API, compares them against a predefined, pre-loaded CSV list of achievements, and returns a comprehensive list indicating completion status for CSV achievements. ''' global RAID_ACHIEVEMENTS_DATA, CSV_LOAD_ERROR_MESSAGE # Access globals # Check if CSV loading failed during startup if CSV_LOAD_ERROR_MESSAGE: # If CSV failed to load, we can't provide the comprehensive list. # Return an error reflecting this. The `data: []` part is for consistency. return {"error": True, "message": f"Prerequisite CSV data failed to load: {CSV_LOAD_ERROR_MESSAGE}", "data": []} if RAID_ACHIEVEMENTS_DATA is None or not RAID_ACHIEVEMENTS_DATA: # Should be initialized by _load_raid_achievements_from_csv # This case implies the CSV was empty or another issue post-initial load attempt. return {"error": True, "message": "Raid achievements list from CSV is unavailable or empty.", "data": []} realm_slug = realm.lower().replace(" ", "-") character_name_slug = character.lower() api_url = f"https://us.api.blizzard.com/profile/wow/character/{realm_slug}/{character_name_slug}/achievements?namespace={NAMESPACE}&locale={LOCALE}" headers = {"Authorization": f"Bearer {access_token}"} char_raw_achievements = None try: response = requests.get(api_url, headers=headers, timeout=15) response.raise_for_status() char_raw_achievements = response.json() except requests.exceptions.HTTPError as http_err: msg = f"HTTP error fetching achievements for {character} on {realm}: {http_err}" if http_err.response is not None: msg += f" - {http_err.response.text}" print(msg) # Even if API fails for char, we should still return the CSV list marked as incomplete # Or, indicate this specific character API fetch failed. For now, let's try to return CSV marked as incomplete. # This behavior might need adjustment based on desired user experience. comprehensive_status_list_on_api_error = [] for ach_from_csv in RAID_ACHIEVEMENTS_DATA: comprehensive_status_list_on_api_error.append({ 'id': ach_from_csv['id'], 'name': ach_from_csv['name'], 'completed': False # Mark as false due to API error for this char }) return {"error": True, "message": msg, "status_code": http_err.response.status_code if http_err.response else 500, "data_source": "csv_marked_incomplete", "achievements_list": comprehensive_status_list_on_api_error} except requests.exceptions.RequestException as req_err: msg = f"Request error fetching achievements for {character} on {realm}: {req_err}" print(msg) # Similar handling for request errors comprehensive_status_list_on_api_error = [] for ach_from_csv in RAID_ACHIEVEMENTS_DATA: comprehensive_status_list_on_api_error.append({ 'id': ach_from_csv['id'], 'name': ach_from_csv['name'], 'completed': False }) return {"error": True, "message": msg, "data_source": "csv_marked_incomplete", "achievements_list": comprehensive_status_list_on_api_error} except Exception as e: msg = f"Unexpected error fetching raw achievements for {character} on {realm}: {e}" print(msg) # Similar handling for unexpected errors comprehensive_status_list_on_api_error = [] for ach_from_csv in RAID_ACHIEVEMENTS_DATA: comprehensive_status_list_on_api_error.append({ 'id': ach_from_csv['id'], 'name': ach_from_csv['name'], 'completed': False }) return {"error": True, "message": msg, "data_source": "csv_marked_incomplete", "achievements_list": comprehensive_status_list_on_api_error} completed_char_achievement_ids = set() if char_raw_achievements and 'achievements' in char_raw_achievements and isinstance(char_raw_achievements['achievements'], list): for ach_details in char_raw_achievements['achievements']: if isinstance(ach_details, dict) and ach_details.get('id') is not None: completed_char_achievement_ids.add(ach_details.get('id')) else: print(f"Warning: Could not parse 'achievements' list from API for {character}. Assuming no achievements completed.") comprehensive_status_list = [] for ach_from_csv in RAID_ACHIEVEMENTS_DATA: # Iterate over pre-loaded data is_completed = ach_from_csv['id'] in completed_char_achievement_ids comprehensive_status_list.append({ 'id': ach_from_csv['id'], 'name': ach_from_csv['name'], 'completed': is_completed }) return comprehensive_status_list # Successful return