diff --git a/README.md b/README.md index dfc0e7fb..45ff8797 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,8 @@ Every time you run the app it will check to see if you are using the latest vers If you wish to update the app yourself manually, you can just type `n` to skip automatically updating, and run `git pull origin main` manually from within the application directory on the command line. +If you wish to disable the automatic check for updates, you can set `CHECK_FOR_UPDATES=False` in your `.env` file and the app will skip checking GitHub for any updates. *Please note that until you set the `CHECK_FOR_UPDATES` environment variable back to its default value of `True`, the app will **never** attempt to check for updates again.* + --- diff --git a/RELEASE.md b/RELEASE.md index c2cfa98c..8b8ae173 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -82,4 +82,6 @@ git push ``` -16. Go to the [FFMWR Releases page](https://github.com/uberfastman/fantasy-football-metrics-weekly-report/releases) and draft a new release using the above git tag. +16. Open a pull request (PR) with the `release/vX.X.X` branch, allow GitHub actions to complete successfully, draft release notes, and merge it. + +17. Go to the [FFMWR Releases page](https://github.com/uberfastman/fantasy-football-metrics-weekly-report/releases) and draft a new release using the above git tag. diff --git a/calculate/bad_boy_stats.py b/calculate/bad_boy_stats.py deleted file mode 100644 index 6a913645..00000000 --- a/calculate/bad_boy_stats.py +++ /dev/null @@ -1,355 +0,0 @@ -__author__ = "Wren J. R. (uberfastman)" -__email__ = "uberfastman@uberfastman.dev" - -import itertools -import json -import os -import re -import string -from collections import OrderedDict -from pathlib import Path -from typing import Dict, List, Any, Union - -import requests -from bs4 import BeautifulSoup - -from utilities.constants import nfl_team_abbreviations, nfl_team_abbreviation_conversions -from utilities.logger import get_logger - -logger = get_logger(__name__, propagate=False) - - -class BadBoyStats(object): - - def __init__(self, data_dir: Path, save_data: bool = False, offline: bool = False, refresh: bool = False): - """ Initialize class, load data from USA Today NFL Arrest DB. Combine defensive player data - """ - logger.debug("Initializing bad boy stats.") - - self.save_data: bool = save_data - self.offline: bool = offline - self.refresh: bool = refresh - - # position type reference - self.position_types: Dict[str, str] = { - "C": "D", "CB": "D", "DB": "D", "DE": "D", "DE/DT": "D", "DT": "D", "LB": "D", "S": "D", "Safety": "D", - # defense - "FB": "O", "QB": "O", "RB": "O", "TE": "O", "WR": "O", # offense - "K": "S", "P": "S", # special teams - "OG": "L", "OL": "L", "OT": "L", # offensive line - "OC": "C", # coaching staff - } - - # create parent directory if it does not exist - if not Path(data_dir).exists(): - os.makedirs(data_dir) - - # Load the scoring based on crime categories - with open(Path(__file__).parent.parent / "resources" / "files" / "crime_categories.json", mode="r", - encoding="utf-8") as crimes: - self.crime_rankings = json.load(crimes) - logger.debug("Crime categories loaded.") - - # for outputting all unique crime categories found in the USA Today NFL arrests data - self.unique_crime_categories_for_output = {} - - # preserve raw retrieved player crime data for reference and later usage - self.raw_bad_boy_data: Dict[str, Any] = {} - self.raw_bad_boy_data_file_path: Path = Path(data_dir) / "bad_boy_raw_data.json" - - # for collecting all retrieved bad boy data - self.bad_boy_data: Dict[str, Any] = {} - self.bad_boy_data_file_path: Path = Path(data_dir) / "bad_boy_data.json" - - # load preexisting (saved) bad boy data (if it exists) if refresh=False - if not self.refresh: - self.open_bad_boy_data() - - # fetch crimes of players from the web if not running in offline mode or if refresh=True - if self.refresh or not self.offline: - if not self.bad_boy_data: - logger.debug("Retrieving bad boy data from the web.") - - usa_today_nfl_arrest_url = "https://www.usatoday.com/sports/nfl/arrests/" - res = requests.get(usa_today_nfl_arrest_url) - soup = BeautifulSoup(res.text, "html.parser") - cdata = re.search("var sitedata = (.*);", soup.find(string=re.compile("CDATA"))).group(1) - ajax_nonce = json.loads(cdata)["ajax_nonce"] - - usa_today_nfl_arrest_url = "https://databases.usatoday.com/wp-admin/admin-ajax.php" - headers = { - "Content-Type": "application/x-www-form-urlencoded" - } - - # example ajax query body - # example_body = ( - # 'action=cspFetchTable&' - # 'security=61406e4feb&' - # 'pageID=10&' - # 'sortBy=Date&' - # 'sortOrder=desc&' - # 'searches={"Last_name":"hill","Team":"SEA","First_name":"leroy"}' - # ) - arrests = [] - for team in nfl_team_abbreviations: - - page_num = 1 - body = ( - f"action=cspFetchTable" - f"&security={ajax_nonce}" - f"&pageID=10" - f"&sortBy=Date" - f"&sortOrder=desc" - f"&page={page_num}" - f"&searches={{\"Team\":\"{team}\"}}" - ) - - res_json = requests.post(usa_today_nfl_arrest_url, data=body, headers=headers).json() - - arrests_data = res_json["data"]["Result"] - - for arrest in arrests_data: - arrests.append({ - "name": f"{arrest['First_name']} {arrest['Last_name']}", - "team": ( - "FA" - if (arrest["Team"] == "Free agent" or arrest["Team"] == "Free Agent") - else arrest["Team"] - ), - "date": arrest["Date"], - "position": arrest["Position"], - "position_type": self.position_types[arrest["Position"]], - "case": arrest["Case_1"].upper(), - "crime": arrest["Category"].upper(), - "description": arrest["Description"], - "outcome": arrest["Outcome"] - }) - - total_results = res_json["data"]["totalResults"] - - # the USA Today NFL arrests database only retrieves 20 entries per request - if total_results > 20: - if (total_results % 20) > 0: - num_pages = (total_results // 20) + 1 - else: - num_pages = total_results // 20 - - for page in range(2, num_pages + 1): - page_num += 1 - body = ( - f"action=cspFetchTable" - f"&security={ajax_nonce}" - f"&pageID=10" - f"&sortBy=Date" - f"&sortOrder=desc" - f"&page={page_num}" - f"&searches={{\"Team\":\"{team}\"}}" - ) - - r = requests.post(usa_today_nfl_arrest_url, data=body, headers=headers) - resp_json = r.json() - - arrests_data = resp_json["data"]["Result"] - - for arrest in arrests_data: - arrests.append({ - "name": f"{arrest['First_name']} {arrest['Last_name']}", - "team": ( - "FA" - if (arrest["Team"] == "Free agent" or arrest["Team"] == "Free Agent") - else arrest["Team"] - ), - "date": arrest["Date"], - "position": arrest["Position"], - "position_type": self.position_types[arrest["Position"]], - "case": arrest["Case_1"].upper(), - "crime": arrest["Category"].upper(), - "description": arrest["Description"], - "outcome": arrest["Outcome"] - }) - - arrests_by_team = { - key: list(group) for key, group in itertools.groupby( - sorted(arrests, key=lambda x: x["team"]), - lambda x: x["team"] - ) - } - - for team_abbr in nfl_team_abbreviations: - self.add_entry(team_abbr, arrests_by_team.get(team_abbr)) - - self.save_bad_boy_data() - - # if offline mode, load pre-fetched bad boy data (only works if you've previously run application with -s flag) - else: - if not self.bad_boy_data: - raise FileNotFoundError( - f"FILE {self.bad_boy_data_file_path} DOES NOT EXIST. CANNOT RUN LOCALLY WITHOUT HAVING PREVIOUSLY " - f"SAVED DATA!" - ) - - if len(self.bad_boy_data) == 0: - logger.warning( - "NO bad boy records were loaded, please check your internet connection or the availability of " - "\"https://www.usatoday.com/sports/nfl/arrests/\" and try generating a new report.") - else: - logger.info(f"{len(self.bad_boy_data)} bad boy records loaded") - - def open_bad_boy_data(self): - logger.debug("Loading saved bay boy data.") - if Path(self.bad_boy_data_file_path).exists(): - with open(self.bad_boy_data_file_path, "r", encoding="utf-8") as bad_boy_in: - self.bad_boy_data = dict(json.load(bad_boy_in)) - - def save_bad_boy_data(self): - if self.save_data: - logger.debug("Saving bad boy data and raw player crime data.") - # save report bad boy data locally - with open(self.bad_boy_data_file_path, "w", encoding="utf-8") as bad_boy_out: - json.dump(self.bad_boy_data, bad_boy_out, ensure_ascii=False, indent=2) - - # save raw player crime data locally - with open(self.raw_bad_boy_data_file_path, "w", encoding="utf-8") as bad_boy_raw_out: - json.dump(self.raw_bad_boy_data, bad_boy_raw_out, ensure_ascii=False, indent=2) - - def add_entry(self, team_abbr: str, arrests: List[Dict[str, str]]): - - if arrests: - nfl_team = { - "pos": "D/ST", - "players": {}, - "total_points": 0, - "offenders": [], - "num_offenders": 0, - "worst_offense": None, - "worst_offense_points": 0 - } - - for player_arrest in arrests: - player_name = player_arrest.get("name") - player_pos = player_arrest.get("position") - player_pos_type = player_arrest.get("position_type") - offense_category = str.upper(player_arrest.get("crime")) - - # Add each crime to output categories for generation of crime_categories.new.json file, which can - # be used to replace the existing crime_categories.json file. Each new crime categories will default to - # a score of 0, and must have its score manually assigned within the json file. - self.unique_crime_categories_for_output[offense_category] = self.crime_rankings.get(offense_category, 0) - - # add raw player arrest data to raw data collection - self.raw_bad_boy_data[player_name] = player_arrest - - if offense_category in self.crime_rankings.keys(): - offense_points = self.crime_rankings.get(offense_category) - else: - offense_points = 0 - logger.warning(f"Crime ranking not found: \"{offense_category}\". Assigning score of 0.") - - nfl_player = { - "team": team_abbr, - "pos": player_pos, - "offenses": [], - "total_points": 0, - "worst_offense": None, - "worst_offense_points": 0 - } - - # update player entry - nfl_player["offenses"].append({offense_category: offense_points}) - nfl_player["total_points"] += offense_points - - if offense_points > nfl_player["worst_offense_points"]: - nfl_player["worst_offense"] = offense_category - nfl_player["worst_offense_points"] = offense_points - - self.bad_boy_data[player_name] = nfl_player - - # update team DEF entry - if player_pos_type == "D": - nfl_team["players"][player_name] = self.bad_boy_data[player_name] - nfl_team["total_points"] += offense_points - nfl_team["offenders"].append(player_name) - nfl_team["offenders"] = list(set(nfl_team["offenders"])) - nfl_team["num_offenders"] = len(nfl_team["offenders"]) - - if offense_points > nfl_team["worst_offense_points"]: - nfl_team["worst_offense"] = offense_category - nfl_team["worst_offense_points"] = offense_points - - self.bad_boy_data[team_abbr] = nfl_team - - def get_player_bad_boy_stats(self, player_first_name: str, player_last_name: str, player_team_abbr: str, - player_pos: str, key_str: str = "") -> Union[int, str, Dict[str, Any]]: - """ Looks up given player and returns number of "bad boy" points based on custom crime scoring. - - TODO: maybe limit for years and adjust defensive players rolling up to DEF team as it skews DEF scores high - :param player_first_name: First name of player to look up - :param player_last_name: Last name of player to look up - :param player_team_abbr: Player's team (maybe limit to only crimes while on that team...or for DEF players???) - :param player_pos: Player's position - :param key_str: which player information to retrieve (crime: "worst_offense" or bad boy points: "total_points") - :return: Ether integer number of bad boy points or crime recorded (depending on key_str) - """ - player_team = str.upper(player_team_abbr) if player_team_abbr else "?" - if player_team not in nfl_team_abbreviations: - if player_team in nfl_team_abbreviation_conversions.keys(): - player_team = nfl_team_abbreviation_conversions[player_team] - - player_full_name = ( - (string.capwords(player_first_name) if player_first_name else "") + - (" " if player_first_name and player_last_name else "") + - (string.capwords(player_last_name) if player_last_name else "") - ).strip() - - # TODO: figure out how to include only ACTIVE players in team DEF roll-ups - if player_pos == "D/ST": - # player_full_name = player_team - player_full_name = "TEMPORARY DISABLING OF TEAM DEFENSES IN BAD BOY POINTS" - if player_full_name in self.bad_boy_data: - return self.bad_boy_data[player_full_name][key_str] if key_str else self.bad_boy_data[player_full_name] - else: - logger.debug( - f"Player not found: {player_full_name}. Setting crime category and bad boy points to 0. Run report " - f"with the -r flag (--refresh-web-data) to refresh all external web data and try again." - ) - - self.bad_boy_data[player_full_name] = { - "team": player_team, - "pos": player_pos, - "offenses": [], - "total_points": 0, - "worst_offense": None, - "worst_offense_points": 0 - } - return self.bad_boy_data[player_full_name][key_str] if key_str else self.bad_boy_data[player_full_name] - - def get_player_bad_boy_crime(self, player_first_name: str, player_last_name: str, player_team: str, - player_pos: str) -> str: - return self.get_player_bad_boy_stats(player_first_name, player_last_name, player_team, player_pos, - "worst_offense") - - def get_player_bad_boy_points(self, player_first_name: str, player_last_name: str, player_team: str, - player_pos: str) -> int: - return self.get_player_bad_boy_stats(player_first_name, player_last_name, player_team, player_pos, - "total_points") - - def get_player_bad_boy_num_offenders(self, player_first_name: str, player_last_name: str, player_team: str, - player_pos: str) -> int: - player_bad_boy_stats = self.get_player_bad_boy_stats(player_first_name, player_last_name, player_team, - player_pos) - if player_bad_boy_stats.get("pos") == "D/ST": - return player_bad_boy_stats.get("num_offenders") - else: - return 0 - - def generate_crime_categories_json(self): - unique_crimes = OrderedDict(sorted(self.unique_crime_categories_for_output.items(), key=lambda k_v: k_v[0])) - with open(Path(__file__).parent.parent / "resources" / "files" / "crime_categories.new.json", mode="w", - encoding="utf-8") as crimes: - json.dump(unique_crimes, crimes, ensure_ascii=False, indent=2) - - def __str__(self): - return json.dumps(self.bad_boy_data, indent=2, ensure_ascii=False) - - def __repr__(self): - return json.dumps(self.bad_boy_data, indent=2, ensure_ascii=False) diff --git a/calculate/beef_stats.py b/calculate/beef_stats.py deleted file mode 100644 index 58f22c53..00000000 --- a/calculate/beef_stats.py +++ /dev/null @@ -1,196 +0,0 @@ -__author__ = "Wren J. R. (uberfastman)" -__email__ = "uberfastman@uberfastman.dev" - -import json -from collections import OrderedDict -from pathlib import Path -from typing import List, Dict, Any - -import requests - -from utilities.constants import nfl_team_abbreviations, nfl_team_abbreviation_conversions -from utilities.logger import get_logger - -logger = get_logger(__name__, propagate=False) - - -class BeefStats(object): - - def __init__(self, data_dir: Path, save_data: bool = False, offline: bool = False, refresh: bool = False): - """ - Initialize class, load data from Sleeper API, and combine defensive player data into team total - """ - logger.debug("Initializing beef stats.") - - self.save_data: bool = save_data - self.offline: bool = offline - self.refresh: bool = refresh - - self.first_name_punctuation: List[str] = [".", "'"] - self.last_name_suffixes: List[str] = ["Jr", "Jr.", "Sr", "Sr.", "I", "II", "III", "IV", "V"] - - self.nfl_player_data_url: str = "https://api.sleeper.app/v1/players/nfl" - self.tabbu_value: float = 500.0 - - self.raw_player_data: Dict[str, Dict[str, str]] = {} - self.raw_player_data_file_path: Path = Path(data_dir) / "beef_raw_data.json" - - self.beef_data: Dict[str, Dict[str, Any]] = {} - self.beef_data_file_path: Path = Path(data_dir) / "beef_data.json" - if not self.refresh: - self.open_beef_data() - - # fetch weights of players from the web if not running in offline mode or refresh=True - if self.refresh or not self.offline: - if not self.beef_data: - logger.debug("Retrieving beef data from the web.") - - nfl_player_data = requests.get(self.nfl_player_data_url).json() - for player_sleeper_key, player_data in nfl_player_data.items(): - self.add_entry(player_data) - - self.save_beef_data() - - # if offline mode, load pre-fetched weight data (only works if you've previously run application with -s flag) - else: - if not self.beef_data: - raise FileNotFoundError( - f"FILE {self.beef_data_file_path} DOES NOT EXIST. CANNOT RUN LOCALLY WITHOUT HAVING PREVIOUSLY " - f"SAVED DATA!" - ) - - if len(self.beef_data) == 0: - logger.warning( - "NO beef data was loaded, please check your internet connection or the availability of " - "\"https://api.sleeper.app/v1/players/nfl\" and try generating a new report.") - else: - logger.info(f"{len(self.beef_data)} player weights/TABBUs were loaded") - - def open_beef_data(self): - logger.debug("Loading saved beef data.") - if Path(self.beef_data_file_path).exists(): - with open(self.beef_data_file_path, "r", encoding="utf-8") as beef_in: - self.beef_data = dict(json.load(beef_in)) - - def save_beef_data(self): - if self.save_data: - logger.debug("Saving beef data.") - with open(self.beef_data_file_path, "w", encoding="utf-8") as beef_out: - json.dump(self.beef_data, beef_out, ensure_ascii=False, indent=2) - - def add_entry(self, player_json: Dict[str, Any] = None): - - player_full_name = player_json.get("full_name", "") - # excludes defences with "DEF" as beef data for defences is generated by rolling up all players on that defense - if (player_json - and player_json.get("team") is not None - and player_json.get("fantasy_positions") is not None - and "DEF" not in player_json.get("fantasy_positions")): - - # add raw player data json to raw_player_data for output and later reference - self.raw_player_data[player_full_name] = player_json - - player_beef_dict = { - "fullName": player_full_name, - "firstName": player_json.get("first_name").replace(".", ""), - "lastName": player_json.get("last_name"), - "weight": float(player_json.get("weight")) if player_json.get("weight") != "" else 0.0, - "tabbu": ( - (float(player_json.get("weight")) if player_json.get("weight") != "" else 0.0) - / float(self.tabbu_value) - ), - "position": player_json.get("position"), - "team": player_json.get("team") - } - - if player_full_name not in self.beef_data.keys(): - self.beef_data[player_full_name] = player_beef_dict - - positions = set() - position_types = player_json.get("fantasy_positions") - if position_types and not positions.intersection(("OL", "RB", "WR", "TE")) and ( - "DL" in position_types or "DB" in position_types): - - if player_beef_dict.get("team") not in self.beef_data.keys(): - self.beef_data[player_beef_dict.get("team")] = { - "weight": player_beef_dict.get("weight"), - "tabbu": player_beef_dict.get("weight") / self.tabbu_value, - "players": {player_full_name: player_beef_dict} - } - else: - weight = self.beef_data[player_beef_dict.get("team")].get("weight") + player_beef_dict.get("weight") - tabbu = self.beef_data[player_beef_dict.get("team")].get("tabbu") + ( - player_beef_dict.get("weight") / self.tabbu_value) - - team_def_entry = self.beef_data[player_beef_dict.get("team")] - team_def_entry["weight"] = weight - team_def_entry["tabbu"] = tabbu - team_def_entry["players"][player_full_name] = player_beef_dict - else: - player_beef_dict = { - "fullName": player_full_name, - "weight": 0, - "tabbu": 0, - } - - self.beef_data[player_full_name] = player_beef_dict - return player_beef_dict - - def get_player_beef_stat(self, player_first_name: str, player_last_name: str, player_team_abbr: str, - key_str: str) -> float: - - team_abbr = player_team_abbr.upper() if player_team_abbr else "?" - cleaned_player_full_name = None - if player_first_name and player_last_name: - player_full_name = f"{player_first_name} {player_last_name}" - if (any(punc in player_first_name for punc in self.first_name_punctuation) - or any(suffix in player_last_name for suffix in self.last_name_suffixes)): - - cleaned_player_first_name = player_first_name - for punc in self.first_name_punctuation: - cleaned_player_first_name = cleaned_player_first_name.replace(punc, "").strip() - - cleaned_player_last_name = player_last_name - for suffix in self.last_name_suffixes: - cleaned_player_last_name = cleaned_player_last_name.removesuffix(suffix).strip() - - cleaned_player_full_name = f"{cleaned_player_first_name} {cleaned_player_last_name}" - else: - if team_abbr not in nfl_team_abbreviations: - if team_abbr in nfl_team_abbreviation_conversions.keys(): - team_abbr = nfl_team_abbreviation_conversions[team_abbr] - player_full_name = team_abbr - - if player_full_name in self.beef_data.keys(): - return self.beef_data[player_full_name][key_str] - elif cleaned_player_full_name and cleaned_player_full_name in self.beef_data.keys(): - return self.beef_data[cleaned_player_full_name][key_str] - else: - logger.debug( - f"Player not found: {player_full_name}. Setting weight and TABBU to 0. Run report with the -r flag " - f"(--refresh-web-data) to refresh all external web data and try again." - ) - - self.beef_data[player_full_name] = { - "fullName": player_full_name, - "weight": 0, - "tabbu": 0, - } - return self.beef_data[player_full_name][key_str] - - def get_player_weight(self, player_first_name, player_last_name, team_abbr) -> int: - return int(self.get_player_beef_stat(player_first_name, player_last_name, team_abbr, "weight")) - - def get_player_tabbu(self, player_first_name, player_last_name, team_abbr) -> float: - return round(self.get_player_beef_stat(player_first_name, player_last_name, team_abbr, "tabbu"), 3) - - def generate_player_info_json(self): - ordered_player_data = OrderedDict(sorted(self.raw_player_data.items(), key=lambda k_v: k_v[0])) - with open(self.raw_player_data_file_path, mode="w", encoding="utf-8") as player_data: - json.dump(ordered_player_data, player_data, ensure_ascii=False, indent=2) - - def __str__(self): - return json.dumps(self.beef_data, indent=2, ensure_ascii=False) - - def __repr__(self): - return json.dumps(self.beef_data, indent=2, ensure_ascii=False) diff --git a/calculate/metrics.py b/calculate/metrics.py index a748e7c8..47f07dd5 100644 --- a/calculate/metrics.py +++ b/calculate/metrics.py @@ -348,12 +348,12 @@ def get_bad_boy_data(bad_boy_results: List[BaseTeam]) -> List[List[Any]]: for team in bad_boy_results: ranked_team_name = team.name ranked_team_manager = team.manager_str - ranked_bb_points = str(team.bad_boy_points) + ranked_bad_boy_points = str(team.bad_boy_points) ranked_offense = team.worst_offense ranked_count = str(team.num_offenders) bad_boy_results_data.append( - [place, ranked_team_name, ranked_team_manager, ranked_bb_points, ranked_offense, ranked_count] + [place, ranked_team_name, ranked_team_manager, ranked_bad_boy_points, ranked_offense, ranked_count] ) place += 1 @@ -375,6 +375,28 @@ def get_beef_rank_data(beef_results: List[BaseTeam]) -> List[List[Any]]: place += 1 return beef_results_data + @staticmethod + def get_high_roller_data(high_roller_results: List[BaseTeam]) -> List[List[Any]]: + logger.debug("Creating league high roller data.") + + high_roller_results_data = [] + place = 1 + team: BaseTeam + for team in high_roller_results: + ranked_team_name = team.name + ranked_team_manager = team.manager_str + ranked_total_fines = str(team.fines_total) + ranked_violation = team.worst_violation + ranked_violation_fine = str(team.worst_violation_fine) + + high_roller_results_data.append( + [place, ranked_team_name, ranked_team_manager, ranked_total_fines, ranked_violation, + ranked_violation_fine] + ) + + place += 1 + return high_roller_results_data + def get_ties_count(self, results_data: List[List[Any]], tie_type: str, break_ties: bool) -> int: if tie_type == "power_ranking": @@ -420,6 +442,15 @@ def get_ties_count(self, results_data: List[List[Any]], tie_type: str, break_tie team[4], team[5] ] + elif tie_type == "high_roller": + results_data[team_index] = [ + str(place) + ("*" if group_has_ties else ""), + team[1], + team[2], + team[3], + team[4], + team[5] + ] else: results_data[team_index] = [ str(place) + ("*" if group_has_ties else ""), @@ -441,6 +472,13 @@ def get_ties_count(self, results_data: List[List[Any]], tie_type: str, break_tie if len(group) > 1 and int(group[0][3]) > 0: num_ties += sum(range(len(group))) + if tie_type == "high_roller": + groups = [list(group) for key, group in itertools.groupby(results_data, lambda x: x[3])] + num_ties = 0 + for group in groups: + if len(group) > 1 and float(group[0][3]) > 0: + num_ties += sum(range(len(group))) + return num_ties @staticmethod diff --git a/compose.yaml b/compose.yaml index 68d14281..d95ef028 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,7 +2,7 @@ services: app: - image: ghcr.io/uberfastman/fantasy-football-metrics-weekly-report:18.1.3 + image: ghcr.io/uberfastman/fantasy-football-metrics-weekly-report:19.0.0 platform: linux/amd64 ports: - "5001:5000" diff --git a/dao/base.py b/dao/base.py index 580eacfc..bfd6dbc8 100644 --- a/dao/base.py +++ b/dao/base.py @@ -6,11 +6,12 @@ import json from collections import defaultdict from pathlib import Path -from typing import Set, Union, List, Dict, Any, Callable +from typing import Set, Union, List, Dict, Any, Callable, Optional -from calculate.bad_boy_stats import BadBoyStats -from calculate.beef_stats import BeefStats from calculate.playoff_probabilities import PlayoffProbabilities +from features.bad_boy import BadBoyFeature +from features.beef import BeefFeature +from features.high_roller import HighRollerFeature # noinspection GrazieInspection @@ -85,11 +86,12 @@ def to_json(self): class BaseLeague(FantasyFootballReportObject): - def __init__(self, data_dir: Path, league_id: str, season: int, week_for_report: int, + def __init__(self, root_dir: Path, data_dir: Path, league_id: str, season: int, week_for_report: int, save_data: bool = True, offline: bool = False): super().__init__() # attributes set during instantiation + self.root_dir: Path = root_dir self.data_dir: Path = data_dir self.league_id: str = league_id self.season: int = season @@ -98,7 +100,7 @@ def __init__(self, data_dir: Path, league_id: str, season: int, week_for_report: self.offline: bool = offline # attributes mapped directly from platform API data - self.name: Union[str, None] = None + self.name: Optional[str] = None self.week: int = 0 self.start_week: int = 1 self.num_teams: int = 0 @@ -112,7 +114,7 @@ def __init__(self, data_dir: Path, league_id: str, season: int, week_for_report: self.has_waiver_priorities: bool = False self.is_faab: bool = False self.faab_budget: int = 0 - self.url: Union[str, None] = None + self.url: Optional[str] = None # attributes calculated externally from platform API data self.roster_positions: List[str] = [] @@ -139,8 +141,8 @@ def __init__(self, data_dir: Path, league_id: str, season: int, week_for_report: self.median_standings: List[BaseTeam] = [] self.current_median_standings: List[BaseTeam] = [] - self.player_data_by_week_function: Union[Callable, None] = None - self.player_data_by_week_key: Union[str, None] = None + self.player_data_by_week_function: Optional[Callable] = None + self.player_data_by_week_key: Optional[str] = None def get_player_data_by_week(self, player_id: str, week: int = None) -> Any: return getattr(self.player_data_by_week_function(player_id, week), self.player_data_by_week_key) @@ -242,20 +244,31 @@ def get_playoff_probs(self, save_data: bool = False, playoff_prob_sims: int = No offline=offline ) - def get_bad_boy_stats(self, save_data: bool = False, offline: bool = False, refresh: bool = False) -> BadBoyStats: - return BadBoyStats( - Path(self.data_dir) / str(self.season) / self.league_id, + def get_bad_boy_stats(self, refresh: bool = False, save_data: bool = False, offline: bool = False) -> BadBoyFeature: + return BadBoyFeature( + self.data_dir / str(self.season) / self.league_id, + self.root_dir, + refresh=refresh, save_data=save_data, - offline=offline, - refresh=refresh + offline=offline ) - def get_beef_stats(self, save_data: bool = False, offline: bool = False, refresh: bool = False) -> BeefStats: - return BeefStats( - Path(self.data_dir) / str(self.season) / self.league_id, + def get_beef_stats(self, refresh: bool = False, save_data: bool = False, offline: bool = False) -> BeefFeature: + return BeefFeature( + self.data_dir / str(self.season) / self.league_id, + refresh=refresh, save_data=save_data, - offline=offline, - refresh=refresh + offline=offline + ) + + def get_high_roller_stats(self, refresh: bool = False, save_data: bool = False, + offline: bool = False) -> HighRollerFeature: + return HighRollerFeature( + self.data_dir / str(self.season) / self.league_id, + self.season, + refresh=refresh, + save_data=save_data, + offline=offline ) @@ -287,31 +300,43 @@ def __init__(self): super().__init__() self.week: int = 0 - self.name: Union[str, None] = None + self.name: Optional[str] = None self.num_moves: int = 0 self.num_trades: int = 0 self.managers: List[BaseManager] = [] - self.team_id: Union[str, None] = None - self.division: Union[str, None] = None + self.team_id: Optional[str] = None + self.division: Optional[str] = None self.points: float = 0 self.projected_points: float = 0 self.home_field_advantage_points: float = 0 self.waiver_priority: int = 0 self.faab: int = 0 - self.url: Union[str, None] = None + self.url: Optional[str] = None self.roster: List[BasePlayer] = [] + # - - - - - - - - - - - - # custom report attributes - self.manager_str: Union[str, None] = None + # v v v v v v v v v v v v + + self.manager_str: Optional[str] = None self.bench_points: float = 0 - self.streak_str: Union[str, None] = None - self.division_streak_str: Union[str, None] = None + self.streak_str: Optional[str] = None + self.division_streak_str: Optional[str] = None + self.bad_boy_points: int = 0 - self.worst_offense: Union[str, None] = None - self.num_offenders: int = 0 + self.worst_offense: Optional[str] = None self.worst_offense_score: int = 0 + self.num_offenders: int = 0 + self.total_weight: float = 0.0 - self.tabbu: float = 0 + self.tabbu: float = 0.0 + + self.fines_count: int = 0 + self.fines_total: float = 0.0 + self.worst_violation: Optional[str] = None + self.worst_violation_fine: float = 0.0 + self.num_violators: int = 0 + self.positions_filled_active: List[str] = [] self.coaching_efficiency: Union[float, str] = 0.0 self.luck: float = 0 @@ -578,11 +603,11 @@ class BaseManager(FantasyFootballReportObject): def __init__(self): super().__init__() - self.manager_id: Union[str, None] = None - self.email: Union[str, None] = None - self.name: Union[str, None] = None - self.name_str: Union[str, None] = None - self.nickname: Union[str, None] = None + self.manager_id: Optional[str] = None + self.email: Optional[str] = None + self.name: Optional[str] = None + self.name_str: Optional[str] = None + self.nickname: Optional[str] = None def __setattr__(self, key: str, value: Any): if key == "name": @@ -605,38 +630,48 @@ def __init__(self): super().__init__() self.week_for_report: int = 0 - self.player_id: Union[str, None] = None + self.player_id: Optional[str] = None self.bye_week: int = 0 - self.display_position: Union[str, None] = None - self.nfl_team_id: Union[str, None] = None - self.nfl_team_abbr: Union[str, None] = None - self.nfl_team_name: Union[str, None] = None - self.first_name: Union[str, None] = None - self.last_name: Union[str, None] = None - self.full_name: Union[str, None] = None - self.headshot_url: Union[str, None] = None - self.owner_team_id: Union[str, None] = None - self.owner_team_name: Union[str, None] = None + self.display_position: Optional[str] = None + self.nfl_team_id: Optional[str] = None + self.nfl_team_abbr: Optional[str] = None + self.nfl_team_name: Optional[str] = None + self.first_name: Optional[str] = None + self.last_name: Optional[str] = None + self.full_name: Optional[str] = None + self.headshot_url: Optional[str] = None + self.owner_team_id: Optional[str] = None + self.owner_team_name: Optional[str] = None self.percent_owned: float = 0.0 self.points: float = 0.0 self.projected_points: float = 0.0 self.season_points: float = 0.0 self.season_projected_points: float = 0.0 self.season_average_points: float = 0.0 - self.position_type: Union[str, None] = None - self.primary_position: Union[str, None] = None - self.selected_position: Union[str, None] = None + self.position_type: Optional[str] = None + self.primary_position: Optional[str] = None + self.selected_position: Optional[str] = None self.selected_position_is_flex: bool = False - self.status: Union[str, None] = None + self.status: Optional[str] = None self.eligible_positions: Set[str] = set() self.stats: List[BaseStat] = [] + # - - - - - - - - - - - - # custom report attributes - self.bad_boy_crime: Union[str, None] = None + # v v v v v v v v v v v v + + self.bad_boy_crime: Optional[str] = None self.bad_boy_points: int = 0 self.bad_boy_num_offenders: int = 0 - self.weight: int = 0 - self.tabbu: float = 0.0 + + self.beef_weight: int = 0 + self.beef_tabbu: float = 0.0 + + self.high_roller_fines_count: int = 0 + self.high_roller_fines_total: float = 0.0 + self.high_roller_worst_violation: Optional[str] = None + self.high_roller_worst_violation_fine: float = 0.0 + self.high_roller_num_violators: int = 0 class BaseStat(FantasyFootballReportObject): @@ -644,7 +679,7 @@ class BaseStat(FantasyFootballReportObject): def __init__(self): super().__init__() - self.stat_id: Union[str, None] = None - self.name: Union[str, None] = None - self.abbreviation: Union[str, None] = None + self.stat_id: Optional[str] = None + self.name: Optional[str] = None + self.abbreviation: Optional[str] = None self.value: float = 0.0 diff --git a/dao/platforms/base/base.py b/dao/platforms/base/league.py similarity index 98% rename from dao/platforms/base/base.py rename to dao/platforms/base/league.py index 2db0fdfa..677c500f 100644 --- a/dao/platforms/base/base.py +++ b/dao/platforms/base/league.py @@ -54,7 +54,7 @@ def __init__(self, week_for_report = week_validation_function(week_for_report, self.current_week, season) logger.debug(f"Initializing {self.platform_display} league.") - self.league: BaseLeague = BaseLeague(data_dir, league_id, season, week_for_report, save_data, offline) + self.league: BaseLeague = BaseLeague(base_dir, data_dir, league_id, season, week_for_report, save_data, offline) # create full directory path if any directories in it do not already exist if not Path(self.league.data_dir).exists(): diff --git a/dao/platforms/cbs.py b/dao/platforms/cbs.py index 4f654ba2..240d8766 100644 --- a/dao/platforms/cbs.py +++ b/dao/platforms/cbs.py @@ -9,7 +9,7 @@ from colorama import Fore, Style from dao.base import BaseLeague, BaseMatchup, BaseTeam, BaseManager, BaseRecord, BasePlayer, BaseStat -from dao.platforms.base.base import BaseLeagueData +from dao.platforms.base.league import BaseLeagueData from utilities.logger import get_logger from utilities.settings import settings @@ -610,7 +610,7 @@ def map_data_to_base(self) -> BaseLeague: cbs_platform = LeagueData( root_directory, - Path(__file__).parent.parent.parent / 'data', + Path(__file__).parent.parent.parent / "output" / "data", settings.league_id, settings.season, 2, diff --git a/dao/platforms/espn.py b/dao/platforms/espn.py index 90a92a9f..4b7b7e9b 100644 --- a/dao/platforms/espn.py +++ b/dao/platforms/espn.py @@ -28,7 +28,7 @@ from selenium.webdriver.support.ui import WebDriverWait from dao.base import BaseMatchup, BaseTeam, BaseRecord, BaseManager, BasePlayer, BaseStat -from dao.platforms.base.base import BaseLeagueData +from dao.platforms.base.league import BaseLeagueData from utilities.logger import get_logger from utilities.settings import settings diff --git a/dao/platforms/fleaflicker.py b/dao/platforms/fleaflicker.py index be0f4811..bfa1ea79 100644 --- a/dao/platforms/fleaflicker.py +++ b/dao/platforms/fleaflicker.py @@ -15,7 +15,7 @@ from bs4 import BeautifulSoup from dao.base import BaseMatchup, BaseTeam, BaseRecord, BaseManager, BasePlayer, BaseStat -from dao.platforms.base.base import BaseLeagueData +from dao.platforms.base.league import BaseLeagueData from utilities.logger import get_logger from utilities.settings import settings diff --git a/dao/platforms/sleeper.py b/dao/platforms/sleeper.py index d9b612a0..6844a142 100644 --- a/dao/platforms/sleeper.py +++ b/dao/platforms/sleeper.py @@ -13,7 +13,7 @@ from typing import Union, Callable from dao.base import BaseMatchup, BaseTeam, BaseRecord, BaseManager, BasePlayer, BaseStat -from dao.platforms.base.base import BaseLeagueData +from dao.platforms.base.league import BaseLeagueData from utilities.logger import get_logger from utilities.settings import settings diff --git a/dao/platforms/yahoo.py b/dao/platforms/yahoo.py index 3f291cd0..a8567de0 100644 --- a/dao/platforms/yahoo.py +++ b/dao/platforms/yahoo.py @@ -13,7 +13,7 @@ from yfpy.query import YahooFantasySportsQuery from dao.base import BaseMatchup, BaseTeam, BaseRecord, BaseManager, BasePlayer, BaseStat -from dao.platforms.base.base import BaseLeagueData +from dao.platforms.base.league import BaseLeagueData from utilities.logger import get_logger from utilities.settings import settings diff --git a/features/bad_boy.py b/features/bad_boy.py new file mode 100644 index 00000000..5cdac1b3 --- /dev/null +++ b/features/bad_boy.py @@ -0,0 +1,307 @@ +__author__ = "Wren J. R. (uberfastman)" +__email__ = "uberfastman@uberfastman.dev" + +import itertools +import json +import re +from collections import OrderedDict +from pathlib import Path +from string import capwords +from typing import Dict, Any, Union, Optional + +import requests +from bs4 import BeautifulSoup + +from features.base.feature import BaseFeature +from utilities.constants import nfl_team_abbreviations, nfl_team_abbreviation_conversions +from utilities.logger import get_logger + +logger = get_logger(__name__, propagate=False) + + +class BadBoyFeature(BaseFeature): + + def __init__(self, data_dir: Path, root_dir: Path, refresh: bool = False, save_data: bool = False, + offline: bool = False): + """Initialize class, load data from USA Today NFL Arrest DB. Combine defensive player data + """ + # position type reference + self.position_types: Dict[str, str] = { + "C": "D", "CB": "D", "DB": "D", "DE": "D", "DE/DT": "D", "DT": "D", "LB": "D", "S": "D", "Safety": "D", + # defense + "FB": "O", "QB": "O", "RB": "O", "TE": "O", "WR": "O", # offense + "K": "S", "P": "S", # special teams + "OG": "L", "OL": "L", "OT": "L", # offensive line + "OC": "C", # coaching staff + } + + self.resource_files_dir = root_dir / "resources" / "files" + + # Load the scoring based on crime categories + with open(self.resource_files_dir / "crime_categories.json", mode="r", + encoding="utf-8") as crimes: + self.crime_rankings = json.load(crimes) + logger.debug("Crime categories loaded.") + + # for outputting all unique crime categories found in the USA Today NFL arrests data + self.unique_crime_categories_for_output = {} + + super().__init__( + "bad_boy", + "https://www.usatoday.com/sports/nfl/arrests", + data_dir, + refresh, + save_data, + offline + ) + + def _get_feature_data(self) -> None: + logger.debug("Retrieving bad boy feature data from the web.") + + res = requests.get(self.feature_web_base_url) + soup = BeautifulSoup(res.text, "html.parser") + cdata = re.search("var sitedata = (.*);", soup.find(string=re.compile("CDATA"))).group(1) + ajax_nonce = json.loads(cdata)["ajax_nonce"] + + usa_today_nfl_arrest_url = "https://databases.usatoday.com/wp-admin/admin-ajax.php" + headers = { + "Content-Type": "application/x-www-form-urlencoded" + } + + """ + Example ajax query body: + + example_body = ( + 'action=cspFetchTable&' + 'security=61406e4feb&' + 'pageID=10&' + 'sortBy=Date&' + 'sortOrder=desc&' + 'searches={"Last_name":"hill","Team":"SEA","First_name":"leroy"}' + ) + """ + arrests = [] + for team in nfl_team_abbreviations: + + page_num = 1 + body = ( + f"action=cspFetchTable" + f"&security={ajax_nonce}" + f"&pageID=10" + f"&sortBy=Date" + f"&sortOrder=desc" + f"&page={page_num}" + f"&searches={{\"Team\":\"{team}\"}}" + ) + + res_json = requests.post(usa_today_nfl_arrest_url, data=body, headers=headers).json() + + arrests_data = res_json["data"]["Result"] + + for arrest in arrests_data: + arrests.append({ + "name": f"{arrest['First_name']} {arrest['Last_name']}", + "team": ( + "FA" + if (arrest["Team"] == "Free agent" or arrest["Team"] == "Free Agent") + else arrest["Team"] + ), + "date": arrest["Date"], + "position": arrest["Position"], + "position_type": self.position_types[arrest["Position"]], + "case": arrest["Case_1"].upper(), + "crime": arrest["Category"].upper(), + "description": arrest["Description"], + "outcome": arrest["Outcome"] + }) + + total_results = res_json["data"]["totalResults"] + + # the USA Today NFL arrests database only retrieves 20 entries per request + if total_results > 20: + if (total_results % 20) > 0: + num_pages = (total_results // 20) + 1 + else: + num_pages = total_results // 20 + + for page in range(2, num_pages + 1): + page_num += 1 + body = ( + f"action=cspFetchTable" + f"&security={ajax_nonce}" + f"&pageID=10" + f"&sortBy=Date" + f"&sortOrder=desc" + f"&page={page_num}" + f"&searches={{\"Team\":\"{team}\"}}" + ) + + r = requests.post(usa_today_nfl_arrest_url, data=body, headers=headers) + resp_json = r.json() + + arrests_data = resp_json["data"]["Result"] + + for arrest in arrests_data: + arrests.append({ + "name": f"{arrest['First_name']} {arrest['Last_name']}", + "team": ( + "FA" + if (arrest["Team"] == "Free agent" or arrest["Team"] == "Free Agent") + else arrest["Team"] + ), + "date": arrest["Date"], + "position": arrest["Position"], + "position_type": self.position_types[arrest["Position"]], + "case": arrest["Case_1"].upper(), + "crime": arrest["Category"].upper(), + "description": arrest["Description"], + "outcome": arrest["Outcome"] + }) + + arrests_by_team = { + key: list(group) for key, group in itertools.groupby( + sorted(arrests, key=lambda x: x["team"]), + lambda x: x["team"] + ) + } + + for team_abbr in nfl_team_abbreviations: + + if team_arrests := arrests_by_team.get(team_abbr): + nfl_team: Dict = { + "pos": "D/ST", + "players": {}, + "total_points": 0, + "offenders": [], + "num_offenders": 0, + "worst_offense": None, + "worst_offense_points": 0 + } + + for player_arrest in team_arrests: + player_name = player_arrest.get("name") + player_pos = player_arrest.get("position") + player_pos_type = player_arrest.get("position_type") + offense_category = str.upper(player_arrest.get("crime")) + + # Add each crime to output categories for generation of crime_categories.new.json file, which can + # be used to replace the existing crime_categories.json file. Each new crime categories will default + # to a score of 0, and must have its score manually assigned within the json file. + self.unique_crime_categories_for_output[offense_category] = self.crime_rankings.get( + offense_category, 0 + ) + + # add raw player arrest data to raw data collection + self.raw_feature_data[player_name] = player_arrest + + if offense_category in self.crime_rankings.keys(): + offense_points = self.crime_rankings.get(offense_category) + else: + offense_points = 0 + logger.warning(f"Crime ranking not found: \"{offense_category}\". Assigning score of 0.") + + nfl_player = { + "team": team_abbr, + "pos": player_pos, + "offenses": [], + "total_points": 0, + "worst_offense": None, + "worst_offense_points": 0 + } + + # update player entry + nfl_player["offenses"].append({offense_category: offense_points}) + nfl_player["total_points"] += offense_points + + if offense_points > nfl_player["worst_offense_points"]: + nfl_player["worst_offense"] = offense_category + nfl_player["worst_offense_points"] = offense_points + + self.feature_data[player_name] = nfl_player + + # update team DEF entry + if player_pos_type == "D": + nfl_team["players"][player_name] = self.feature_data[player_name] + nfl_team["total_points"] += offense_points + nfl_team["offenders"].append(player_name) + nfl_team["offenders"] = list(set(nfl_team["offenders"])) + nfl_team["num_offenders"] = len(nfl_team["offenders"]) + + if offense_points > nfl_team["worst_offense_points"]: + nfl_team["worst_offense"] = offense_category + nfl_team["worst_offense_points"] = offense_points + + self.feature_data[team_abbr] = nfl_team + + def _get_player_bad_boy_stats(self, player_first_name: str, player_last_name: str, player_team_abbr: str, + player_pos: str, key_str: Optional[str] = None) -> Union[int, str, Dict[str, Any]]: + """Looks up given player and returns number of "bad boy" points based on custom crime scoring. + + TODO: maybe limit for years and adjust defensive players rolling up to DEF team as it skews DEF scores high + :param player_first_name: First name of player to look up + :param player_last_name: Last name of player to look up + :param player_team_abbr: Player's team (maybe limit to only crimes while on that team...or for DEF players???) + :param player_pos: Player's position + :param key_str: which player information to retrieve (crime: "worst_offense" or bad boy points: "total_points") + :return: Ether integer number of bad boy points or crime recorded (depending on key_str) + """ + player_team = str.upper(player_team_abbr) if player_team_abbr else "?" + if player_team not in nfl_team_abbreviations: + if player_team in nfl_team_abbreviation_conversions.keys(): + player_team = nfl_team_abbreviation_conversions[player_team] + + player_full_name = ( + f"{capwords(player_first_name) if player_first_name else ''}" + f"{' ' if player_first_name and player_last_name else ''}" + f"{capwords(player_last_name) if player_last_name else ''}" + ).strip() + + # TODO: figure out how to include only ACTIVE players in team DEF roll-ups + if player_pos == "D/ST": + # player_full_name = player_team + player_full_name = "TEMPORARY DISABLING OF TEAM DEFENSES IN BAD BOY POINTS" + if player_full_name in self.feature_data: + return self.feature_data[player_full_name][key_str] if key_str else self.feature_data[player_full_name] + else: + logger.debug( + f"Player not found: {player_full_name}. Setting crime category and bad boy points to 0. Run report " + f"with the -r flag (--refresh-web-data) to refresh all external web data and try again." + ) + + self.feature_data[player_full_name] = { + "team": player_team, + "pos": player_pos, + "offenses": [], + "total_points": 0, + "worst_offense": None, + "worst_offense_points": 0 + } + return self.feature_data[player_full_name][key_str] if key_str else self.feature_data[player_full_name] + + def get_player_bad_boy_crime(self, player_first_name: str, player_last_name: str, player_team: str, + player_pos: str) -> str: + return self._get_player_bad_boy_stats( + player_first_name, player_last_name, player_team, player_pos, "worst_offense" + ) + + def get_player_bad_boy_points(self, player_first_name: str, player_last_name: str, player_team: str, + player_pos: str) -> int: + return self._get_player_bad_boy_stats( + player_first_name, player_last_name, player_team, player_pos, "total_points" + ) + + def get_player_bad_boy_num_offenders(self, player_first_name: str, player_last_name: str, player_team: str, + player_pos: str) -> int: + player_bad_boy_stats = self._get_player_bad_boy_stats( + player_first_name, player_last_name, player_team, player_pos + ) + if player_bad_boy_stats.get("pos") == "D/ST": + return player_bad_boy_stats.get("num_offenders") + else: + return 0 + + def generate_crime_categories_json(self): + unique_crimes = OrderedDict(sorted(self.unique_crime_categories_for_output.items(), key=lambda k_v: k_v[0])) + with open(self.resource_files_dir / "crime_categories.new.json", mode="w", + encoding="utf-8") as crimes: + json.dump(unique_crimes, crimes, ensure_ascii=False, indent=2) diff --git a/features/base/feature.py b/features/base/feature.py new file mode 100644 index 00000000..51077f0d --- /dev/null +++ b/features/base/feature.py @@ -0,0 +1,116 @@ +__author__ = "Wren J. R. (uberfastman)" +__email__ = "uberfastman@uberfastman.dev" + +import json +import os +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Dict, List, Any + +from utilities.logger import get_logger + +logger = get_logger(__name__, propagate=False) + + +class BaseFeature(ABC): + + def __init__(self, feature_type: str, feature_web_base_url: str, data_dir: Path, refresh: bool = False, + save_data: bool = False, offline: bool = False): + """Base Feature class for retrieving data from the web, saving, and loading it. + """ + self.feature_type_str: str = feature_type.replace(" ", "_").lower() + self.feature_type_title: str = feature_type.replace("_", " ").capitalize() + + logger.debug(f"Initializing {self.feature_type_title} feature.") + + self.feature_web_base_url = feature_web_base_url + + self.data_dir: Path = data_dir + + self.refresh: bool = refresh + self.save_data: bool = save_data + self.offline: bool = offline + + self.raw_feature_data: Dict[str, Any] = {} + self.feature_data: Dict[str, Any] = {} + + self.raw_feature_data_file_path: Path = self.data_dir / f"{self.feature_type_str}_raw_data.json" + self.feature_data_file_path: Path = self.data_dir / f"{self.feature_type_str}_data.json" + + self.player_name_punctuation: List[str] = [".", "'"] + self.player_name_suffixes: List[str] = ["Jr", "Sr", "V", "IV", "III", "II", "I"] # ordered for str.removesuffix + + # fetch feature data from the web if not running in offline mode or if refresh=True + if not self.offline and self.refresh: + if not self.feature_data: + logger.debug(f"Retrieving {self.feature_type_title} data from the web.") + + self._get_feature_data() + + if self.save_data: + self._save_feature_data() + # if offline=True or refresh=False load saved feature data (must have previously run application with -s flag) + else: + self._load_feature_data() + + if len(self.feature_data) == 0: + logger.warning( + f"No {self.feature_type_title} data records were loaded, please check your internet connection or the " + f"availability of {self.feature_web_base_url} and try generating a new report." + ) + else: + logger.info(f"{len(self.feature_data)} feature data records loaded") + + def __str__(self): + return json.dumps(self.feature_data, indent=2, ensure_ascii=False) + + def __repr__(self): + return json.dumps(self.feature_data, indent=2, ensure_ascii=False) + + def _load_feature_data(self) -> None: + logger.debug(f"Loading saved {self.feature_type_title} data...") + + if self.feature_data_file_path.is_file(): + with open(self.feature_data_file_path, "r", encoding="utf-8") as feature_data_in: + self.feature_data = dict(json.load(feature_data_in)) + else: + raise FileNotFoundError( + f"FILE {self.feature_data_file_path} DOES NOT EXIST. CANNOT RUN LOCALLY WITHOUT HAVING PREVIOUSLY " + f"SAVED DATA!" + ) + + def _save_feature_data(self) -> None: + logger.debug(f"Saving {self.feature_type_title} data and raw {self.feature_type_title} data.") + + # create output data directory if it does not exist + if not self.data_dir.is_dir(): + os.makedirs(self.data_dir, exist_ok=True) + + # save feature data locally + if self.feature_data: + with open(self.feature_data_file_path, "w", encoding="utf-8") as feature_data_out: + json.dump(self.feature_data, feature_data_out, ensure_ascii=False, indent=2) + + # save raw feature data locally + if self.raw_feature_data: + with open(self.raw_feature_data_file_path, "w", encoding="utf-8") as feature_raw_data_out: + json.dump(self.raw_feature_data, feature_raw_data_out, ensure_ascii=False, indent=2) + + def _normalize_player_name(self, player_name: str) -> str: + """Remove all punctuation and name suffixes from player names and covert them to title case. + """ + normalized_player_name: str = player_name.strip() + if (any(punc in player_name for punc in self.player_name_punctuation) + or any(suffix in player_name for suffix in self.player_name_suffixes)): + + for punc in self.player_name_punctuation: + normalized_player_name = normalized_player_name.replace(punc, "") + + for suffix in self.player_name_suffixes: + normalized_player_name = normalized_player_name.removesuffix(suffix) + + return normalized_player_name.strip().title() + + @abstractmethod + def _get_feature_data(self) -> None: + raise NotImplementedError diff --git a/features/beef.py b/features/beef.py new file mode 100644 index 00000000..5fa81e7d --- /dev/null +++ b/features/beef.py @@ -0,0 +1,152 @@ +__author__ = "Wren J. R. (uberfastman)" +__email__ = "uberfastman@uberfastman.dev" + +import json +from collections import OrderedDict +from pathlib import Path +from typing import List + +import requests + +from features.base.feature import BaseFeature +from utilities.constants import nfl_team_abbreviations, nfl_team_abbreviation_conversions +from utilities.logger import get_logger + +logger = get_logger(__name__, propagate=False) + + +class BeefFeature(BaseFeature): + + def __init__(self, data_dir: Path, refresh: bool = False, save_data: bool = False, offline: bool = False): + """Initialize class, load data from Sleeper API, and combine defensive player data into team total + """ + self.first_name_punctuation: List[str] = [".", "'"] + self.last_name_suffixes: List[str] = ["Jr", "Jr.", "Sr", "Sr.", "I", "II", "III", "IV", "V"] + + self.tabbu_value: float = 500.0 + + super().__init__( + "beef", + "https://api.sleeper.app/v1/players/nfl", + data_dir, + refresh, + save_data, + offline + ) + + def _get_feature_data(self): + logger.debug("Retrieving beef feature data from the web.") + + nfl_player_data = requests.get(self.feature_web_base_url).json() + for player_sleeper_key, player_data_json in nfl_player_data.items(): + + player_full_name = player_data_json.get("full_name", "") + # excludes defences with "DEF" as beef data for defences is generated by rolling up all players on that defense + if (player_data_json + and player_data_json.get("team") is not None + and player_data_json.get("fantasy_positions") is not None + and "DEF" not in player_data_json.get("fantasy_positions")): + + # add raw player data json to raw_player_data for output and later reference + self.raw_feature_data[player_full_name] = player_data_json + + player_beef_dict = { + "fullName": player_full_name, + "firstName": player_data_json.get("first_name").replace(".", ""), + "lastName": player_data_json.get("last_name"), + "weight": float(player_data_json.get("weight")) if player_data_json.get("weight") != "" else 0.0, + "tabbu": ( + (float(player_data_json.get("weight")) if player_data_json.get("weight") != "" else 0.0) + / float(self.tabbu_value) + ), + "position": player_data_json.get("position"), + "team": player_data_json.get("team") + } + + if player_full_name not in self.feature_data.keys(): + self.feature_data[player_full_name] = player_beef_dict + + positions = set() + position_types = player_data_json.get("fantasy_positions") + if position_types and not positions.intersection(("OL", "RB", "WR", "TE")) and ( + "DL" in position_types or "DB" in position_types): + + if player_beef_dict.get("team") not in self.feature_data.keys(): + self.feature_data[player_beef_dict.get("team")] = { + "weight": player_beef_dict.get("weight"), + "tabbu": player_beef_dict.get("weight") / self.tabbu_value, + "players": {player_full_name: player_beef_dict} + } + else: + weight = ( + self.feature_data[player_beef_dict.get("team")].get("weight") + + player_beef_dict.get("weight") + ) + tabbu = self.feature_data[player_beef_dict.get("team")].get("tabbu") + ( + player_beef_dict.get("weight") / self.tabbu_value) + + team_def_entry = self.feature_data[player_beef_dict.get("team")] + team_def_entry["weight"] = weight + team_def_entry["tabbu"] = tabbu + team_def_entry["players"][player_full_name] = player_beef_dict + else: + player_beef_dict = { + "fullName": player_full_name, + "weight": 0, + "tabbu": 0, + } + + self.feature_data[player_full_name] = player_beef_dict + + def _get_player_beef_stats(self, player_first_name: str, player_last_name: str, player_team_abbr: str, + key_str: str) -> float: + + team_abbr = player_team_abbr.upper() if player_team_abbr else "?" + cleaned_player_full_name = None + if player_first_name and player_last_name: + player_full_name = f"{player_first_name} {player_last_name}" + if (any(punc in player_first_name for punc in self.first_name_punctuation) + or any(suffix in player_last_name for suffix in self.last_name_suffixes)): + + cleaned_player_first_name = player_first_name + for punc in self.first_name_punctuation: + cleaned_player_first_name = cleaned_player_first_name.replace(punc, "").strip() + + cleaned_player_last_name = player_last_name + for suffix in self.last_name_suffixes: + cleaned_player_last_name = cleaned_player_last_name.removesuffix(suffix).strip() + + cleaned_player_full_name = f"{cleaned_player_first_name} {cleaned_player_last_name}" + else: + if team_abbr not in nfl_team_abbreviations: + if team_abbr in nfl_team_abbreviation_conversions.keys(): + team_abbr = nfl_team_abbreviation_conversions[team_abbr] + player_full_name = team_abbr + + if player_full_name in self.feature_data.keys(): + return self.feature_data[player_full_name][key_str] + elif cleaned_player_full_name and cleaned_player_full_name in self.feature_data.keys(): + return self.feature_data[cleaned_player_full_name][key_str] + else: + logger.debug( + f"Player not found: {player_full_name}. Setting weight and TABBU to 0. Run report with the -r flag " + f"(--refresh-web-data) to refresh all external web data and try again." + ) + + self.feature_data[player_full_name] = { + "fullName": player_full_name, + "weight": 0, + "tabbu": 0, + } + return self.feature_data[player_full_name][key_str] + + def get_player_weight(self, player_first_name, player_last_name, team_abbr) -> int: + return int(self._get_player_beef_stats(player_first_name, player_last_name, team_abbr, "weight")) + + def get_player_tabbu(self, player_first_name, player_last_name, team_abbr) -> float: + return round(self._get_player_beef_stats(player_first_name, player_last_name, team_abbr, "tabbu"), 3) + + def generate_player_info_json(self): + ordered_player_data = OrderedDict(sorted(self.raw_feature_data.items(), key=lambda k_v: k_v[0])) + with open(self.raw_feature_data_file_path, mode="w", encoding="utf-8") as player_data: + json.dump(ordered_player_data, player_data, ensure_ascii=False, indent=2) diff --git a/features/high_roller.py b/features/high_roller.py new file mode 100644 index 00000000..97e4ba75 --- /dev/null +++ b/features/high_roller.py @@ -0,0 +1,206 @@ +__author__ = "Wren J. R. (uberfastman)" +__email__ = "uberfastman@uberfastman.dev" + +from datetime import datetime +from pathlib import Path +from typing import Dict, Union, Type + +import requests +from bs4 import BeautifulSoup + +from features.base.feature import BaseFeature +from utilities.constants import nfl_team_abbreviations, nfl_team_abbreviation_conversions +from utilities.logger import get_logger + +logger = get_logger(__name__, propagate=False) + + +class HighRollerFeature(BaseFeature): + + def __init__(self, data_dir: Path, season: int, refresh: bool = False, save_data: bool = False, + offline: bool = False): + """Initialize class, load data from Spotrac.com. + """ + self.season: int = season + + # position type reference + self.position_types: Dict[str, str] = { + "CB": "D", "DE": "D", "DT": "D", "FS": "D", "ILB": "D", "LB": "D", "OLB": "D", "S": "D", "SS": "D", + # defense + "FB": "O", "QB": "O", "RB": "O", "TE": "O", "WR": "O", # offense + "K": "S", "P": "S", # special teams + "C": "L", "G": "L", "LS": "L", "LT": "L", "RT": "L", # offensive line + "D/ST": "D" # team defense + } + + super().__init__( + "high_roller", + f"https://www.spotrac.com/nfl/fines/_/year/{self.season}", + data_dir, + refresh, + save_data, + offline + ) + + def _get_feature_data(self): + + for team in nfl_team_abbreviations: + self.feature_data[team] = { + "position": "D/ST", + "players": {}, + "violators": [], + "num_violators": 0, + "fines_count": 0, + "fines_total": 0.0, + "worst_violation": None, + "worst_violation_fine": 0.0 + } + + user_agent = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) " + "AppleWebKit/605.1.15 (KHTML, like Gecko) " + "Version/13.0.2 Safari/605.1.15" + ) + headers = { + "user-agent": user_agent + } + + response = requests.get(self.feature_web_base_url, headers=headers) + + html_soup = BeautifulSoup(response.text, "html.parser") + logger.debug(f"Response URL: {response.url}") + logger.debug(f"Response (HTML): {html_soup}") + + fined_players = html_soup.find("tbody").findAll("tr", {"class": ""}) + + for player in fined_players: + + player_name = player.find("a", {"class": "link"}).getText().strip() + player_team = player.find("img", {"class": "me-2"}).getText().strip() + if not player_team: + # attempt to retrieve team from parent element if img element is missing closing tag + player_team = player.find("td", {"class": "text-left details"}).getText().strip() + + # TODO: move this cleaning to base feature.py + # replace player team abbreviation with universal team abbreviation as needed + if player_team not in nfl_team_abbreviations: + player_team = nfl_team_abbreviation_conversions[player_team] + player_position = player.find("td", {"class": "text-left details-sm"}).getText().strip() + + player_fine_info = { + "violation": player.find("span", {"class": "text-muted"}).getText()[2:].strip(), + "violation_fine": int("".join([ + ch for ch in player.find("td", {"class": "text-center details highlight"}).getText().strip() + if ch.isdigit() + ])), + "violation_season": self.season, + "violation_date": datetime.strptime( + player.find("td", {"class": "text-right details"}).getText().strip(), "%m/%d/%y" + ).isoformat() + } + + if player_name not in self.feature_data.keys(): + self.feature_data[player_name] = { + "normalized_name": self._normalize_player_name(player_name), + "team": player_team, + "position": player_position, + "position_type": self.position_types[player_position], + "fines": [player_fine_info], + "fines_count": 1, + "fines_total": player_fine_info["violation_fine"], + "worst_violation": player_fine_info["violation"], + "worst_violation_fine": player_fine_info["violation_fine"] + } + else: + self.feature_data[player_name]["fines"].append(player_fine_info) + self.feature_data[player_name]["fines"].sort( + key=lambda x: (-x["violation_fine"], -datetime.fromisoformat(x["violation_date"]).timestamp()) + ) + self.feature_data[player_name]["fines_count"] += 1 + self.feature_data[player_name]["fines_total"] += player_fine_info["violation_fine"] + + worst_violation = self.feature_data[player_name]["fines"][0] + self.feature_data[player_name]["worst_violation"] = worst_violation["violation"] + self.feature_data[player_name]["worst_violation_fine"] = worst_violation["violation_fine"] + + for player_name in self.feature_data.keys(): + + if self.feature_data[player_name]["position"] != "D/ST": + player_team = self.feature_data[player_name]["team"] + + if player_name not in self.feature_data[player_team]["players"]: + player = self.feature_data[player_name] + self.feature_data[player_team]["players"][player_name] = player + self.feature_data[player_team]["violators"].append(player_name) + self.feature_data[player_team]["violators"] = list(set(self.feature_data[player_team]["violators"])) + self.feature_data[player_team]["num_violators"] = len(self.feature_data[player_team]["violators"]) + self.feature_data[player_team]["fines_count"] += player["fines_count"] + self.feature_data[player_team]["fines_total"] += player["fines_total"] + if player["worst_violation_fine"] >= self.feature_data[player_team]["worst_violation_fine"]: + self.feature_data[player_team]["worst_violation"] = player["worst_violation"] + self.feature_data[player_team]["worst_violation_fine"] = player["worst_violation_fine"] + + def _get_player_high_roller_stats(self, player_first_name: str, player_last_name: str, player_team_abbr: str, + player_pos: str, key_str: str, key_type: Type) -> Union[str, float, int]: + + player_full_name = ( + f"{player_first_name.title() if player_first_name else ''}" + f"{' ' if player_first_name and player_last_name else ''}" + f"{player_last_name.title() if player_last_name else ''}" + ).strip() + + if player_full_name in self.feature_data.keys(): + return self.feature_data[player_full_name].get(key_str, key_type()) + else: + logger.debug( + f"No {self.feature_type_title} data found for player \"{player_full_name}\". " + f"Run report with the -r flag (--refresh-web-data) to refresh all external web data and try again." + ) + + player = { + "position": player_pos, + "fines_count": 0, + "fines_total": 0.0, + "worst_violation": None, + "worst_violation_fine": 0.0 + } + if player_pos == "D/ST": + player.update({ + "players": {}, + "violators": [], + "num_violators": 0, + }) + else: + player.update({ + "normalized_name": self._normalize_player_name(player_full_name), + "team": player_team_abbr, + "fines": [], + }) + + self.feature_data[player_full_name] = player + + return self.feature_data[player_full_name][key_str] + + def get_player_worst_violation(self, player_first_name: str, player_last_name: str, player_team: str, + player_pos: str) -> str: + return self._get_player_high_roller_stats( + player_first_name, player_last_name, player_team, player_pos, "worst_violation", str + ) + + def get_player_worst_violation_fine(self, player_first_name: str, player_last_name: str, player_team: str, + player_pos: str) -> float: + return self._get_player_high_roller_stats( + player_first_name, player_last_name, player_team, player_pos, "worst_violation_fine", float + ) + + def get_player_fines_total(self, player_first_name: str, player_last_name: str, player_team: str, + player_pos: str) -> float: + return self._get_player_high_roller_stats( + player_first_name, player_last_name, player_team, player_pos, "fines_total", float + ) + + def get_player_num_violators(self, player_first_name: str, player_last_name: str, player_team: str, + player_pos: str) -> int: + return self._get_player_high_roller_stats( + player_first_name, player_last_name, player_team, player_pos, "num_violators", int + ) diff --git a/main.py b/main.py index 20979936..5f8209f9 100644 --- a/main.py +++ b/main.py @@ -16,7 +16,7 @@ from integrations.drive_integration import GoogleDriveUploader from integrations.slack_integration import SlackUploader from report.builder import FantasyFootballReport -from utilities.app import check_for_updates +from utilities.app import check_github_for_updates from utilities.logger import get_logger from utilities.settings import settings @@ -284,8 +284,9 @@ def select_week(use_default: bool = False) -> Union[int, None]: options = main(sys.argv[1:]) logger.debug(f"Fantasy football metrics weekly report app settings options:\n{options}") - # check to see if the current app is behind any commits, and provide option to update and re-run if behind - up_to_date = check_for_updates(options.get("use_default", False)) + if settings.check_for_updates: + # check to see if the current app is behind any commits, and provide option to update and re-run if behind + up_to_date = check_github_for_updates(options.get("use_default", False)) report = select_league( options.get("use_default", False), diff --git a/report/builder.py b/report/builder.py index 83e0d4da..fdfadc0d 100644 --- a/report/builder.py +++ b/report/builder.py @@ -132,7 +132,7 @@ def __init__(self, f"Retrieving bad boy data from https://www.usatoday.com/sports/nfl/arrests/ " f"{'website' if not self.offline or self.refresh_web_data else 'saved data'}..." ) - self.bad_boy_stats = self.league.get_bad_boy_stats(self.save_data, self.offline, self.refresh_web_data) + self.bad_boy_stats = self.league.get_bad_boy_stats(self.refresh_web_data, self.save_data, self.offline) delta = datetime.datetime.now() - begin logger.info( f"...retrieved all bad boy data from https://www.usatoday.com/sports/nfl/arrests/ " @@ -147,7 +147,7 @@ def __init__(self, f"Retrieving beef data from Sleeper " f"{'API' if not self.offline or self.refresh_web_data else 'saved data'}..." ) - self.beef_stats = self.league.get_beef_stats(self.save_data, self.offline, self.refresh_web_data) + self.beef_stats = self.league.get_beef_stats(self.refresh_web_data, self.save_data, self.offline) delta = datetime.datetime.now() - begin logger.info( f"...retrieved all beef data from Sleeper " @@ -156,6 +156,23 @@ def __init__(self, else: self.beef_stats = None + if settings.report_settings.league_high_roller_rankings_bool: + begin = datetime.datetime.now() + logger.info( + f"Retrieving high roller data from https://www.spotrac.com/nfl/fines " + f"{'website' if not self.offline or self.refresh_web_data else 'saved data'}..." + ) + self.high_roller_stats = self.league.get_high_roller_stats( + self.refresh_web_data, self.save_data, self.offline + ) + delta = datetime.datetime.now() - begin + logger.info( + f"...retrieved all high roller data from https://www.spotrac.com/nfl/fines " + f"{'website' if not self.offline else 'saved data'} in {delta}\n" + ) + else: + self.high_roller_stats = None + # output league info for verification logger.info( f"...setup complete for " @@ -213,7 +230,8 @@ def create_pdf_report(self) -> Path: ), "playoff_probs": self.playoff_probs, "bad_boy_stats": self.bad_boy_stats, - "beef_stats": self.beef_stats + "beef_stats": self.beef_stats, + "high_roller_stats": self.high_roller_stats }, break_ties=self.break_ties, dq_ce=self.dq_ce, diff --git a/report/data.py b/report/data.py index 5958cd5c..bf99cc17 100644 --- a/report/data.py +++ b/report/data.py @@ -199,19 +199,28 @@ def __init__(self, league: BaseLeague, season_weekly_teams_results, week_counter # luck data self.data_for_luck = metrics_calculator.get_luck_data( - sorted(self.teams_results.values(), key=lambda x: float(x.luck), reverse=True)) + sorted(self.teams_results.values(), key=lambda x: float(x.luck), reverse=True) + ) # optimal score data self.data_for_optimal_scores = metrics_calculator.get_optimal_score_data( - sorted(self.teams_results.values(), key=lambda x: float(x.optimal_points), reverse=True)) + sorted(self.teams_results.values(), key=lambda x: float(x.optimal_points), reverse=True) + ) # bad boy data self.data_for_bad_boy_rankings = metrics_calculator.get_bad_boy_data( - sorted(self.teams_results.values(), key=lambda x: x.bad_boy_points, reverse=True)) + sorted(self.teams_results.values(), key=lambda x: x.bad_boy_points, reverse=True) + ) # beef rank data self.data_for_beef_rankings = metrics_calculator.get_beef_rank_data( - sorted(self.teams_results.values(), key=lambda x: x.tabbu, reverse=True)) + sorted(self.teams_results.values(), key=lambda x: x.tabbu, reverse=True) + ) + + # high roller data + self.data_for_high_roller_rankings = metrics_calculator.get_high_roller_data( + sorted(self.teams_results.values(), key=lambda x: x.fines_total, reverse=True) + ) # ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ # ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ COUNT METRIC TIES ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ @@ -249,18 +258,33 @@ def __init__(self, league: BaseLeague, season_weekly_teams_results, week_counter [list(group) for key, group in itertools.groupby(self.data_for_luck, lambda x: x[3])][0]) # get number of bad boy rankings ties and ties for first - self.ties_for_bad_boy_rankings = metrics_calculator.get_ties_count(self.data_for_bad_boy_rankings, "bad_boy", - self.break_ties) + self.ties_for_bad_boy_rankings = metrics_calculator.get_ties_count( + self.data_for_bad_boy_rankings, "bad_boy", self.break_ties + ) self.num_first_place_for_bad_boy_rankings = len( - [list(group) for key, group in itertools.groupby(self.data_for_bad_boy_rankings, lambda x: x[3])][0]) + [list(group) for key, group in itertools.groupby(self.data_for_bad_boy_rankings, lambda x: x[3])][0] + ) # filter out teams that have no bad boys in their starting lineup - self.data_for_bad_boy_rankings = [result for result in self.data_for_bad_boy_rankings if int(result[5]) != 0] + self.data_for_bad_boy_rankings = [result for result in self.data_for_bad_boy_rankings if int(result[-1]) != 0] # get number of beef rankings ties and ties for first - self.ties_for_beef_rankings = metrics_calculator.get_ties_count(self.data_for_beef_rankings, "beef", - self.break_ties) + self.ties_for_beef_rankings = metrics_calculator.get_ties_count( + self.data_for_beef_rankings, "beef", self.break_ties + ) self.num_first_place_for_beef_rankings = len( - [list(group) for key, group in itertools.groupby(self.data_for_beef_rankings, lambda x: x[3])][0]) + [list(group) for key, group in itertools.groupby(self.data_for_beef_rankings, lambda x: x[3])][0] + ) + + # get number of high roller rankings ties and ties for first + self.ties_for_high_roller_rankings = metrics_calculator.get_ties_count( + self.data_for_high_roller_rankings, "high_roller", self.break_ties + ) + self.num_first_place_for_high_roller_rankings = len( + [list(group) for key, group in itertools.groupby(self.data_for_high_roller_rankings, lambda x: x[3])][0]) + # filter out teams that have no high rollers in their starting lineup + self.data_for_high_roller_rankings = [ + result for result in self.data_for_high_roller_rankings if float(result[3]) != 0.0 + ] # ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ # ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ CALCULATE POWER RANKING ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ diff --git a/report/pdf/generator.py b/report/pdf/generator.py index fdb91e01..a9729622 100644 --- a/report/pdf/generator.py +++ b/report/pdf/generator.py @@ -9,7 +9,7 @@ from copy import deepcopy from pathlib import Path from random import choice -from typing import List, Dict, Tuple, Callable, Any, Union +from typing import List, Dict, Tuple, Callable, Any, Union, Optional from urllib.error import URLError from PIL import Image @@ -143,6 +143,7 @@ def __init__(self, season: int, league: BaseLeague, playoff_prob_sims: int, self.season = season self.league_id = league.league_id self.playoff_slots = int(league.num_playoff_slots) + self.has_divisions = league.has_divisions self.num_regular_season_weeks = int(league.num_regular_season_weeks) self.week_for_report = league.week_for_report self.data_dir = Path(league.data_dir) / str(league.season) / league.league_id @@ -172,6 +173,10 @@ def __init__(self, season: int, league: BaseLeague, playoff_prob_sims: int, self.widths_06_cols_no_3 = [0.45 * inch, 1.95 * inch, 1.85 * inch, 0.60 * inch, 1.45 * inch, 1.45 * inch] # 7.75 + # ..........................Place........Team.........Manager......Col 4........Col 5........Col 6..... + self.widths_06_cols_no_4 = [0.45 * inch, 1.75 * inch, 1.50 * inch, 1.00 * inch, 2.25 * inch, + 0.80 * inch] # 7.75 + # ..........................Place........Team.........Manager......Col 4........Col 5........Col 6........Col 7..... self.widths_07_cols_no_1 = [0.45 * inch, 1.80 * inch, 1.50 * inch, 0.75 * inch, 1.50 * inch, 0.75 * inch, 1.00 * inch] # 7.75 @@ -433,6 +438,9 @@ def __init__(self, season: int, league: BaseLeague, playoff_prob_sims: int, self.optimal_scores_headers = [["Place", "Team", "Manager", "Optimal Points", "Season Total"]] self.bad_boy_headers = [["Place", "Team", "Manager", "Bad Boy Pts", "Worst Offense", "# Offenders"]] self.beef_headers = [["Place", "Team", "Manager", "TABBU(s)"]] + self.high_roller_headers = [[ + "Place", "Team", "Manager", "Fines Total ($)", "Worst Violation", "Fine ($)" + ]] self.weekly_top_scorer_headers = [["Week", "Team", "Manager", "Score"]] self.weekly_low_scorer_headers = [["Week", "Team", "Manager", "Score"]] self.weekly_highest_ce_headers = [["Week", "Team", "Manager", "Coaching Efficiency (%)"]] @@ -506,6 +514,7 @@ def __init__(self, season: int, league: BaseLeague, playoff_prob_sims: int, self.data_for_z_scores = report_data.data_for_z_scores self.data_for_bad_boy_rankings = report_data.data_for_bad_boy_rankings self.data_for_beef_rankings = report_data.data_for_beef_rankings + self.data_for_high_roller_rankings = report_data.data_for_high_roller_rankings self.data_for_weekly_points_by_position = report_data.data_for_weekly_points_by_position self.data_for_season_average_team_points_by_position = report_data.data_for_season_avg_points_by_position self.data_for_season_weekly_top_scorers = report_data.data_for_season_weekly_top_scorers @@ -525,11 +534,14 @@ def __init__(self, season: int, league: BaseLeague, playoff_prob_sims: int, self.report_data.ties_for_power_rankings, table_style_list, "power_ranking" ) self.style_tied_bad_boy = self.set_tied_values_style( - self.report_data.ties_for_power_rankings, table_style_list, "bad_boy" + self.report_data.ties_for_bad_boy_rankings, table_style_list, "bad_boy" ) self.style_tied_beef = self.set_tied_values_style( self.report_data.ties_for_beef_rankings, style_left_alight_right_col_list, "beef" ) + self.style_tied_high_roller = self.set_tied_values_style( + self.report_data.ties_for_high_roller_rankings, table_style_list, "high_roller" + ) # table of contents self.toc = TableOfContents(self.font, self.font_size, self.break_ties) @@ -590,6 +602,11 @@ def set_tied_values_style(self, num_ties: int, table_style_list: List[Tuple[Any] num_first_places = 0 else: num_first_places = self.report_data.num_first_place_for_beef_rankings + elif metric_type == "high_roller": + if not self.report_data.num_first_place_for_high_roller_rankings > 0: + num_first_places = 0 + else: + num_first_places = self.report_data.num_first_place_for_high_roller_rankings tied_values_table_style_list = list(table_style_list) if metric_type == "scores" and self.break_ties: @@ -598,7 +615,7 @@ def set_tied_values_style(self, num_ties: int, table_style_list: List[Tuple[Any] else: iterator = num_first_places index = 1 - if metric_type == "bad_boy": + if metric_type == "bad_boy" or metric_type == "high_roller": color = colors.darkred else: color = colors.green @@ -630,13 +647,16 @@ def create_section(self, title_text: str, headers: List[List[str]], data: Any, table_style: TableStyle, table_style_ties: Union[TableStyle, None], col_widths: List[float], subtitle_text: Union[str, List[str]] = None, subsubtitle_text: Union[str, List[str]] = None, header_text: str = None, footer_text: str = None, row_heights: List[List[float]] = None, - tied_metric: bool = False, metric_type: str = None, - section_title_function: Callable = None) -> KeepTogether: + tied_metric: bool = False, metric_type: str = None, section_title_function: Callable = None, + sesqui_max_chars_col_ndxs: Optional[List[int]] = None) -> KeepTogether: logger.debug( f"Creating report section: \"{title_text if title_text else metric_type}\" with " f"data:\n{json.dumps(data, indent=2)}\n" ) + if not sesqui_max_chars_col_ndxs: + sesqui_max_chars_col_ndxs = [] + title = None if title_text: section_anchor = str(self.toc.get_current_anchor()) @@ -684,8 +704,14 @@ def create_section(self, title_text: str, headers: List[List[str]], data: Any, if metric_type == "playoffs": font_reduction = 0 - for x in range(1, (len(data[0][5:]) % 6) + 2): - font_reduction += 1 + # reduce playoff probabilities font size for every playoff slot over 6 + if self.playoff_slots > 6: + for x in range(1, (self.playoff_slots % 6) + 2): + font_reduction += 1 + # reduce playoff probabilities font size if league has divisions since it adds a division record column + if self.has_divisions: + font_reduction += 2 + table_style.add("FONTSIZE", (0, 0), (-1, -1), (self.font_size - 2) - font_reduction) if metric_type == "scores": @@ -783,8 +809,36 @@ def create_section(self, title_text: str, headers: List[List[str]], data: Any, tabbu_column_table.setStyle(TableStyle(tabbu_column_table_style_list)) team[-1] = tabbu_column_table + if metric_type == "high_roller": + font_reduction = 0 + for x in range(1, (len(data[0][5:]) % 6) + 2): + font_reduction += 1 + table_style.add("FONTSIZE", (0, 0), (-1, -1), (self.font_size - 2) - font_reduction) + + temp_data = [] + row: List[Any] + for row in data: + entry = [ + row[0], + row[1], + row[2], + f"${float(row[3]):,.0f}", + row[4], + f"${float(row[5]):,.0f}" + ] + temp_data.append(entry) + data = temp_data + data_table = self.create_data_table( - metric_type, headers, data, table_style, table_style_ties, col_widths, row_heights, tied_metric + metric_type, + headers, + data, + table_style, + table_style_ties, + col_widths, + row_heights, + tied_metric, + sesqui_max_chars_col_ndxs=sesqui_max_chars_col_ndxs ) if metric_type == "coaching_efficiency": @@ -889,7 +943,10 @@ def create_anchored_title(self, title_text: str, title_width: float = 8.5, eleme def create_data_table(self, metric_type: str, col_headers: List[List[str]], data: Any, table_style: TableStyle = None, table_style_for_ties: TableStyle = None, col_widths: List[float] = None, row_heights: List[List[float]] = None, - tied_metric: bool = False) -> Table: + tied_metric: bool = False, sesqui_max_chars_col_ndxs: Optional[List[int]] = False) -> Table: + + if not sesqui_max_chars_col_ndxs: + sesqui_max_chars_col_ndxs = [] table_data = deepcopy(col_headers) @@ -904,11 +961,15 @@ def create_data_table(self, metric_type: str, col_headers: List[List[str]], data display_row = [] for cell_ndx, cell in enumerate(row): if isinstance(cell, str): - # truncate data cell contents to specified max characters and half of specified max characters if - # cell is a team manager header - display_row.append( - truncate_cell_for_display(cell, halve_max_chars=(cell_ndx == manager_header_ndx)) - ) + if cell_ndx not in sesqui_max_chars_col_ndxs: + # truncate data cell contents to specified max characters and half of specified max characters if + # cell is a team manager header + display_row.append( + truncate_cell_for_display(cell, halve_max_chars=(cell_ndx == manager_header_ndx)) + ) + else: + display_row.append(truncate_cell_for_display(cell, sesqui_max_chars=True)) + else: display_row.append(cell) table_data.append(display_row) @@ -1083,13 +1144,38 @@ def create_team_stats_pages(self, doc_elements: List[Flowable], weekly_team_data team_result: BaseTeam = self.teams_results[team_id] player_info = team_result.roster - if (settings.report_settings.team_points_by_position_charts_bool - or settings.report_settings.team_bad_boy_stats_bool + has_team_graphics_page = ( + settings.report_settings.team_points_by_position_charts_bool + or settings.report_settings.team_boom_or_bust_bool + ) + + has_team_tables_page = ( + settings.report_settings.team_bad_boy_stats_bool or settings.report_settings.team_beef_stats_bool - or settings.report_settings.team_boom_or_bust_bool): - title = self.create_title("" + team_result.name + "", element_type="section", - anchor="") - self.toc.add_team_section(team_result.name) + or settings.report_settings.team_high_roller_stats_bool + ) + + if has_team_graphics_page and not has_team_tables_page: + team_graphics_page_title = team_result.name + team_tables_page_title = None + elif not has_team_graphics_page and not has_team_tables_page: + team_graphics_page_title = None + team_tables_page_title = team_result.name + else: + team_graphics_page_title = f"{team_result.name} (Part 1)" + team_tables_page_title = f"{team_result.name} (Part 2)" + + if has_team_graphics_page: + title = self.create_title( + "" + team_graphics_page_title + "", + element_type="section", + anchor="" + ) + + if has_team_tables_page: + self.toc.add_team_section(team_result.name, team_page=1) + else: + self.toc.add_team_section(team_result.name) doc_elements.append(title) @@ -1116,65 +1202,6 @@ def create_team_stats_pages(self, doc_elements: List[Flowable], weekly_team_data doc_elements.append(KeepTogether(team_table)) doc_elements.append(self.spacer_quarter_inch) - if (settings.report_settings.league_bad_boy_rankings_bool - and settings.report_settings.team_bad_boy_stats_bool): - - if player_info: - offending_players = [] - for player in player_info: - if player.bad_boy_points > 0: - offending_players.append(player) - - offending_players = sorted(offending_players, key=lambda x: x.bad_boy_points, reverse=True) - offending_players_data = [] - for player in offending_players: - offending_players_data.append([player.full_name, player.bad_boy_points, player.bad_boy_crime]) - # if there are no offending players, skip table - if offending_players_data: - doc_elements.append(self.create_title("Whodunnit?", 8.5, "section")) - doc_elements.append(self.spacer_tenth_inch) - bad_boys_table = self.create_data_table( - "bad_boy", - [["Starting Player", "Bad Boy Points", "Worst Offense"]], - offending_players_data, - self.style_red_highlight, - self.style_tied_bad_boy, - [2.50 * inch, 2.50 * inch, 2.75 * inch] - ) - doc_elements.append(KeepTogether(bad_boys_table)) - doc_elements.append(self.spacer_tenth_inch) - - if (settings.report_settings.league_beef_rankings_bool - and settings.report_settings.team_beef_stats_bool): - - if player_info: - doc_elements.append(self.create_title("Beefiest Bois", 8.5, "section")) - doc_elements.append(self.spacer_tenth_inch) - beefy_players = sorted( - [player for player in player_info if player.primary_position != "D/ST"], - key=lambda x: x.tabbu, reverse=True - ) - beefy_players_data = [] - num_beefy_bois = 3 - ndx = 0 - count = 0 - while count < num_beefy_bois: - player: BasePlayer = beefy_players[ndx] - if player.last_name: - beefy_players_data.append([player.full_name, f"{player.tabbu:.3f}", player.weight]) - count += 1 - ndx += 1 - beefy_boi_table = self.create_data_table( - "beef", - [["Starting Player", "TABBU(s)", "Weight (lbs.)"]], - beefy_players_data, - self.style_red_highlight, - self.style_tied_bad_boy, - [2.50 * inch, 2.50 * inch, 2.75 * inch] - ) - doc_elements.append(KeepTogether(beefy_boi_table)) - doc_elements.append(self.spacer_tenth_inch) - if settings.report_settings.team_boom_or_bust_bool: if player_info: starting_players = [] @@ -1294,11 +1321,121 @@ def create_team_stats_pages(self, doc_elements: List[Flowable], weekly_team_data doc_elements.append(self.spacer_tenth_inch) doc_elements.append(KeepTogether(table)) - if (settings.report_settings.team_points_by_position_charts_bool - or settings.report_settings.team_bad_boy_stats_bool - or settings.report_settings.team_beef_stats_bool - or settings.report_settings.team_boom_or_bust_bool): - doc_elements.append(self.add_page_break()) + if has_team_graphics_page: + doc_elements.append(self.add_page_break()) + + if has_team_tables_page: + title = self.create_title( + "" + team_tables_page_title + "", + element_type="section", + anchor="" + ) + + if has_team_graphics_page: + self.toc.add_team_section(team_result.name, team_page=2) + else: + self.toc.add_team_section(team_result.name) + + doc_elements.append(title) + + if (settings.report_settings.league_bad_boy_rankings_bool + and settings.report_settings.team_bad_boy_stats_bool): + + if player_info: + offending_players = [] + for player in player_info: + if player.bad_boy_points > 0: + offending_players.append(player) + + offending_players = sorted(offending_players, key=lambda x: x.bad_boy_points, reverse=True) + offending_players_data = [] + for player in offending_players: + offending_players_data.append([player.full_name, player.bad_boy_points, player.bad_boy_crime]) + # if there are no offending players, skip table + if offending_players_data: + doc_elements.append(self.create_title("Whodunnit?", 8.5, "section")) + doc_elements.append(self.spacer_tenth_inch) + bad_boys_table = self.create_data_table( + "bad_boy", + [["Starting Player", "Bad Boy Points", "Worst Offense"]], + offending_players_data, + self.style_red_highlight, + self.style_tied_bad_boy, + [2.50 * inch, 2.50 * inch, 2.75 * inch] + ) + doc_elements.append(KeepTogether(bad_boys_table)) + doc_elements.append(self.spacer_tenth_inch) + + if (settings.report_settings.league_beef_rankings_bool + and settings.report_settings.team_beef_stats_bool): + + if player_info: + doc_elements.append(self.create_title("Beefiest Bois", 8.5, "section")) + doc_elements.append(self.spacer_tenth_inch) + beefy_players = sorted( + [player for player in player_info if player.primary_position != "D/ST"], + key=lambda x: x.beef_tabbu, reverse=True + ) + beefy_players_data = [] + num_beefy_bois = 3 + ndx = 0 + count = 0 + while count < num_beefy_bois: + player: BasePlayer = beefy_players[ndx] + if player.last_name: + beefy_players_data.append( + [player.full_name, f"{player.beef_tabbu:.3f}", player.beef_weight]) + count += 1 + ndx += 1 + beefy_boi_table = self.create_data_table( + "beef", + [["Starting Player", "TABBU(s)", "Weight (lbs.)"]], + beefy_players_data, + self.style_red_highlight, + self.style_tied_bad_boy, + [2.50 * inch, 2.50 * inch, 2.75 * inch] + ) + doc_elements.append(KeepTogether(beefy_boi_table)) + doc_elements.append(self.spacer_tenth_inch) + + if (settings.report_settings.league_high_roller_rankings_bool + and settings.report_settings.team_high_roller_stats_bool): + + if player_info: + violating_players = [] + for player in player_info: + if player.high_roller_fines_total > 0: + violating_players.append(player) + + violating_players = sorted(violating_players, key=lambda x: x.high_roller_fines_total, reverse=True) + violating_players_data = [] + for player in violating_players: + violating_players_data.append([ + player.full_name, + f"${player.high_roller_fines_total:,.0f}", + player.high_roller_worst_violation, + f"${player.high_roller_worst_violation_fine:,.0f}" + ]) + # if there are no violating players, skip table + if violating_players_data: + doc_elements.append( + self.create_title("Paid the Piper", 8.5, "section") + ) + doc_elements.append(self.spacer_tenth_inch) + high_rollers_table = self.create_data_table( + "high_roller", + [["Starting Player", "Fines Total ($)", "Worst Violation", "Fine ($)"]], + violating_players_data, + self.style_red_highlight, + self.style_tied_high_roller, + [2.50 * inch, 1.25 * inch, 2.75 * inch, 1.25 * inch], + sesqui_max_chars_col_ndxs=[2] # increased allowed max chars of "Worst Violation" column + ) + doc_elements.append(KeepTogether(high_rollers_table)) + doc_elements.append(self.spacer_tenth_inch) + + if has_team_tables_page: + doc_elements.append(self.add_page_break()) def generate_pdf(self, filename_with_path: Path, line_chart_data_list: List[List[Any]]) -> Path: logger.debug("Generating report PDF.") @@ -1398,8 +1535,8 @@ def generate_pdf(self, filename_with_path: Path, line_chart_data_list: List[List self.playoff_probs_headers[0].insert(3, "Division") playoff_probs_style.add("FONTSIZE", (0, 0), (-1, -1), self.font_size - 4) self.widths_n_cols_no_1 = ( - [1.35 * inch, 0.90 * inch, 0.75 * inch, 0.75 * inch, 0.50 * inch, 0.50 * inch] + - [round(3.4 / self.playoff_slots, 2) * inch] * self.playoff_slots + [1.35 * inch, 0.90 * inch, 0.75 * inch, 0.75 * inch, 0.50 * inch, 0.50 * inch] + + [round(3.4 / self.playoff_slots, 2) * inch] * self.playoff_slots ) data_for_playoff_probs = self.report_data.data_for_playoff_probs @@ -1434,6 +1571,28 @@ def generate_pdf(self, filename_with_path: Path, line_chart_data_list: List[List else settings.num_playoff_simulations ) + if self.report_data.has_divisions: + + subtitle_text_for_divisions = ( + "\nProbabilities account for division winners in addition to overall win/loss/tie record." + ) + + if settings.num_playoff_slots_per_division > 1: + footer_text_for_divisions_with_extra_qualifiers = ( + "

" + "           " + "‡ Predicted Division Qualifiers" + ) + else: + footer_text_for_divisions_with_extra_qualifiers = "" + + footer_text_for_divisions = ( + f"† Predicted Division Leaders{footer_text_for_divisions_with_extra_qualifiers}" + ) + else: + subtitle_text_for_divisions = "" + footer_text_for_divisions = None + elements.append(self.create_section( "Playoff Probabilities", self.playoff_probs_headers, @@ -1442,24 +1601,12 @@ def generate_pdf(self, filename_with_path: Path, line_chart_data_list: List[List playoff_probs_style, self.widths_n_cols_no_1, subtitle_text=( - f"Playoff probabilities were calculated using {num_playoff_simulations:,} Monte Carlo " - f"simulations to predict team performances through the end of the regular fantasy season." - + ( - "\nProbabilities account for division winners in addition to overall win/loss/tie record." - if self.report_data.has_divisions else "") + f"Playoff probabilities were calculated using {num_playoff_simulations:,} Monte Carlo " + f"simulations to predict team performances through the end of the regular fantasy season." + f"{subtitle_text_for_divisions}" ), metric_type="playoffs", - footer_text=( - f"""† Predicted Division Leaders - { - "

           " - "‡ Predicted Division Qualifiers" - if settings.num_playoff_slots_per_division > 1 - else "" - } - """ - if self.report_data.has_divisions else None - ) + footer_text=footer_text_for_divisions )) if settings.report_settings.league_standings_bool or settings.report_settings.league_playoff_probs_bool: @@ -1619,7 +1766,24 @@ def generate_pdf(self, filename_with_path: Path, line_chart_data_list: List[List ] )) - if settings.report_settings.league_bad_boy_rankings_bool or settings.report_settings.league_beef_rankings_bool: + if settings.report_settings.league_high_roller_rankings_bool: + # high roller rankings + elements.append(self.create_section( + "High Roller Rankings", + self.high_roller_headers, + self.data_for_high_roller_rankings, + self.style, + self.style_tied_high_roller, + self.widths_06_cols_no_4, + tied_metric=self.report_data.ties_for_high_roller_rankings > 0, + metric_type="high_roller", + sesqui_max_chars_col_ndxs=[4] # increased allowed max chars of "Worst Violation" column + )) + elements.append(self.spacer_twentieth_inch) + + if (settings.report_settings.league_bad_boy_rankings_bool + or settings.report_settings.league_beef_rankings_bool + or settings.reportsettings.league_high_roller_rankings_bool): elements.append(self.add_page_break()) if settings.report_settings.league_weekly_top_scorers_bool: @@ -1728,10 +1892,13 @@ def generate_pdf(self, filename_with_path: Path, line_chart_data_list: List[List elements.append(self.add_page_break()) if (settings.report_settings.report_team_stats_bool - and settings.report_settings.team_points_by_position_charts_bool - and settings.report_settings.team_bad_boy_stats_bool - and settings.report_settings.team_beef_stats_bool - and settings.report_settings.team_boom_or_bust_bool): + and ( + settings.report_settings.team_points_by_position_charts_bool + or settings.report_settings.team_boom_or_bust_bool + or settings.report_settings.team_bad_boy_stats_bool + or settings.report_settings.team_beef_stats_bool + or settings.report_settings.team_high_roller_stats_bool + )): # dynamically build additional pages for individual team stats self.create_team_stats_pages( elements, self.data_for_weekly_points_by_position, self.data_for_season_average_team_points_by_position @@ -1760,20 +1927,46 @@ class TableOfContents(object): def __init__(self, font, font_size, break_ties): - self.break_ties = break_ties + self.toc_col_widths = [3.25 * inch, 2 * inch, 2.50 * inch] + + self.toc_line_height = 0.25 * inch + self.toc_section_spacer_row_height = 0.05 * inch - self.toc_style_right = ParagraphStyle(name="tocr", alignment=TA_RIGHT, fontSize=font_size - 2, fontName=font) - self.toc_style_center = ParagraphStyle(name="tocc", alignment=TA_CENTER, fontSize=font_size - 2, fontName=font) - self.toc_style_left = ParagraphStyle(name="tocl", alignment=TA_LEFT, fontSize=font_size - 2, fontName=font) - self.toc_style_title_right = ParagraphStyle(name="tocr", alignment=TA_RIGHT, fontSize=font_size, - fontName=font) - self.toc_style_title_left = ParagraphStyle(name="tocl", alignment=TA_LEFT, fontSize=font_size, - fontName=font) + self.toc_title_font_size = font_size + self.toc_font_size = font_size - 2 + # map scaled down TOC font size to number of ". " repetitions that should be inserted into center column + self.toc_dot_leaders_ref_dict = { + 4: 61, 5: 49, 6: 41, 7: 35, 8: 30, 9: 27, 10: 24 + } + self.toc_dot_leaders_scalar = self.toc_dot_leaders_ref_dict[self.toc_font_size] + + # style for page name titles + self.toc_style_title_left = ParagraphStyle( + name="tocl", alignment=TA_LEFT, fontSize=self.toc_title_font_size, fontName=font + ) + # style for page names column + self.toc_style_left = ParagraphStyle( + name="tocl", alignment=TA_LEFT, fontSize=self.toc_font_size, fontName=font + ) + + # style for dot leaders + self.toc_style_center = ParagraphStyle( + name="tocc", alignment=TA_CENTER, fontSize=self.toc_font_size, fontName=font + ) + + # style for page number titles + self.toc_style_title_right = ParagraphStyle( + name="tocr", alignment=TA_RIGHT, fontSize=self.toc_title_font_size, fontName=font + ) + # style for page numbers + self.toc_style_right = ParagraphStyle( + name="tocr", alignment=TA_RIGHT, fontSize=self.toc_font_size, fontName=font + ) self.toc_anchor = 0 - # start on page 1 since table of contents is on first page - self.toc_page = 1 + # start on page 2 since table of contents is on first two pages + self.toc_page = 2 self.toc_metric_section_data = None self.toc_top_performers_section_data = None @@ -1781,6 +1974,8 @@ def __init__(self, font, font_size, break_ties): self.toc_team_section_data = None self.toc_appendix_data = None + self.break_ties = break_ties + if (settings.report_settings.league_standings_bool or settings.report_settings.league_playoff_probs_bool or settings.report_settings.league_median_standings_bool @@ -1832,19 +2027,19 @@ def __init__(self, font, font_size, break_ties): Paragraph("Page", self.toc_style_title_left)] ] - def add_toc_page(self, pages_to_add=1): + def add_toc_page(self, pages_to_add: int = 1) -> None: self.toc_page += pages_to_add - def format_toc_section(self, title, color="blue"): + def format_toc_section(self, title: str, color: str = "blue") -> List[Paragraph]: return [ Paragraph( "" + title + "", self.toc_style_right), - Paragraph(". . . . . . . . . . . . . . . . . . . .", self.toc_style_center), + Paragraph(". " * self.toc_dot_leaders_scalar, self.toc_style_center), Paragraph(str(self.toc_page), self.toc_style_left) ] - def add_metric_section(self, title): + def add_metric_section(self, title: str) -> None: if self.break_ties: if title == "Team Score Rankings" or title == "Team Coaching Efficiency Rankings": color = "green" @@ -1856,57 +2051,84 @@ def add_metric_section(self, title): self.toc_metric_section_data.append(metric_section) self.toc_anchor += 1 - def add_top_performers_section(self, title): + def add_top_performers_section(self, title: str) -> None: top_performers_section = self.format_toc_section(title) self.toc_top_performers_section_data.append(top_performers_section) self.toc_anchor += 1 - def add_chart_section(self, title): + def add_chart_section(self, title: str) -> None: chart_section = self.format_toc_section(title) self.toc_chart_section_data.append(chart_section) self.toc_anchor += 1 - def add_team_section(self, team_name): + def add_team_section(self, team_name: str, team_page: Optional[int] = None) -> None: + + if team_page: + team_section_suffix = f" (Part {team_page})" + else: + team_section_suffix = "" + # truncate data cell contents to 1.5x specified max characters if team name length exceeds that value - team_section = self.format_toc_section(truncate_cell_for_display(team_name, sesqui_max_chars=True)) + team_section = self.format_toc_section( + f"{truncate_cell_for_display(team_name, sesqui_max_chars=True)}{team_section_suffix}" + ) + self.toc_team_section_data.append(team_section) self.toc_anchor += 1 - def add_appendix(self, title): + def add_appendix(self, title: str) -> None: appendix_section = self.format_toc_section(title) self.toc_appendix_data.append(appendix_section) self.toc_anchor += 1 - def get_current_anchor(self): + def get_current_anchor(self) -> int: return self.toc_anchor # noinspection DuplicatedCode - def get_toc(self): + def get_toc(self) -> Table: + """Retrieve Table of Contents element (table comprised of two separate tables that allow the TOC to be divided + into to sections so that when it spans two pages it looks good). + """ - row_heights: List = [] + toc_part_one_row_heights: List = [] if self.toc_metric_section_data: - row_heights.extend([0.25 * inch] * len(self.toc_metric_section_data)) - row_heights.append(0.05 * inch) + toc_part_one_row_heights.extend([self.toc_line_height] * len(self.toc_metric_section_data)) + toc_part_one_row_heights.append(self.toc_section_spacer_row_height) if self.toc_top_performers_section_data: - row_heights.extend([0.25 * inch] * len(self.toc_top_performers_section_data)) - row_heights.append(0.05 * inch) + toc_part_one_row_heights.extend([self.toc_line_height] * len(self.toc_top_performers_section_data)) + toc_part_one_row_heights.append(self.toc_section_spacer_row_height) if self.toc_chart_section_data: - row_heights.extend([0.25 * inch] * len(self.toc_chart_section_data)) - row_heights.append(0.05 * inch) + toc_part_one_row_heights.extend([self.toc_line_height] * len(self.toc_chart_section_data)) + toc_part_one_row_heights.append(self.toc_section_spacer_row_height) + + toc_part_two_row_heights: List = [] if self.toc_team_section_data: - row_heights.extend([0.25 * inch] * len(self.toc_team_section_data)) - row_heights.append(0.05 * inch) + toc_part_two_row_heights.extend([self.toc_line_height] * len(self.toc_team_section_data)) + toc_part_two_row_heights.append(self.toc_section_spacer_row_height) if self.toc_appendix_data: - row_heights.extend([0.25 * inch] * len(self.toc_appendix_data)) + toc_part_two_row_heights.extend([self.toc_line_height] * len(self.toc_appendix_data)) - return Table( + toc_part_one_table = Table( (self.toc_metric_section_data + [["", "", ""]] if self.toc_metric_section_data else []) + (self.toc_top_performers_section_data + [["", "", ""]] if self.toc_top_performers_section_data else []) + - (self.toc_chart_section_data + [["", "", ""]] if self.toc_chart_section_data else []) + + (self.toc_chart_section_data + [["", "", ""]] if self.toc_chart_section_data else []), + colWidths=self.toc_col_widths, + rowHeights=toc_part_one_row_heights + ) + + toc_part_two_table = Table( (self.toc_team_section_data + [["", "", ""]] if self.toc_team_section_data else []) + (self.toc_appendix_data if self.toc_appendix_data else []), - colWidths=[3.25 * inch, 2 * inch, 2.50 * inch], - rowHeights=row_heights + colWidths=self.toc_col_widths, + rowHeights=toc_part_two_row_heights + ) + + return Table( + [ + [toc_part_one_table], + [toc_part_two_table] + ], + style=TableStyle([("ALIGN", (0, 0), (-1, -1), "CENTER")]) ) @@ -1926,7 +2148,7 @@ def get_last_entry_anchor(self): def add_entry(self, title, section_anchor, text): body_style: ParagraphStyle = deepcopy(self.style) - body_style.fontSize = self.font_size - 4 + body_style.fontSize = self.font_size // 2 body_style.firstLineIndent = 1 entry = Paragraph( '''''' + diff --git a/requirements.txt b/requirements.txt index 0cf0bbdf..39f729e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ beautifulsoup4==4.12.3 -camel-converter==3.1.2 +camel-converter==4.0.0 colorama==0.4.6 espn-api==0.39.0 GitPython==3.1.43 @@ -13,7 +13,7 @@ pydantic-settings==2.5.2 PyDrive2==1.20.0 pytest==8.3.3 python-dotenv==1.0.1 -reportlab==4.2.2 +reportlab==4.2.5 requests==2.32.3 selenium==4.25.0 slack_sdk==3.33.1 diff --git a/resources/documentation/descriptions.py b/resources/documentation/descriptions.py index 19e445b4..12fed7b6 100644 --- a/resources/documentation/descriptions.py +++ b/resources/documentation/descriptions.py @@ -1,87 +1,113 @@ __author__ = "Wren J. R. (uberfastman)" __email__ = "uberfastman@uberfastman.dev" -league_standings = "Overall standings for the chosen week. Dynamically adjusts information based on whether a league " \ - "uses only waivers or has a FAAB (free agent acquisition budget). Data marked with an " \ - "asterisk (\"*\") means that due to limitations of whichever fantasy platform API is being " \ - "used, the available information is incomplete in some way." +league_standings = ( + "Overall standings for the chosen week. Dynamically adjusts information based on whether a league uses only " + "waivers or has a FAAB (free agent acquisition budget). Data marked with an asterisk (\"*\") means that due " + "to limitations of whichever fantasy platform API is being used, the available information is incomplete in some " + "way." +) -league_median_matchup_standings = "Overall \"against the median\" league standings. Every week the median score is " \ - "calculated across all teams, and every team plays an extra matchup versus the " \ - "league median score. Teams earn an additional win/loss/tie based on how " \ - "their score matches up against the league median. Median standings are ranked by " \ - "\"Combined Record\" (most wins, then fewest losses, then most ties), and use " \ - " \"Season +/- Median\" (how many total points over/under the median teams have " \ - "scored on the season) as the tie-breaker." +league_median_matchup_standings = ( + "Overall \"against the median\" league standings. Every week the median score is calculated across all teams, and " + "every team plays an extra matchup versus the league median score. Teams earn an additional win/loss/tie based on " + "how their score matches up against the league median. Median standings are ranked by \"Combined Record\" (most " + "wins, then fewest losses, then most ties), and use \"Season +/- Median\" (how many total points over/under the " + "median teams have scored on the season) as the tie-breaker." +) -playoff_probabilities = "Predicts each team's likelihood of making the playoffs, as well as of finishing in any " \ - "given place. These predictions are created using Monte Carlo simulations to simulate the " \ - "rest of the season over and over, and then averaging out each team's performance across all " \ - "performed simulations. Currently these predictions are not aware of special playoff " \ - "eligibility for leagues with divisions or other custom playoff settings." +playoff_probabilities = ( + "Predicts each team's likelihood of making the playoffs, as well as of finishing in any given place. These " + "predictions are created using Monte Carlo simulations to simulate the rest of the season over and over, and then " + "averaging out each team's performance across all performed simulations. Currently these predictions are not aware " + "of special playoff eligibility for leagues with divisions or other custom playoff settings." +) -team_power_rankings = "The power rankings are calculated by taking a weekly average of each team's score, coaching " \ - "efficiency, and luck." +team_power_rankings = ( + "The power rankings are calculated by taking a weekly average of each team's score, coaching efficiency, and luck." +) -team_z_score_rankings = "Measure of standard deviations away from mean for a score. Shows teams performing above or " \ - "below their normal scores for the current week. See Standard Score. This metric shows which teams " \ - "over-performed or underperformed compared to how those teams usually do." +team_z_score_rankings = ( + "Measure of standard deviations away from mean for a score. Shows teams performing above or below their normal " + "scores for the current week. See Standard " + "Score. This metric shows which teams over-performed or underperformed compared to how those teams usually do." +) -team_score_rankings = "Teams ranked by highest score. If tie-breaks are turned on, highest bench points will be used " \ - "to break score ties." +team_score_rankings = ( + "Teams ranked by highest score. If tie-breaks are turned on, highest bench points will be used to break score ties." +) -team_coaching_efficiency_rankings = "Coaching efficiency is calculated by dividing the total points scored by each " \ - "team this week by the highest possible points they could have scored (optimal " \ - "points) this week. This metric is designed to quantify whether manager made " \ - "good sit/start decisions, regardless of how high their team scored or whether " \ - "their team won or lost.
    If tie-breaks are turned " \ - "on, the team with the most starting players that exceeded their weekly average " \ - "points is awarded a higher ranking, and if that is still tied, the team whose " \ - "starting players exceeded their weekly average points by the highest cumulative " \ - "percentage points is awarded a higher ranking." +team_coaching_efficiency_rankings = ( + "Coaching efficiency is calculated by dividing the total points scored by each team this week by the highest " + "possible points they could have scored (optimal points) this week. This metric is designed to quantify whether " + "manager made good sit/start decisions, regardless of how high their team scored or whether their team won or " + "lost.
    If tie-breaks are turned on, the team with the most starting players that " + "exceeded their weekly average points is awarded a higher ranking, and if that is still tied, the team whose " + "starting players exceeded their weekly average points by the highest cumulative percentage points is awarded a " + "higher ranking." +) -team_luck_rankings = "Luck is calculated by matching up each team against every other team that week to get a total " \ - "record against the whole league, then if that team won, the formula is:
" \ - "               " \ - "               " \ - "               " \ - "
luck = (losses + ties) / (number of teams excluding that " \ - "team) * 100

" \ - "and if that team lost, the formula is:
" \ - "               " \ - "               " \ - "               " \ - "
luck = 0 - (wins + ties) / (number of teams excluding " \ - "that team) * 100

" \ - "    This metric is designed to show whether your team was very \"lucky\" " \ - "or \"unlucky\", since a team that would have beaten all but one team this week (second highest " \ - "score) but lost played the only other team they could have lost to, and a team that would have " \ - "lost to all but one team this week (second lowest score) but won played the only other team " \ - "that they could have beaten." +team_luck_rankings = ( + "Luck is calculated by matching up each team against every other team that week to get a total record against the " + "whole league, then if that team won, the formula is:" + "
" + "                   " + "                   " + "       " + "
" + "luck = (losses + ties) / (number of teams excluding that team) * 100" + "
" + "
" + "and if that team lost, the formula is:" + "
" + "                   " + "                   " + "       " + "
" + "luck = 0 - (wins + ties) / (number of teams excluding that team) * 100" + "
" + "
" + "    " + "This metric is designed to show whether your team was very \"lucky\" or \"unlucky\", since a team that would have " + "beaten all but one team this week (second highest score) but lost played the only other team they could have lost " + "to, and a team that would have lost to all but one team this week (second lowest score) but won played the only " + "other team that they could have beaten." +) team_optimal_score_rankings = "Teams ranked by highest optimal score." -bad_boy_rankings = "The Bad Boy ranking is a \"just-for-fun\" metric that pulls NFL player arrest history from the " \ - "USA Today NFL player " \ - "arrest database, and then assigns points to all crimes committed by players on each " \ - "team's starting lineup to give the team a total bad boy score. The points assigned to each " \ - "crime can be found here." +bad_boy_rankings = ( + "The Bad Boy ranking is a \"just-for-fun\" metric that pulls NFL player arrest history from the " + "USA Today NFL player arrest " + "database, and then assigns points to all crimes committed by players on each team's starting lineup to " + "give the team a total bad boy score. The points assigned to each crime can be found " + "here." +) -beef_rankings = "The Beef ranking is a \"just-for-fun\" metric with a made-up unit of measurement, the " \ - "\"TABBU\", which stands for \"Trimmed And Boneless Beef " \ - "Unit(s)\". The TABBU was derived from the amount of trimmed and boneless beef is produced by " \ - "one beef cow, based on academic research done for the beef industry found " \ - "here, " \ - "and is set as equivalent to 500 lbs. The app pulls player weight data from the Sleeper API, an " \ - "example of which can be found " \ - "here, and uses the total weight of each team's starting lineup, including the rolled-up " \ - "weights of starting defenses, to give each team a total TABBU score." +beef_rankings = ( + "The Beef ranking is a \"just-for-fun\" metric with a made-up unit of measurement, the \"TABBU\", which " + "stands for \"Trimmed And Boneless Beef Unit(s)\". The TABBU was derived from " + "the amount of trimmed and boneless beef is produced by one beef cow, based on academic research done for the beef " + "industry found " + "here, and is set as " + "equivalent to 500 lbs. The app pulls player weight data from the Sleeper API, an example of which can be found " + "here, and uses the total weight of each " + "team's starting lineup, including the rolled-up weights of starting defenses, to give each team a total TABBU " + "score." +) + +high_roller_rankings = ( + "The High Roller ranking is a \"just-for-fun\" metric that pulls NFL player fine history (for behavior deemed " + "punishable by the NFL) from Spotrac, and then " + "totals all the fines levied against each team's starting lineup." +) weekly_top_scorers = "Running list of each week's highest scoring team. Can be used for weekly highest points payouts." weekly_low_scorers = "Running list of each week's lowest scoring team." -weekly_highest_coaching_efficiency = "Running list of each week's team with the highest coaching efficiency. Can be " \ - "used for weekly highest coaching efficiency payouts." +weekly_highest_coaching_efficiency = ( + "Running list of each week's team with the highest coaching efficiency. Can be used for weekly highest coaching " + "efficiency payouts." +) diff --git a/tests/test_features.py b/tests/test_features.py index d10fc334..9482c3c2 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -5,67 +5,96 @@ import sys from pathlib import Path -module_dir = Path(__file__).parent.parent -sys.path.append(str(module_dir)) +root_dir = Path(__file__).parent.parent +sys.path.append(str(root_dir)) -from calculate.bad_boy_stats import BadBoyStats # noqa: E402 -from calculate.beef_stats import BeefStats # noqa: E402 +from features.bad_boy import BadBoyFeature # noqa: E402 +from features.beef import BeefFeature # noqa: E402 +from features.high_roller import HighRollerFeature # noqa: E402 from utilities.logger import get_logger # noqa: E402 logger = get_logger(__file__) -test_data_dir = Path(module_dir) / "tests" +test_data_dir = Path(root_dir) / "tests" if not Path(test_data_dir).exists(): os.makedirs(test_data_dir) -player_first_name = "Marquise" -player_last_name = "Brown" +player_first_name = "Rashee" +player_last_name = "Rice" player_full_name = f"{player_first_name} {player_last_name}" -player_team_abbr = "ARI" +player_team_abbr = "KC" player_position = "WR" +season = 2023 + def test_bad_boy_init(): - bad_boy_stats = BadBoyStats( + bad_boy_feature = BadBoyFeature( data_dir=test_data_dir, + root_dir=root_dir, + refresh=True, save_data=True, - offline=False, - refresh=True + offline=False ) - bad_boy_stats.generate_crime_categories_json() + bad_boy_feature.generate_crime_categories_json() logger.info( f"\nPlayer Bad Boy crime for {player_first_name} {player_last_name}: " - f"{bad_boy_stats.get_player_bad_boy_crime(player_first_name, player_last_name, player_team_abbr, player_position)}" + f"{bad_boy_feature.get_player_bad_boy_crime(player_first_name, player_last_name, player_team_abbr, player_position)}" ) logger.info( f"\nPlayer Bad Boy points for {player_first_name} {player_last_name}: " - f"{bad_boy_stats.get_player_bad_boy_points(player_first_name, player_last_name, player_team_abbr, player_position)}" + f"{bad_boy_feature.get_player_bad_boy_points(player_first_name, player_last_name, player_team_abbr, player_position)}" ) - assert bad_boy_stats.bad_boy_data is not None + assert bad_boy_feature.feature_data is not None def test_beef_init(): - beef_stats = BeefStats( + beef_feature = BeefFeature( data_dir=test_data_dir, + refresh=True, save_data=True, - offline=False, - refresh=True + offline=False ) - beef_stats.generate_player_info_json() + beef_feature.generate_player_info_json() logger.info( f"\nPlayer weight for {player_full_name}: " - f"{beef_stats.get_player_weight(player_first_name, player_last_name, player_team_abbr)}" + f"{beef_feature.get_player_weight(player_first_name, player_last_name, player_team_abbr)}" ) logger.info( f"\nPlayer TABBU for {player_full_name}: " - f"{beef_stats.get_player_tabbu(player_first_name, player_last_name, player_team_abbr)}" + f"{beef_feature.get_player_tabbu(player_first_name, player_last_name, player_team_abbr)}" ) - assert beef_stats.beef_data is not None + assert beef_feature.feature_data is not None + + +def test_high_roller_init(): + high_roller_feature = HighRollerFeature( + data_dir=test_data_dir, + season=season, + refresh=True, + save_data=True, + offline=False + ) + + logger.info( + f"\nPlayer worst violation for {player_full_name}: " + f"{high_roller_feature.get_player_worst_violation(player_first_name, player_last_name, player_team_abbr, player_position)}" + ) + logger.info( + f"\nPlayer worst violation fine for {player_full_name}: " + f"{high_roller_feature.get_player_worst_violation_fine(player_first_name, player_last_name, player_team_abbr, player_position)}" + ) + logger.info( + f"\nPlayer fines total for {player_full_name}: " + f"{high_roller_feature.get_player_fines_total(player_first_name, player_last_name, player_team_abbr, player_position)}" + ) + + assert high_roller_feature.feature_data is not None if __name__ == "__main__": @@ -76,3 +105,6 @@ def test_beef_init(): # test player weight (beef) data retrieval test_beef_init() + + # test player NFL fines (high roller) data retrieval + test_high_roller_init() diff --git a/utilities/app.py b/utilities/app.py index 47d6585d..392fb41b 100644 --- a/utilities/app.py +++ b/utilities/app.py @@ -17,8 +17,6 @@ from git import Repo, TagReference, cmd from urllib3 import connectionpool, poolmanager -from calculate.bad_boy_stats import BadBoyStats -from calculate.beef_stats import BeefStats from calculate.metrics import CalculateMetrics from dao.base import BaseLeague, BaseTeam, BasePlayer from dao.platforms.cbs import LeagueData as CbsLeagueData @@ -26,6 +24,9 @@ from dao.platforms.fleaflicker import LeagueData as FleaflickerLeagueData from dao.platforms.sleeper import LeagueData as SleeperLeagueData from dao.platforms.yahoo import LeagueData as YahooLeagueData +from features.bad_boy import BadBoyFeature +from features.beef import BeefFeature +from features.high_roller import HighRollerFeature from utilities.logger import get_logger from utilities.settings import settings from utilities.utils import format_platform_display @@ -137,7 +138,7 @@ def league_data_factory(base_dir: Path, data_dir: Path, platform: str, elif platform == "fleaflicker": fleaflicker_league = FleaflickerLeagueData( - None, + base_dir, data_dir, league_id, season, @@ -152,7 +153,7 @@ def league_data_factory(base_dir: Path, data_dir: Path, platform: str, elif platform == "sleeper": sleeper_league = SleeperLeagueData( - None, + base_dir, data_dir, league_id, season, @@ -208,13 +209,17 @@ def add_report_player_stats(metrics: Dict[str, Any], player: BasePlayer, player.bad_boy_crime = str() player.bad_boy_points = int() player.bad_boy_num_offenders = int() - player.weight = float() - player.tabbu = float() + player.beef_weight = float() + player.beef_tabbu = float() + player.high_roller_worst_violation = str() + player.high_roller_worst_violation_fine = float() + player.high_roller_fines_total = float() + player.high_roller_num_violators = int() if player.selected_position not in bench_positions: if settings.report_settings.league_bad_boy_rankings_bool: - bad_boy_stats: BadBoyStats = metrics.get("bad_boy_stats") + bad_boy_stats: BadBoyFeature = metrics.get("bad_boy_stats") player.bad_boy_crime = bad_boy_stats.get_player_bad_boy_crime( player.first_name, player.last_name, player.nfl_team_abbr, player.primary_position ) @@ -226,9 +231,24 @@ def add_report_player_stats(metrics: Dict[str, Any], player: BasePlayer, ) if settings.report_settings.league_beef_rankings_bool: - beef_stats: BeefStats = metrics.get("beef_stats") - player.weight = beef_stats.get_player_weight(player.first_name, player.last_name, player.nfl_team_abbr) - player.tabbu = beef_stats.get_player_tabbu(player.first_name, player.last_name, player.nfl_team_abbr) + beef_stats: BeefFeature = metrics.get("beef_stats") + player.beef_weight = beef_stats.get_player_weight(player.first_name, player.last_name, player.nfl_team_abbr) + player.beef_tabbu = beef_stats.get_player_tabbu(player.first_name, player.last_name, player.nfl_team_abbr) + + if settings.report_settings.league_high_roller_rankings_bool: + high_roller_stats: HighRollerFeature = metrics.get("high_roller_stats") + player.high_roller_worst_violation = high_roller_stats.get_player_worst_violation( + player.first_name, player.last_name, player.nfl_team_abbr, player.primary_position + ) + player.high_roller_worst_violation_fine = high_roller_stats.get_player_worst_violation_fine( + player.first_name, player.last_name, player.nfl_team_abbr, player.primary_position + ) + player.high_roller_fines_total = high_roller_stats.get_player_fines_total( + player.first_name, player.last_name, player.nfl_team_abbr, player.primary_position + ) + player.high_roller_num_violators = high_roller_stats.get_player_num_violators( + player.first_name, player.last_name, player.nfl_team_abbr, player.primary_position + ) return player @@ -257,6 +277,7 @@ def add_report_team_stats(team: BaseTeam, league: BaseLeague, week_counter: int, team.worst_offense = None team.num_offenders = 0 team.worst_offense_score = 0 + p: BasePlayer for p in team.roster: if p.selected_position not in bench_positions: if p.bad_boy_points > 0: @@ -270,8 +291,22 @@ def add_report_team_stats(team: BaseTeam, league: BaseLeague, week_counter: int, team.worst_offense_score = p.bad_boy_points if settings.report_settings.league_beef_rankings_bool: - team.total_weight = sum([p.weight for p in team.roster if p.selected_position not in bench_positions]) - team.tabbu = sum([p.tabbu for p in team.roster if p.selected_position not in bench_positions]) + team.total_weight = sum([p.beef_weight for p in team.roster if p.selected_position not in bench_positions]) + team.tabbu = sum([p.beef_tabbu for p in team.roster if p.selected_position not in bench_positions]) + + if settings.report_settings.league_high_roller_rankings_bool: + p: BasePlayer + for p in team.roster: + if p.selected_position not in bench_positions: + if p.high_roller_fines_total > 0: + team.fines_total += p.high_roller_fines_total + if p.selected_position == "D/ST": + team.num_violators += p.high_roller_num_violators + else: + team.num_violators += 1 + if p.high_roller_fines_total > team.worst_violation_fine: + team.worst_violation = p.high_roller_worst_violation + team.worst_violation_fine = p.high_roller_worst_violation_fine team.positions_filled_active = [p.selected_position for p in team.roster if p.selected_position not in bench_positions] @@ -383,7 +418,7 @@ def git_ls_remote(url: str): return remote_refs -def check_for_updates(use_default: bool = False): +def check_github_for_updates(use_default: bool = False): if not active_network_connection(): logger.info( "No active network connection found. Unable to check for updates for the Fantasy Football Metrics Weekly " @@ -434,9 +469,9 @@ def check_for_updates(use_default: bool = False): if active_branch != target_branch: if not use_default: switch_branch = input( - f"{Fore.YELLOW}You are {Fore.RED}not{Fore.YELLOW} on the deployment branch " + f"{Fore.YELLOW}You are {Fore.RED}not {Fore.YELLOW}on the deployment branch " f"({Fore.GREEN}\"{target_branch}\"{Fore.YELLOW}) of the Fantasy Football Metrics Weekly Report " - f"app. Do you want to switch to the {Fore.GREEN}\"{target_branch}\"{Fore.YELLOW} branch? " + f"app.\nDo you want to switch to the {Fore.GREEN}\"{target_branch}\"{Fore.YELLOW} branch? " f"({Fore.GREEN}y{Fore.YELLOW}/{Fore.RED}n{Fore.YELLOW}) -> {Style.RESET_ALL}" ) @@ -450,7 +485,7 @@ def check_for_updates(use_default: bool = False): else: logger.warning("You must select either \"y\" or \"n\".") project_repo.remote(name="origin").set_url(origin_url) - return check_for_updates(use_default) + return check_github_for_updates(use_default) else: logger.info("Use-default is set to \"true\". Automatically switching to deployment branch \"main\".") project_repo.git.checkout(target_branch) @@ -505,7 +540,7 @@ def check_for_updates(use_default: bool = False): else: logger.warning("Please only select \"y\" or \"n\".") time.sleep(0.25) - check_for_updates() + check_github_for_updates() else: logger.info( f"The Fantasy Football Metrics Weekly Report app is {Fore.GREEN}up to date{Fore.WHITE} and running " diff --git a/utilities/settings.py b/utilities/settings.py index 307371a8..2ea978f8 100644 --- a/utilities/settings.py +++ b/utilities/settings.py @@ -232,6 +232,7 @@ class ReportSettings(CustomSettings): league_optimal_score_rankings_bool: bool = Field(True, title=__qualname__) league_bad_boy_rankings_bool: bool = Field(True, title=__qualname__) league_beef_rankings_bool: bool = Field(True, title=__qualname__) + league_high_roller_rankings_bool: bool = Field(True, title=__qualname__) league_weekly_top_scorers_bool: bool = Field(True, title=__qualname__) league_weekly_low_scorers_bool: bool = Field(True, title=__qualname__) league_weekly_highest_ce_bool: bool = Field(True, title=__qualname__) @@ -240,6 +241,7 @@ class ReportSettings(CustomSettings): team_points_by_position_charts_bool: bool = Field(True, title=__qualname__) team_bad_boy_stats_bool: bool = Field(True, title=__qualname__) team_beef_stats_bool: bool = Field(True, title=__qualname__) + team_high_roller_stats_bool: bool = Field(True, title=__qualname__) team_boom_or_bust_bool: bool = Field(True, title=__qualname__) font: str = Field( @@ -254,11 +256,16 @@ class ReportSettings(CustomSettings): ) font_size: int = Field( 12, + ge=8, + le=14, title=__qualname__, - description="set base font size (certain report element fonts resize dynamically based on the base font size)" + description=( + "set base font size so report element fonts resize dynamically (min: 8, max: 14)" + ) ) image_quality: int = Field( 75, + le=100, title=__qualname__, description=( "specify player headshot image quality in percent (default: 75%), where higher quality (up to 100%) " @@ -327,6 +334,13 @@ class AppSettings(CustomSettings): title=__qualname__, description="logger output level: notset, debug, info, warning, error, critical" ) + check_for_updates: bool = Field( + True, + title=__qualname__, + description=( + "automatically check GitHub for app updates and prompt user to update if local installation is out of date" + ) + ) # output directories can be set to store your saved data and generated reports wherever you want data_dir_path: Path = Field( Path("output/data"), diff --git a/utilities/utils.py b/utilities/utils.py index 8cc4abe6..977c5aef 100644 --- a/utilities/utils.py +++ b/utilities/utils.py @@ -16,11 +16,22 @@ def truncate_cell_for_display(cell_text: str, halve_max_chars: bool = False, ses if halve_max_chars and sesqui_max_chars: logger.warning( - f"Max characters cannot be both halved and doubled. Defaulting to configure max characters: {max_chars}" + f"Max characters cannot be both halved and multiplied. Defaulting to configure max characters: {max_chars}" ) elif halve_max_chars: max_chars //= 2 elif sesqui_max_chars: max_chars += (max_chars // 2) - return f"{cell_text[:max_chars].strip()}..." if len(cell_text) > max_chars else cell_text + if len(cell_text) > max_chars: + # preserve footnote character on strings that need to be truncated + footnote_char = None + if cell_text.endswith("†") or cell_text.endswith("‡"): + footnote_char = cell_text[-1] + cell_text = cell_text[:-1] + max_chars -= 1 + + return f"{cell_text[:max_chars].strip()}...{footnote_char if footnote_char else ''}" + + else: + return cell_text