diff --git a/README.md b/README.md index dfc0e7f..45ff879 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/calculate/metrics.py b/calculate/metrics.py index a748e7c..47f07dd 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 68d1428..d95ef02 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 a53e0f6..bfd6dbc 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.playoff_probabilities import PlayoffProbabilities from features.bad_boy import BadBoyFeature from features.beef import BeefFeature -from calculate.playoff_probabilities import PlayoffProbabilities +from features.high_roller import HighRollerFeature # noinspection GrazieInspection @@ -99,7 +100,7 @@ def __init__(self, root_dir: Path, data_dir: Path, league_id: str, season: int, 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 @@ -113,7 +114,7 @@ def __init__(self, root_dir: Path, data_dir: Path, league_id: str, season: int, 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] = [] @@ -140,8 +141,8 @@ def __init__(self, root_dir: Path, data_dir: Path, league_id: str, season: int, 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) @@ -243,21 +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) -> BadBoyFeature: + def get_bad_boy_stats(self, refresh: bool = False, save_data: bool = False, offline: bool = False) -> BadBoyFeature: return BadBoyFeature( - self.root_dir, 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) -> BeefFeature: + 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 + ) + + 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, - refresh=refresh + offline=offline ) @@ -289,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 @@ -580,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": @@ -607,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): @@ -646,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/cbs.py b/dao/platforms/cbs.py index a5bb73b..240d876 100644 --- a/dao/platforms/cbs.py +++ b/dao/platforms/cbs.py @@ -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/features/bad_boy.py b/features/bad_boy.py index 771e85c..5cdac1b 100644 --- a/features/bad_boy.py +++ b/features/bad_boy.py @@ -3,36 +3,28 @@ 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 +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(object): +class BadBoyFeature(BaseFeature): - def __init__(self, root_dir: Path, 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 + 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 """ - logger.debug("Initializing bad boy stats.") - - self.resource_files_dir = root_dir / "resources" / "files" - - 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", @@ -43,9 +35,7 @@ def __init__(self, root_dir: Path, data_dir: Path, save_data: bool = False, offl "OC": "C", # coaching staff } - # create parent directory if it does not exist - if not data_dir.exists(): - os.makedirs(data_dir) + 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", @@ -56,47 +46,86 @@ def __init__(self, root_dir: Path, data_dir: Path, save_data: bool = False, offl # 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 = 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 = 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.") + 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" + } - 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"] + """ + 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}\"}}" + ) - usa_today_nfl_arrest_url = "https://databases.usatoday.com/wp-admin/admin-ajax.php" - headers = { - "Content-Type": "application/x-www-form-urlencoded" - } + 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 - # 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 + for page in range(2, num_pages + 1): + page_num += 1 body = ( f"action=cspFetchTable" f"&security={ajax_nonce}" @@ -107,9 +136,10 @@ def __init__(self, root_dir: Path, data_dir: Path, save_data: bool = False, offl f"&searches={{\"Team\":\"{team}\"}}" ) - res_json = requests.post(usa_today_nfl_arrest_url, data=body, headers=headers).json() + r = requests.post(usa_today_nfl_arrest_url, data=body, headers=headers) + resp_json = r.json() - arrests_data = res_json["data"]["Result"] + arrests_data = resp_json["data"]["Result"] for arrest in arrests_data: arrests.append({ @@ -128,162 +158,84 @@ def __init__(self, root_dir: Path, data_dir: Path, save_data: bool = False, offl "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 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 + arrests_by_team = { + key: list(group) for key, group in itertools.groupby( + sorted(arrests, key=lambda x: x["team"]), + lambda x: x["team"] + ) + } - 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.") + for team_abbr in nfl_team_abbreviations: - nfl_player = { - "team": team_abbr, - "pos": player_pos, - "offenses": [], + 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 } - # update player entry - nfl_player["offenses"].append({offense_category: offense_points}) - nfl_player["total_points"] += offense_points + 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.") - if offense_points > nfl_player["worst_offense_points"]: - nfl_player["worst_offense"] = offense_category - nfl_player["worst_offense_points"] = offense_points + nfl_player = { + "team": team_abbr, + "pos": player_pos, + "offenses": [], + "total_points": 0, + "worst_offense": None, + "worst_offense_points": 0 + } - self.bad_boy_data[player_name] = nfl_player + # update player entry + nfl_player["offenses"].append({offense_category: offense_points}) + nfl_player["total_points"] += offense_points - # 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_player["worst_offense_points"]: + nfl_player["worst_offense"] = offense_category + nfl_player["worst_offense_points"] = offense_points - if offense_points > nfl_team["worst_offense_points"]: - nfl_team["worst_offense"] = offense_category - nfl_team["worst_offense_points"] = offense_points + self.feature_data[player_name] = nfl_player - self.bad_boy_data[team_abbr] = nfl_team + # 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"]) - 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. + 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 @@ -299,24 +251,24 @@ def get_player_bad_boy_stats(self, player_first_name: str, player_last_name: str 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 "") + 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.bad_boy_data: - return self.bad_boy_data[player_full_name][key_str] if key_str else self.bad_boy_data[player_full_name] + 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.bad_boy_data[player_full_name] = { + self.feature_data[player_full_name] = { "team": player_team, "pos": player_pos, "offenses": [], @@ -324,22 +276,25 @@ def get_player_bad_boy_stats(self, player_first_name: str, player_last_name: str "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] + 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") + 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") + 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) + 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: @@ -350,9 +305,3 @@ def generate_crime_categories_json(self): 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) - - 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/features/base/feature.py b/features/base/feature.py index 0f81749..51077f0 100644 --- a/features/base/feature.py +++ b/features/base/feature.py @@ -1,360 +1,116 @@ __author__ = "Wren J. R. (uberfastman)" __email__ = "uberfastman@uberfastman.dev" -import itertools import json import os -import re -import string -from collections import OrderedDict +from abc import ABC, abstractmethod from pathlib import Path -from typing import Dict, List, Any, Union, Optional +from typing import Dict, List, Any -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 BaseFeature(object): +class BaseFeature(ABC): - def __init__(self, feature_type: str, refresh: bool = False, save_data: bool = False, offline: bool = False, - root_dir: Optional[Path] = None, data_dir: Optional[Path] = None): - """ + 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.root_dir: Path = root_dir - self.data_dir: Path = data_dir - self.raw_feature_data: Dict[str, Any] = {} self.feature_data: Dict[str, Any] = {} - self.raw_feature_data_file_path: Optional[Path] = None - self.feature_data_file_path: Optional[Path] = None - - # load preexisting (saved) feature data (if it exists) if refresh=False or if offline=True - if not self.refresh or self.offline: - self.load_feature_data() - - # create output data directory if it does not exist - if not data_dir.exists(): - os.makedirs(data_dir) - - # preserve raw retrieved data for reference and later usage - self.raw_feature_data: Dict[str, Any] = {} - self.raw_feature_data_file_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 = data_dir / "bad_boy_data.json" - - - - # 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"] - }) + 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" - arrests_by_team = { - key: list(group) for key, group in itertools.groupby( - sorted(arrests, key=lambda x: x["team"]), - lambda x: x["team"] - ) - } + self.player_name_punctuation: List[str] = [".", "'"] + self.player_name_suffixes: List[str] = ["Jr", "Sr", "V", "IV", "III", "II", "I"] # ordered for str.removesuffix - for team_abbr in nfl_team_abbreviations: - self.add_entry(team_abbr, arrests_by_team.get(team_abbr)) + # 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.save_bad_boy_data() + self._get_feature_data() - # if offline mode, load pre-fetched bad boy data (only works if you've previously run application with -s flag) + 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: - 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!" - ) + self._load_feature_data() - if len(self.bad_boy_data) == 0: + if len(self.feature_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.") + 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.bad_boy_data)} bad boy records loaded") + logger.info(f"{len(self.feature_data)} feature data records loaded") - def load_feature_data(self): - logger.debug(f"Loading saved {self.feature_type_title} data...") + def __str__(self): + return json.dumps(self.feature_data, indent=2, ensure_ascii=False) - feature_data_file_path = self.data_dir / f"{self.feature_type_str}_data.json" + def __repr__(self): + return json.dumps(self.feature_data, indent=2, ensure_ascii=False) - if 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 _load_feature_data(self) -> None: + logger.debug(f"Loading saved {self.feature_type_title} data...") - # if offline mode, load pre-fetched bad boy data (only works if you've previously run application with -s flag) + 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: - 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!" - ) - - 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 + raise FileNotFoundError( + f"FILE {self.feature_data_file_path} DOES NOT EXIST. CANNOT RUN LOCALLY WITHOUT HAVING PREVIOUSLY " + f"SAVED DATA!" + ) - # 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"]) + def _save_feature_data(self) -> None: + logger.debug(f"Saving {self.feature_type_title} data and raw {self.feature_type_title} data.") - if offense_points > nfl_team["worst_offense_points"]: - nfl_team["worst_offense"] = offense_category - nfl_team["worst_offense_points"] = offense_points + # create output data directory if it does not exist + if not self.data_dir.is_dir(): + os.makedirs(self.data_dir, exist_ok=True) - self.bad_boy_data[team_abbr] = nfl_team + # 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) - 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. + # 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) - 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) + def _normalize_player_name(self, player_name: str) -> str: + """Remove all punctuation and name suffixes from player names and covert them to title case. """ - 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] + 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)): - 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") + for punc in self.player_name_punctuation: + normalized_player_name = normalized_player_name.replace(punc, "") - 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 + for suffix in self.player_name_suffixes: + normalized_player_name = normalized_player_name.removesuffix(suffix) - 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) + return normalized_player_name.strip().title() - 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) + @abstractmethod + def _get_feature_data(self) -> None: + raise NotImplementedError diff --git a/features/beef.py b/features/beef.py index 168ed42..5fa81e7 100644 --- a/features/beef.py +++ b/features/beef.py @@ -4,140 +4,102 @@ import json from collections import OrderedDict from pathlib import Path -from typing import List, Dict, Any +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(object): +class BeefFeature(BaseFeature): - def __init__(self, data_dir: Path, save_data: bool = False, offline: bool = False, refresh: bool = False): + 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 """ - Initialize class, load data from Sleeper API, and combine defensive player data into team total - """ - logger.debug("Initializing beef feature.") - - 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 = data_dir / "beef_raw_data.json" - - self.beef_data: Dict[str, Dict[str, Any]] = {} - self.beef_data_file_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 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: + 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 @@ -161,36 +123,30 @@ def get_player_beef_stat(self, player_first_name: str, player_last_name: str, pl 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] + 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.beef_data[player_full_name] = { + self.feature_data[player_full_name] = { "fullName": player_full_name, "weight": 0, "tabbu": 0, } - return self.beef_data[player_full_name][key_str] + 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_stat(player_first_name, player_last_name, team_abbr, "weight")) + 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_stat(player_first_name, player_last_name, team_abbr, "tabbu"), 3) + 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_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: + 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) - - 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/features/high_roller.py b/features/high_roller.py index 7482e80..97e4ba7 100644 --- a/features/high_roller.py +++ b/features/high_roller.py @@ -1,32 +1,27 @@ __author__ = "Wren J. R. (uberfastman)" __email__ = "uberfastman@uberfastman.dev" -import json from datetime import datetime from pathlib import Path -from typing import Dict, List, Union +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 HighRollerStats(object): +class HighRollerFeature(BaseFeature): - def __init__(self, season: int, data_dir: Path, save_data: bool = False, offline: bool = False, - refresh: bool = False): - """ Initialize class, load data from Spotrac.com. + 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. """ - logger.debug("Initializing high roller stats.") - - self.data_dir: Path = Path(data_dir) - - self.save_data: bool = save_data - self.offline: bool = offline - self.refresh: bool = refresh + self.season: int = season # position type reference self.position_types: Dict[str, str] = { @@ -34,52 +29,32 @@ def __init__(self, season: int, data_dir: Path, save_data: bool = False, offline # 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 + "C": "L", "G": "L", "LS": "L", "LT": "L", "RT": "L", # offensive line + "D/ST": "D" # team defense } - self.high_roller_data: Dict[str, Dict[str, Union[str, List[Dict]]]] = {} - self.high_roller_data_file_path: Path = self.data_dir / "high_roller_data.json" - - if not self.refresh: - self.open_high_roller_data() - - # fetch fines of players from the web if not running in offline mode or refresh=True - if self.refresh or not self.offline: - if not self.high_roller_data: - logger.debug("Retrieving high roller data from the web.") - - self.get_player_fines_data(season) - - self.save_high_roller_data() - - # if offline mode, load pre-fetched fines data (only works if you've previously run application with -s flag) - else: - if not self.high_roller_data: - raise FileNotFoundError( - f"FILE {str(self.high_roller_data_file_path)} DOES NOT EXIST. CANNOT RUN LOCALLY WITHOUT HAVING " - f"PREVIOUSLY SAVED DATA!" - ) - - if len(self.high_roller_data) == 0: - logger.warning( - f"NO high roller data was loaded, please check your internet connection or the availability of " - f"\"https://www.spotrac.com/nfl/fines/_/year/{season}\" and try generating a new report.") - else: - logger.info(f"{len(self.high_roller_data)} players with fines were loaded") - - def open_high_roller_data(self): - logger.debug("Loading saved high roller data.") - if self.high_roller_data_file_path.exists(): - with open(self.high_roller_data_file_path, "r", encoding="utf-8") as high_roller_in: - self.high_roller_data = dict(json.load(high_roller_in)) - - def save_high_roller_data(self): - if self.save_data: - logger.debug("Saving high roller data.") - with open(self.high_roller_data_file_path, "w", encoding="utf-8") as high_roller_out: - json.dump(self.high_roller_data, high_roller_out, ensure_ascii=False, indent=2) + super().__init__( + "high_roller", + f"https://www.spotrac.com/nfl/fines/_/year/{self.season}", + data_dir, + refresh, + save_data, + offline + ) - def get_player_fines_data(self, season: int) -> None: + 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) " @@ -90,10 +65,7 @@ def get_player_fines_data(self, season: int) -> None: "user-agent": user_agent } - response = requests.get( - f"https://www.spotrac.com/nfl/fines/_/year/{season}", - headers=headers - ) + response = requests.get(self.feature_web_base_url, headers=headers) html_soup = BeautifulSoup(response.text, "html.parser") logger.debug(f"Response URL: {response.url}") @@ -101,38 +73,134 @@ def get_player_fines_data(self, season: int) -> None: fined_players = html_soup.find("tbody").findAll("tr", {"class": ""}) - positions = set() 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 = { - "player_name": player_name, - "player_pos": player.find("td", {"class": "text-left details-sm"}).getText().strip(), - "player_team": player.find("img", {"class": "me-2"}).getText().strip(), - "player_violation": player.find("span", {"class": "text-muted"}).getText()[2:].strip(), - "player_fine": int("".join([ + "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() ])), - "player_fine_date": datetime.strptime( + "violation_season": self.season, + "violation_date": datetime.strptime( player.find("td", {"class": "text-right details"}).getText().strip(), "%m/%d/%y" ).isoformat() } - positions.add(player_fine_info["player_pos"]) - - if player_name not in self.high_roller_data.keys(): - self.high_roller_data[player_name] = { - "total_fines": player_fine_info["player_fine"], - "fines": [player_fine_info] + 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.high_roller_data[player_name]["total_fines"] += player_fine_info["player_fine"] - self.high_roller_data[player_name]["fines"].append(player_fine_info) + 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 - def __str__(self): - return json.dumps(self.high_roller_data, indent=2, ensure_ascii=False) + return self.feature_data[player_full_name][key_str] - def __repr__(self): - return json.dumps(self.high_roller_data, indent=2, ensure_ascii=False) + 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 2097993..5f8209f 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 83e0d4d..fdfadc0 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 5958cd5..bf99cc1 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 fdb91e0..a972962 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 0cf0bbd..39f729e 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 19e445b..12fed7b 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 fe72784..9482c3c 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -10,7 +10,7 @@ from features.bad_boy import BadBoyFeature # noqa: E402 from features.beef import BeefFeature # noqa: E402 -from features.high_roller import HighRollerStats # noqa: E402 +from features.high_roller import HighRollerFeature # noqa: E402 from utilities.logger import get_logger # noqa: E402 @@ -20,70 +20,81 @@ 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 = 2024 +season = 2023 def test_bad_boy_init(): - bad_boy_stats = BadBoyFeature( - root_dir=root_dir, + 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 = BeefFeature( + 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_stats = HighRollerStats( - season=season, + high_roller_feature = HighRollerFeature( data_dir=test_data_dir, + season=season, + refresh=True, save_data=True, - offline=False, - refresh=True + offline=False ) - logger.info(f"\n{high_roller_stats}") + 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_stats.high_roller_data is not None + assert high_roller_feature.feature_data is not None if __name__ == "__main__": diff --git a/utilities/app.py b/utilities/app.py index efa0569..392fb41 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 features.bad_boy import BadBoyFeature -from features.beef import BeefFeature 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,8 +209,12 @@ 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: @@ -227,8 +232,23 @@ def add_report_player_stats(metrics: Dict[str, Any], player: BasePlayer, if settings.report_settings.league_beef_rankings_bool: beef_stats: BeefFeature = 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) + 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 307371a..2ea978f 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 8cc4abe..977c5ae 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