Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New Full Logbook #38

Merged
merged 16 commits into from
Jun 21, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 56 additions & 3 deletions src/boardlib/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
import boardlib.api.moon
import boardlib.db.aurora


LOGBOOK_FIELDS = ("board", "angle", "name", "date", "grade", "tries", "is_mirror")
FULL_LOGBOOK_FIELDS = ("board", "angle", "climb_name", "date", "logged_grade", "displayed_grade", "tries", "is_mirror", "sessions_count", "tries_total", "is_repeat", "is_ascent")


def logbook_entries(board, username, password, grade_type="font", database=None):
Expand All @@ -27,11 +29,11 @@ def logbook_entries(board, username, password, grade_type="font", database=None)
raise ValueError(f"Unknown board {board}")


def write_entries(output_file, entries, no_headers=False):
writer = csv.DictWriter(output_file, LOGBOOK_FIELDS)

def write_entries(output_file, entries, no_headers=False, fields=LOGBOOK_FIELDS):
writer = csv.DictWriter(output_file, fieldnames=fields)
if not no_headers:
writer.writeheader()

writer.writerows(entries)


Expand Down Expand Up @@ -62,6 +64,21 @@ def handle_database_command(args):
print(f"Synchronized {row_count} rows in {table_name}")


def handle_full_logbook_command(args):
env_var = f"{args.board.upper()}_PASSWORD"
password = os.environ.get(env_var)
if not password:
password = getpass.getpass("Password: ")
entries = boardlib.api.aurora.get_full_logbook_entries(args.board, args.username, password, args.grade_type, args.database)

if args.output:
with open(args.output, "w", encoding="utf-8") as output_file:
write_entries(output_file, entries.to_dict(orient="records"), args.no_headers, fields=FULL_LOGBOOK_FIELDS)
else:
sys.stdout.reconfigure(encoding="utf-8")
write_entries(sys.stdout, entries.to_dict(orient="records"), args.no_headers, fields=FULL_LOGBOOK_FIELDS)


def add_database_parser(subparsers):
database_parser = subparsers.add_parser(
"database", help="Download and sync the database"
Expand Down Expand Up @@ -116,15 +133,51 @@ def add_logbook_parser(subparsers):
)
logbook_parser.set_defaults(func=handle_logbook_command)


def add_full_logbook_parser(subparsers):
full_logbook_parser = subparsers.add_parser(
"full_logbook", help="Download full logbook entries (ascents and bids) to CSV"
)
full_logbook_parser.add_argument(
"board",
help="Board name",
choices=sorted(
boardlib.api.moon.BOARD_IDS.keys() | boardlib.api.aurora.HOST_BASES.keys()
),
)
full_logbook_parser.add_argument("-u", "--username", help="Username", required=True)
full_logbook_parser.add_argument("-o", "--output", help="Output file", required=False)
full_logbook_parser.add_argument(
"--no-headers", help="Don't write headers", action="store_true", required=False
)
full_logbook_parser.add_argument(
"-g",
"--grade-type",
help="Grade type",
choices=("font", "hueco"),
default="font",
required=False,
)
full_logbook_parser.add_argument(
"-d",
"--database",
help="Path to the local database (optional). Using a local database can significantly speed up the logbook generation. Create a local database with the 'boardlib database' command.",
type=pathlib.Path,
required=False,
)
full_logbook_parser.set_defaults(func=handle_full_logbook_command)


def main():
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
add_logbook_parser(subparsers)
add_full_logbook_parser(subparsers) # Add this line
Copy link
Owner

@lemeryfertitta lemeryfertitta Jun 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than maintaining two logbook commands, let's just get your features into the original command.

For Moonboard logbooks, we can just return null in the new fields for now, with the idea of eventually implementing them or just leaving them null if they don't apply. I think it's fine to leave fields null if they require a database. We can update the README and/or command help to explain the differences.

add_database_parser(subparsers)
args = parser.parse_args()
args.func(args)



if __name__ == "__main__":
main()
202 changes: 202 additions & 0 deletions src/boardlib/api/aurora.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import sqlite3
import bs4
import requests
import pandas as pd

import boardlib.util.grades

Expand Down Expand Up @@ -391,3 +392,204 @@ def save_climb(
)
response.raise_for_status()
return response.json()


def get_bids_logbook(board, token, user_id):
sync_results = user_sync(board, token, user_id, tables=["bids"])
return sync_results["PUT"]["bids"]




def bids_logbook_entries(board, username, password, db_path=None):
login_info = login(board, username, password)
raw_entries = get_bids_logbook(board, login_info["token"], login_info["user_id"])

for raw_entry in raw_entries:
if db_path:
climb_name = get_climb_name_from_db(db_path, raw_entry["climb_uuid"])
else:
climb_name = get_climb_name(board, raw_entry["climb_uuid"])

yield {
"uuid": raw_entry["uuid"],
"user_id": raw_entry["user_id"],
"climb_uuid": raw_entry["climb_uuid"],
"climb_name": climb_name if climb_name else "Unknown Climb",
"angle": raw_entry["angle"],
"is_mirror": raw_entry["is_mirror"],
"bid_count": raw_entry["bid_count"],
"comment": raw_entry["comment"],
"climbed_at": raw_entry["climbed_at"],
"created_at": raw_entry["created_at"],
}

def get_displayed_grade_from_db(database, climb_uuid, angle, grades_dict):
conn = sqlite3.connect(database)
cursor = conn.cursor()
cursor.execute(
"SELECT display_difficulty FROM climb_stats WHERE climb_uuid = ? AND angle = ?",
(climb_uuid, angle)
)
row = cursor.fetchone()
conn.close()
if row:
difficulty_value = round(row[0])
grade_info = grades_dict.get(difficulty_value, {})
return grade_info.get("french_name", "NA")
return "NA"
lemeryfertitta marked this conversation as resolved.
Show resolved Hide resolved

def get_full_logbook_entries(board, username, password, grade_type="font", db_path=None):
lemeryfertitta marked this conversation as resolved.
Show resolved Hide resolved
# Get bids and ascents data
login_info = login(board, username, password)
token = login_info["token"]
user_id = login_info["user_id"]

bids_entries = list(bids_logbook_entries(board, username, password, db_path))
raw_ascents_entries = get_logbook(board, token, user_id)

# Convert bids entries to DataFrame
bids_df = pd.DataFrame(bids_entries)
bids_df['climbed_at'] = pd.to_datetime(bids_df['climbed_at'])

# Process raw ascents entries and convert to DataFrame
ascents_entries = []
grades = get_grades(board)

# Convert grades to dictionary for easy lookup
grades_dict = {grade['difficulty']: grade for grade in grades}

for raw_entry in raw_ascents_entries:
if not raw_entry["is_listed"]:
continue
if db_path:
climb_name = get_climb_name_from_db(db_path, raw_entry["climb_uuid"])
displayed_grade = get_displayed_grade_from_db(db_path, raw_entry["climb_uuid"], raw_entry["angle"], grades_dict)
else:
climb_name = get_climb_name(board, raw_entry["climb_uuid"])
displayed_grade = "NA"
lemeryfertitta marked this conversation as resolved.
Show resolved Hide resolved

grade_info = grades_dict.get(raw_entry["difficulty"], {})
logged_grade = grade_info.get("french_name" if grade_type == "font" else "verm_name", "Unknown")
lemeryfertitta marked this conversation as resolved.
Show resolved Hide resolved

ascents_entries.append({
"board": board,
"angle": raw_entry["angle"],
"climb_uuid": raw_entry["climb_uuid"],
"name": climb_name if climb_name else "Unknown Climb",
lemeryfertitta marked this conversation as resolved.
Show resolved Hide resolved
"date": datetime.datetime.strptime(raw_entry["climbed_at"], "%Y-%m-%d %H:%M:%S"),
"logged_grade": logged_grade,
"displayed_grade": displayed_grade,
"tries": raw_entry["attempt_id"] if raw_entry["attempt_id"] else raw_entry["bid_count"],
"is_mirror": raw_entry["is_mirror"]
})

ascents_df = pd.DataFrame(ascents_entries)

# Summarize the bids table
bids_summary = bids_df.groupby(['climb_uuid', 'climb_name', bids_df['climbed_at'].dt.date, 'is_mirror', 'angle']).agg({
'bid_count': 'sum'
}).reset_index().rename(columns={'climbed_at': 'date'})

# Create a new column for is_ascent
bids_summary['is_ascent'] = False
bids_summary['tries'] = bids_summary['bid_count']

# Check for ascents and update the logbook
final_logbook = []

for _, ascent_row in ascents_df.iterrows():
ascent_date = ascent_row['date'].date()
ascent_climb_uuid = ascent_row['climb_uuid']
ascent_climb_name = ascent_row['name']
ascent_is_mirror = ascent_row['is_mirror']
ascent_angle = ascent_row['angle']

# Find corresponding bids entries
bid_match = bids_summary[
(bids_summary['climb_uuid'] == ascent_climb_uuid) &
(bids_summary['date'] == ascent_date) &
(bids_summary['is_mirror'] == ascent_is_mirror) &
(bids_summary['angle'] == ascent_angle)
]

if not bid_match.empty:
bid_row = bid_match.iloc[0]
total_tries = ascent_row['tries'] + bid_row['tries']
final_logbook.append({
'board': ascent_row['board'],
'angle': ascent_row['angle'],
'climb_name': ascent_row['name'],
'date': ascent_row['date'],
'logged_grade': ascent_row['logged_grade'],
'displayed_grade': ascent_row['displayed_grade'],
'tries': total_tries,
'is_mirror': ascent_row['is_mirror'],
'is_ascent': True
})
bids_summary = bids_summary.drop(bid_match.index) # Remove matched bids
else:
final_logbook.append({
'board': ascent_row['board'],
'angle': ascent_row['angle'],
'climb_name': ascent_row['name'],
'date': ascent_row['date'],
'logged_grade': ascent_row['logged_grade'],
'displayed_grade': ascent_row['displayed_grade'],
'tries': ascent_row['tries'],
'is_mirror': ascent_row['is_mirror'],
'is_ascent': True
})

# Add remaining bids that do not have corresponding ascents
for _, bid_row in bids_summary.iterrows():
if db_path:
displayed_grade = get_displayed_grade_from_db(db_path, bid_row["climb_uuid"], bid_row["angle"], grades_dict)
else:
displayed_grade = 'NA'
lemeryfertitta marked this conversation as resolved.
Show resolved Hide resolved

final_logbook.append({
'board': board,
'angle': bid_row['angle'],
'climb_name': bid_row['climb_name'],
'date': bid_row['date'],
'logged_grade': 'NA', # We don't have logged_grade information for bids
lemeryfertitta marked this conversation as resolved.
Show resolved Hide resolved
'displayed_grade': displayed_grade,
'tries': bid_row['tries'],
'is_mirror': bid_row['is_mirror'],
'is_ascent': False
})

# Convert to DataFrame
full_logbook_df = pd.DataFrame(final_logbook, columns=['board', 'angle', 'climb_name', 'date', 'logged_grade', 'displayed_grade', 'tries', 'is_mirror', 'is_ascent'])

# Ensure all dates are converted to Timestamps
full_logbook_df['date'] = pd.to_datetime(full_logbook_df['date'])


# Calculate sessions_count and tries_total
def calculate_sessions_count(group):
group = group.sort_values(by='date')
unique_dates = group['date'].dt.date.drop_duplicates().reset_index(drop=True)
sessions_count = unique_dates.rank(method='dense').astype(int)
sessions_count_map = dict(zip(unique_dates, sessions_count))
group['sessions_count'] = group['date'].dt.date.map(sessions_count_map)
return group

full_logbook_df = full_logbook_df.groupby(['climb_name', 'is_mirror', 'angle']).apply(calculate_sessions_count).reset_index(drop=True)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

full_logbook_df = full_logbook_df.groupby(['climb_uuid', 'climb_name', 'is_mirror', 'angle']).apply(calculate_sessions_count).reset_index(drop=True)


def calculate_tries_total(group):
group = group.sort_values(by='date')
group['tries_total'] = group['tries'].cumsum()
return group

full_logbook_df = full_logbook_df.groupby(['climb_name', 'is_mirror', 'angle']).apply(calculate_tries_total).reset_index(drop=True)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

full_logbook_df = full_logbook_df.groupby(['climb_uuid', 'climb_name', 'is_mirror', 'angle']).apply(calculate_tries_total).reset_index(drop=True)


# Add is_repeat column
full_logbook_df['is_repeat'] = full_logbook_df.duplicated(subset=['climb_name', 'is_mirror', 'angle'], keep='first')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

full_logbook_df['is_repeat'] = full_logbook_df.duplicated(subset=['climb_uuid', 'climb_name', 'is_mirror', 'angle'], keep='first')


# Sort the DataFrame by date
full_logbook_df = full_logbook_df.sort_values(by='date')

return full_logbook_df