Compare commits
27 Commits
d79a6be274
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 326934ca9b | |||
| 077dc25a5d | |||
| 69fe72512c | |||
| cefa490f5f | |||
| 28271623be | |||
| 5f350f1884 | |||
| ff54e2b053 | |||
| 244bed2c51 | |||
| 7dea88a1b3 | |||
| dd2ffced4c | |||
| 0427dbe6bd | |||
| 87477820ec | |||
| ff51dd7f8b | |||
| dc7e5abf4e | |||
| 228e0043d5 | |||
| 7d57f00e5d | |||
| 0090862359 | |||
| 3d17b6ef75 | |||
| 0dd78f1507 | |||
| c689fcb4e7 | |||
| 2f7b0af20c | |||
| f5bf263635 | |||
| e94d0a7714 | |||
| feeaba2c65 | |||
| 48d42c2a4b | |||
| 4b4dfa69fd | |||
| 1d865e605c |
@@ -2,9 +2,10 @@
|
||||
|
||||
docker build -t wordlinator:latest .
|
||||
|
||||
if [ "$1" = "--debug" ]; then
|
||||
if [ "$1" == "--debug" ]; then
|
||||
shift
|
||||
docker run -d --rm -p 8050:8050 -e DEBUG=true -e DB_PORT -e DB_HOST -e DB_PASS "$@" --name wordlinator wordlinator:latest
|
||||
echo "Running debug mode..."
|
||||
docker run -d --rm -p 8050:8050 -e DASH_DEBUG=true -e DB_PORT -e DB_HOST -e DB_PASS "$@" --name wordlinator wordlinator:latest
|
||||
else
|
||||
docker run -d --rm -p 8050:8050 -e DB_PORT -e DB_HOST -e DB_PASS "$@" --name wordlinator wordlinator:latest
|
||||
fi
|
||||
|
||||
@@ -36,6 +36,10 @@ show-user = "wordlinator.app:sync_show_user"
|
||||
show-missing = "wordlinator.app:sync_show_missing"
|
||||
tweet-missing = "wordlinator.app:sync_tweet_missing"
|
||||
db-load = "wordlinator.app:load_db_scores"
|
||||
add-user = "wordlinator.app:sync_add_user"
|
||||
create-round = "wordlinator.app:create_round"
|
||||
copy-users = "wordlinator.app:copy_users"
|
||||
gs-user-sync = "wordlinator.app:sync_gsheet_users"
|
||||
|
||||
[tool.mypy]
|
||||
ignore_missing_imports = true
|
||||
|
||||
@@ -9,6 +9,7 @@ import wordlinator.db.pg
|
||||
import wordlinator.sheets
|
||||
import wordlinator.twitter
|
||||
import wordlinator.utils
|
||||
import wordlinator.utils.scores
|
||||
|
||||
|
||||
async def get_scores(
|
||||
@@ -70,8 +71,9 @@ def print_score_table(wordle_day, scores):
|
||||
|
||||
|
||||
def _save_db_scores(
|
||||
wordle_day: wordlinator.utils.WordleDay, scores: dict, twitter_scores
|
||||
wordle_day: wordlinator.utils.WordleDay, scores: dict, twitter_scores=None
|
||||
):
|
||||
twitter_scores = twitter_scores or {}
|
||||
db = wordlinator.db.pg.WordleDb()
|
||||
hole_data = wordle_day.golf_hole
|
||||
if not hole_data:
|
||||
@@ -79,52 +81,31 @@ def _save_db_scores(
|
||||
game_no = hole_data.game_no
|
||||
|
||||
db_users = db.get_users()
|
||||
db_holes = db.get_holes(game_no)
|
||||
db_holes = db.get_holes(game_no, ensure_all=True)
|
||||
db_scores = db.get_scores(game_no)
|
||||
|
||||
db_scores_by_user = wordlinator.utils.scores.ScoreMatrix(db_scores).by_user(
|
||||
usernames=list(scores.keys())
|
||||
)
|
||||
|
||||
to_update = []
|
||||
to_create = []
|
||||
|
||||
for user, score_list in scores.items():
|
||||
db_user_match = [u for u in db_users if u.username == user]
|
||||
db_user = db_user_match[0] if db_user_match else None
|
||||
|
||||
if not db_user:
|
||||
db_user_scores = db_scores_by_user.get(user)
|
||||
if not db_user_scores:
|
||||
continue
|
||||
|
||||
for day, score_entry in enumerate(score_list, start=1):
|
||||
try:
|
||||
score_entry = int(score_entry)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
score_match = [
|
||||
s
|
||||
for s in db_scores
|
||||
if s.user_id.username == user and s.hole_id.hole == day
|
||||
]
|
||||
db_score = score_match[0] if score_match else None
|
||||
|
||||
if db_score:
|
||||
if db_score.score != score_entry:
|
||||
db_score.score = score_entry
|
||||
to_update.append(db_score)
|
||||
|
||||
else:
|
||||
hole_match = [h for h in db_holes if h.hole == day]
|
||||
if hole_match:
|
||||
hole = hole_match[0]
|
||||
else:
|
||||
hole = db.get_or_create_hole(game_no, day)
|
||||
db_holes.append(hole)
|
||||
to_create.append(
|
||||
{
|
||||
"score": score_entry,
|
||||
"user_id": db_user.user_id,
|
||||
"game_id": hole.game_id.game_id,
|
||||
"hole_id": hole.hole_id,
|
||||
}
|
||||
)
|
||||
db_user_match = [u for u in db_users if u.username == user]
|
||||
if not db_user_match:
|
||||
rich.print(f"[yellow]User {user} not in database, cannot add scores.")
|
||||
continue
|
||||
db_user = db_user_match[0]
|
||||
twitter_score = twitter_scores.get(user, None)
|
||||
changes = db_user_scores.get_changes(
|
||||
score_list, twitter_score, db_user, db_holes
|
||||
)
|
||||
to_update.extend(changes["update"])
|
||||
to_create.extend(changes["create"])
|
||||
|
||||
if to_update:
|
||||
db.bulk_update_scores(to_update)
|
||||
@@ -137,6 +118,9 @@ def _save_db_scores(
|
||||
async def main_update(
|
||||
wordle_day: wordlinator.utils.WordleDay = wordlinator.utils.WORDLE_TODAY,
|
||||
):
|
||||
if not wordle_day.golf_hole:
|
||||
rich.print(f"[yellow]{wordle_day.date} isn't a #WordleGolf day!")
|
||||
exit()
|
||||
sheets_client = wordlinator.sheets.SheetsClient(wordle_day=wordle_day)
|
||||
|
||||
today_scores = await get_scores(wordle_day=wordle_day)
|
||||
@@ -188,6 +172,11 @@ def _get_day():
|
||||
days.add_argument(
|
||||
"--wordle-day", type=int, help="The wordle day number for the score report."
|
||||
)
|
||||
days.add_argument(
|
||||
"--date",
|
||||
type=wordlinator.utils.date_from_string,
|
||||
help="a YYYY-MM-DD format date to pull a score report.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
wordle_day = wordlinator.utils.WORDLE_TODAY
|
||||
if args.wordle_day:
|
||||
@@ -196,6 +185,8 @@ def _get_day():
|
||||
wordle_day = wordlinator.utils.WordleDay.from_wordle_no(
|
||||
wordle_day.wordle_no - args.days_ago
|
||||
)
|
||||
elif args.date:
|
||||
wordle_day = wordlinator.utils.WordleDay.from_date(args.date)
|
||||
return wordle_day
|
||||
|
||||
|
||||
@@ -206,6 +197,115 @@ def load_db_scores():
|
||||
_save_db_scores(wordle_day, scores)
|
||||
|
||||
|
||||
def _add_user_args():
|
||||
parser = argparse.ArgumentParser("add-user")
|
||||
parser.add_argument("username", help="The user twitter handle or name.")
|
||||
parser.add_argument(
|
||||
"--no-check-twitter",
|
||||
dest="check_twitter",
|
||||
action="store_false",
|
||||
default=True,
|
||||
help="don't check Twitter for user's scores",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-g", "--games", nargs="*", help="The game/round number(s) to enroll the user."
|
||||
)
|
||||
parser.add_argument(
|
||||
"-u",
|
||||
"--unenroll-games",
|
||||
nargs="*",
|
||||
help="Game/round number(s) to unenroll the user.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
|
||||
|
||||
async def add_user(username, games=None, unenroll_games=None, check_twitter=True):
|
||||
db = wordlinator.db.pg.WordleDb()
|
||||
|
||||
user = db.get_user(username)
|
||||
if not user:
|
||||
rich.print(f"[green]Creating user {username}")
|
||||
user_id = None
|
||||
if check_twitter:
|
||||
twitter = wordlinator.twitter.TwitterClient()
|
||||
user_id = await twitter.get_user_twitter_id(username)
|
||||
if not user_id:
|
||||
check_twitter = False
|
||||
rich.print(
|
||||
f"[yellow]No twitter ID found for {username}, "
|
||||
"disabling twitter check"
|
||||
)
|
||||
user_id = user_id or f"{username}-NA"
|
||||
user = db.add_user(username, user_id, check_twitter=check_twitter)
|
||||
for round in games or []:
|
||||
rich.print(f"[green]Adding {username} to round {round}")
|
||||
db.add_user_to_round(username, round)
|
||||
for round in unenroll_games or []:
|
||||
rich.print(f"[green]Removing {username} from round {round}")
|
||||
db.remove_user_from_round(username, round)
|
||||
|
||||
|
||||
def create_round():
|
||||
parser = argparse.ArgumentParser("create-round")
|
||||
parser.add_argument("round_no", type=int, help="The round number to create.")
|
||||
parser.add_argument(
|
||||
"start_date",
|
||||
type=wordlinator.utils.date_from_string,
|
||||
help="the YYYY-mm-DD format date the round starts.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
db = wordlinator.db.pg.WordleDb()
|
||||
db.get_or_create_round(args.round_no, args.start_date)
|
||||
db.create_round_holes(args.round_no)
|
||||
|
||||
|
||||
def copy_users():
|
||||
parser = argparse.ArgumentParser("copy-users")
|
||||
parser.add_argument("from_round", type=int, help="The source round number.")
|
||||
parser.add_argument("to_round", type=int, help="The destination round.")
|
||||
args = parser.parse_args()
|
||||
|
||||
wordlinator.db.pg.WordleDb().copy_players_from_round(args.from_round, args.to_round)
|
||||
|
||||
|
||||
async def pull_gsheets_users(round_no):
|
||||
db = wordlinator.db.pg.WordleDb()
|
||||
db_users = db.get_users_by_round(round_no=round_no)
|
||||
|
||||
round = db.get_or_create_round(round_no)
|
||||
wordle_day = wordlinator.utils.WordleDay.from_date(round.end_date)
|
||||
|
||||
sheets = wordlinator.sheets.SheetsClient(wordle_day=wordle_day)
|
||||
sheets_users = sheets.get_users()
|
||||
|
||||
for user in sheets_users:
|
||||
db_match = [u for u in db_users if u.username == user]
|
||||
if not db_match:
|
||||
rich.print(f"[yellow]Adding {user} to Round {round_no}")
|
||||
await add_user(user, games=[round_no])
|
||||
|
||||
for user in db_users:
|
||||
if user.username not in sheets_users:
|
||||
rich.print(f"[yellow]Removing {user.username} from Round {round_no}")
|
||||
db.remove_user_from_round(user.username, round_no)
|
||||
|
||||
|
||||
def sync_gsheet_users():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("round_no", help="The round to sync.")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(pull_gsheets_users(args.round_no))
|
||||
|
||||
|
||||
def sync_add_user():
|
||||
args = _add_user_args()
|
||||
asyncio.run(
|
||||
add_user(args.username, args.games, args.unenroll_games, args.check_twitter)
|
||||
)
|
||||
|
||||
|
||||
def sync_main():
|
||||
wordle_day = _get_day()
|
||||
asyncio.run(main(wordle_day=wordle_day))
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import datetime
|
||||
import os
|
||||
import typing
|
||||
|
||||
import peewee
|
||||
|
||||
import wordlinator.utils
|
||||
|
||||
db = peewee.PostgresqlDatabase(
|
||||
os.getenv("DB_NAME", "wordlegolf"),
|
||||
user=os.getenv("DB_USER", "wordlegolf"),
|
||||
@@ -25,6 +24,9 @@ class User(BaseModel):
|
||||
twitter_id = peewee.CharField(unique=True)
|
||||
check_twitter = peewee.BooleanField(default=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User {self.username}, Check Twitter: {self.check_twitter}>"
|
||||
|
||||
class Meta:
|
||||
table_name = "user_tbl"
|
||||
|
||||
@@ -34,17 +36,30 @@ class Game(BaseModel):
|
||||
game = peewee.IntegerField(null=False)
|
||||
start_date = peewee.DateField(null=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Game: Round {self.game}, Start {self.start_date}>"
|
||||
|
||||
@property
|
||||
def end_date(self):
|
||||
return self.start_date + datetime.timedelta(days=17)
|
||||
|
||||
|
||||
class Player(BaseModel):
|
||||
user_id = peewee.ForeignKeyField(User, "user_id", null=False)
|
||||
game_id = peewee.ForeignKeyField(Game, "game_id", null=False)
|
||||
|
||||
class Meta:
|
||||
primary_key = peewee.CompositeKey("user_id", "game_id")
|
||||
|
||||
|
||||
class Hole(BaseModel):
|
||||
hole_id = peewee.AutoField()
|
||||
hole = peewee.IntegerField(null=False)
|
||||
game_id = peewee.ForeignKeyField(Game, "game_id", null=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Hole #{self.hole}>"
|
||||
|
||||
|
||||
class Score(BaseModel):
|
||||
score = peewee.IntegerField(null=False)
|
||||
@@ -67,92 +82,175 @@ class WordleDb:
|
||||
def get_users(self):
|
||||
return list(User.select())
|
||||
|
||||
def get_user_id(self, username):
|
||||
user = self.get_user(username)
|
||||
return user.twitter_id if user else None
|
||||
def get_users_by_round(self, round_no=None, round_id=None):
|
||||
with db.atomic():
|
||||
query = (
|
||||
User.select(User, Player.user_id, Game.game)
|
||||
.join(Player, on=(Player.user_id == User.user_id))
|
||||
.join(Game, on=(Game.game_id == Player.game_id))
|
||||
)
|
||||
if round_no:
|
||||
query = query.filter(Game.game == round_no)
|
||||
elif round_id:
|
||||
query = query.filter(Game.game_id == round_id)
|
||||
return list(query)
|
||||
|
||||
def add_user(self, username, user_id):
|
||||
return User.create(username=username, twitter_id=user_id)
|
||||
def get_user_id(self, username):
|
||||
with db.atomic():
|
||||
user = self.get_user(username)
|
||||
return user.twitter_id if user else None
|
||||
|
||||
def add_user(self, username, user_id, check_twitter=True):
|
||||
with db.atomic():
|
||||
return User.create(
|
||||
username=username, twitter_id=user_id, check_twitter=check_twitter
|
||||
)
|
||||
|
||||
def get_rounds(self):
|
||||
with db.atomic():
|
||||
return list(sorted(Game.select(), key=lambda d: d.start_date))
|
||||
|
||||
def get_or_create_round(self, round_no, start_date=None):
|
||||
try:
|
||||
return Game.get(Game.game == round_no)
|
||||
except peewee.DoesNotExist:
|
||||
start_date = (
|
||||
start_date or wordlinator.utils.WORDLE_GOLF_ROUND_DATES[round_no - 1]
|
||||
)
|
||||
return Game.create(game=round_no, start_date=start_date)
|
||||
with db.atomic():
|
||||
try:
|
||||
return Game.get(Game.game == round_no)
|
||||
except peewee.DoesNotExist:
|
||||
if not start_date:
|
||||
raise ValueError(
|
||||
f"Round {round_no} does not exist, "
|
||||
"and no start_date provide to create it"
|
||||
)
|
||||
return Game.create(game=round_no, start_date=start_date)
|
||||
|
||||
def get_or_create_hole(self, round_no, hole_no):
|
||||
round = self.get_or_create_round(round_no)
|
||||
with db.atomic():
|
||||
round = self.get_or_create_round(round_no)
|
||||
|
||||
try:
|
||||
return Hole.get(Hole.hole == hole_no, Hole.game_id == round.game_id)
|
||||
except peewee.DoesNotExist:
|
||||
return Hole.create(hole=hole_no, game_id=round.game_id)
|
||||
try:
|
||||
return Hole.get(Hole.hole == hole_no, Hole.game_id == round.game_id)
|
||||
except peewee.DoesNotExist:
|
||||
return Hole.create(hole=hole_no, game_id=round.game_id)
|
||||
|
||||
def get_holes(self, round_no):
|
||||
round = self.get_or_create_round(round_no)
|
||||
return list(Hole.select().filter(game_id=round.game_id))
|
||||
def get_holes(self, round_no, ensure_all=False):
|
||||
with db.atomic():
|
||||
round = self.get_or_create_round(round_no)
|
||||
if ensure_all:
|
||||
self.create_round_holes(round_no)
|
||||
return list(Hole.select().filter(game_id=round.game_id))
|
||||
|
||||
def create_round_holes(self, round_no):
|
||||
for hole_no in range(1, 19):
|
||||
self.get_or_create_hole(round_no, hole_no)
|
||||
with db.atomic():
|
||||
for hole_no in range(1, 19):
|
||||
self.get_or_create_hole(round_no, hole_no)
|
||||
|
||||
def get_or_create_player_round(self, user_id, game_id):
|
||||
with db.atomic():
|
||||
try:
|
||||
return Player.get(Player.user_id == user_id, Player.game_id == game_id)
|
||||
except peewee.DoesNotExist:
|
||||
return Player.create(user_id=user_id, game_id=game_id)
|
||||
|
||||
def add_user_to_round(self, username, round_no):
|
||||
with db.atomic():
|
||||
user = self.get_user(username)
|
||||
if not user:
|
||||
raise ValueError(f"No user found with username {username}")
|
||||
round = self.get_or_create_round(round_no)
|
||||
return self.get_or_create_player_round(user.user_id, round.game_id)
|
||||
|
||||
def remove_user_from_round(self, username, round_no):
|
||||
with db.atomic():
|
||||
user = self.get_user(username)
|
||||
if not user:
|
||||
raise ValueError(f"No user found with username {username}")
|
||||
round = self.get_or_create_round(round_no)
|
||||
try:
|
||||
player = Player.get(user_id=user.user_id, game_id=round.game_id)
|
||||
player.delete_instance()
|
||||
except peewee.DoesNotExist:
|
||||
return
|
||||
|
||||
def copy_players_from_round(self, from_round_no, to_round_no):
|
||||
with db.atomic():
|
||||
to_round = self.get_or_create_round(to_round_no)
|
||||
|
||||
for user in self.get_users_by_round(from_round_no):
|
||||
self.get_or_create_player_round(user.user_id, to_round.game_id)
|
||||
|
||||
def add_score(self, username, game, hole, score):
|
||||
if not score:
|
||||
return
|
||||
with db.atomic():
|
||||
if not score:
|
||||
return
|
||||
|
||||
user = self.get_user(username)
|
||||
if not user:
|
||||
raise ValueError(f'No Such User "{username}"')
|
||||
hole = self.get_or_create_hole(game, hole)
|
||||
user = self.get_user(username)
|
||||
if not user:
|
||||
raise ValueError(f'No Such User "{username}"')
|
||||
hole = self.get_or_create_hole(game, hole)
|
||||
|
||||
try:
|
||||
score_obj = Score.get(
|
||||
Score.user_id == user.user_id,
|
||||
Score.game_id == hole.game_id,
|
||||
Score.hole_id == hole.hole_id,
|
||||
)
|
||||
score_obj.score = score
|
||||
score_obj.save()
|
||||
return score_obj
|
||||
except peewee.DoesNotExist:
|
||||
return Score.create(
|
||||
score=score,
|
||||
user_id=user.user_id,
|
||||
game_id=hole.game_id,
|
||||
hole_id=hole.hole_id,
|
||||
)
|
||||
try:
|
||||
score_obj = Score.get(
|
||||
Score.user_id == user.user_id,
|
||||
Score.game_id == hole.game_id,
|
||||
Score.hole_id == hole.hole_id,
|
||||
)
|
||||
score_obj.score = score
|
||||
score_obj.save()
|
||||
return score_obj
|
||||
except peewee.DoesNotExist:
|
||||
return Score.create(
|
||||
score=score,
|
||||
user_id=user.user_id,
|
||||
game_id=hole.game_id,
|
||||
hole_id=hole.hole_id,
|
||||
)
|
||||
|
||||
def get_scores(self, round_no):
|
||||
round = self.get_or_create_round(round_no)
|
||||
res = (
|
||||
Score.select(
|
||||
Score,
|
||||
Hole.hole,
|
||||
User.username,
|
||||
Player.game_id,
|
||||
def get_scores(self, round_no=None, round_id=None):
|
||||
with db.atomic():
|
||||
if round_no:
|
||||
round = self.get_or_create_round(round_no)
|
||||
elif round_id:
|
||||
round = Game.get_by_id(round_id)
|
||||
else:
|
||||
raise ValueError("Must provide Round Number or Round ID")
|
||||
res = (
|
||||
Score.select(
|
||||
Score,
|
||||
Hole.hole,
|
||||
Hole.hole_id,
|
||||
Game.game_id,
|
||||
User.username,
|
||||
User.user_id,
|
||||
Player.game_id,
|
||||
)
|
||||
.join(Player, on=(Score.user_id == Player.user_id))
|
||||
.switch(Score)
|
||||
.join(Hole, on=(Score.hole_id == Hole.hole_id))
|
||||
.join(Game, on=(Hole.game_id == Game.game_id))
|
||||
.switch(Score)
|
||||
.join(User, on=(Score.user_id == User.user_id))
|
||||
.filter(Player.game_id == round.game_id)
|
||||
.filter(Score.game_id == round.game_id)
|
||||
)
|
||||
.join(Player, on=(Score.user_id == Player.user_id))
|
||||
.switch(Score)
|
||||
.join(Hole, on=(Score.hole_id == Hole.hole_id))
|
||||
.switch(Score)
|
||||
.join(User, on=(Score.user_id == User.user_id))
|
||||
.filter(Player.game_id == round.game_id)
|
||||
.filter(Score.game_id == round.game_id)
|
||||
)
|
||||
return list(res)
|
||||
return list(res) if res else []
|
||||
|
||||
def bulk_insert_scores(self, scores: typing.List[typing.Dict]):
|
||||
with db.atomic():
|
||||
with db.atomic() as txn:
|
||||
for batch in peewee.chunked(scores, 50):
|
||||
Score.insert_many(batch).execute()
|
||||
|
||||
def bulk_update_scores(self, scores: typing.List[Score]):
|
||||
with db.atomic():
|
||||
for score in scores:
|
||||
score.save()
|
||||
query_str = """UPDATE score
|
||||
SET score = {score}, tweet_id = {tweet_id}
|
||||
WHERE user_id = {user_id} AND game_id = {game_id} AND hole_id = {hole_id}"""
|
||||
for score in scores:
|
||||
query = query_str.format(
|
||||
score=score.score,
|
||||
tweet_id=score.tweet_id or "NULL",
|
||||
user_id=score.user_id.user_id,
|
||||
game_id=score.game_id.game_id,
|
||||
hole_id=score.hole_id.hole_id,
|
||||
)
|
||||
db.execute_sql(query)
|
||||
|
||||
def get_users_without_score(self, round_no, hole_no, tweetable=True):
|
||||
hole = self.get_or_create_hole(round_no, hole_no)
|
||||
|
||||
@@ -17,7 +17,15 @@ import wordlinator.utils
|
||||
|
||||
BASE_URL = "https://api.twitter.com/2"
|
||||
WORDLE_RE = re.compile(
|
||||
r"Wordle(\w+)? (?P<number>\d+) (?P<score>[X\d])/6", re.IGNORECASE
|
||||
r"""
|
||||
Wordle # Line Leader
|
||||
(\w+)? # etta factor (catches ...Golf, etc)
|
||||
\s # single space
|
||||
(?P<number>\d+) # game number
|
||||
\s # single space
|
||||
(?P<score>[X\d])/6 # score out of 6
|
||||
""",
|
||||
re.IGNORECASE | re.VERBOSE,
|
||||
)
|
||||
TOKEN = os.getenv("TWITTER_TOKEN")
|
||||
|
||||
@@ -133,17 +141,22 @@ class TwitterClient(httpx.AsyncClient):
|
||||
async def get_user_by(self, username: str):
|
||||
return await self.get(self.USER_PATH.format(username=username))
|
||||
|
||||
async def get_user_twitter_id(self, username: str):
|
||||
user_id = None
|
||||
twitter_user = await self.get_user_by(username)
|
||||
if twitter_user.is_success:
|
||||
user_id = twitter_user.json().get("data", {}).get("id", None)
|
||||
return user_id
|
||||
|
||||
async def get_user_id(self, username: str):
|
||||
db_user = self.db.get_user(username)
|
||||
if db_user:
|
||||
return db_user.twitter_id if db_user.check_twitter else False
|
||||
else:
|
||||
twitter_user = await self.get_user_by(username)
|
||||
user_id = None
|
||||
if twitter_user.is_success:
|
||||
user_id = twitter_user.json().get("data", {}).get("id", None)
|
||||
user_id = await self.get_user_twitter_id(username)
|
||||
if user_id:
|
||||
self.db.add_user(username, user_id)
|
||||
self.db.add_user_to_round(username, self.wordle_day.golf_hole.game_no)
|
||||
return user_id
|
||||
|
||||
def _start_timestamp(self):
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import argparse
|
||||
import dataclasses
|
||||
import datetime
|
||||
import typing
|
||||
|
||||
import wordlinator.db.pg
|
||||
|
||||
WORDLE_DAY_ZERO = datetime.date(2021, 6, 19)
|
||||
|
||||
WORDLE_GOLF_ROUND_DATES = [datetime.date(2022, 5, 9), datetime.date(2022, 5, 31)]
|
||||
WORDLE_GOLF_ROUNDS = wordlinator.db.pg.WordleDb().get_rounds()
|
||||
|
||||
|
||||
def date_from_string(datestr: str):
|
||||
try:
|
||||
return datetime.date.fromisoformat(datestr)
|
||||
except ValueError:
|
||||
msg = "Invalid date string, expected format: YYYY-mm-DD"
|
||||
raise argparse.ArgumentTypeError(msg)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
@@ -14,10 +25,10 @@ class GolfHole:
|
||||
|
||||
@classmethod
|
||||
def from_date(cls, date: datetime.date):
|
||||
for game_no, start_date in enumerate(WORDLE_GOLF_ROUND_DATES, start=1):
|
||||
hole_value = (date - start_date).days + 1
|
||||
if 1 <= hole_value <= 18:
|
||||
return cls(game_no=game_no, hole_no=hole_value)
|
||||
for round in WORDLE_GOLF_ROUNDS:
|
||||
if round.start_date <= date <= round.end_date:
|
||||
hole_no = (date - round.start_date).days + 1
|
||||
return cls(game_no=round.game, hole_no=hole_no)
|
||||
return None
|
||||
|
||||
|
||||
@@ -46,13 +57,17 @@ class WordleDay:
|
||||
|
||||
# Designed so that "today" will be the current date in CST
|
||||
# Regardless of where the code is run
|
||||
def get_wordle_today():
|
||||
def get_today_central():
|
||||
today = (
|
||||
datetime.datetime.now(datetime.timezone.utc)
|
||||
.astimezone(datetime.timezone(datetime.timedelta(hours=-5), name="US Central"))
|
||||
.date()
|
||||
)
|
||||
return WordleDay.from_date(today)
|
||||
return today
|
||||
|
||||
|
||||
def get_wordle_today():
|
||||
return WordleDay.from_date(get_today_central())
|
||||
|
||||
|
||||
WORDLE_TODAY = get_wordle_today()
|
||||
|
||||
220
wordlinator/utils/scores.py
Normal file
220
wordlinator/utils/scores.py
Normal file
@@ -0,0 +1,220 @@
|
||||
import collections
|
||||
import itertools
|
||||
import typing
|
||||
|
||||
import wordlinator.db.pg
|
||||
import wordlinator.twitter
|
||||
|
||||
############
|
||||
# Mappings #
|
||||
############
|
||||
|
||||
SCORE_NAME_MAP = {
|
||||
1: "Hole-in-1",
|
||||
2: "Eagle",
|
||||
3: "Birdie",
|
||||
4: "Par",
|
||||
5: "Bogey",
|
||||
6: "Double Bogey",
|
||||
7: "Fail",
|
||||
}
|
||||
|
||||
|
||||
###############
|
||||
# ScoreMatrix #
|
||||
###############
|
||||
|
||||
T = typing.TypeVar("T", bound="ScoreContainer")
|
||||
Score = wordlinator.db.pg.Score
|
||||
User = wordlinator.db.pg.User
|
||||
Hole = wordlinator.db.pg.Hole
|
||||
WordleTweet = wordlinator.twitter.WordleTweet
|
||||
|
||||
|
||||
class ScoreContainer:
|
||||
def __init__(self, scores: typing.List[Score]):
|
||||
self._scores = scores
|
||||
|
||||
@staticmethod
|
||||
def _get_attribute(score: Score, attribute_path: typing.List[str]):
|
||||
attribute = score
|
||||
for path_part in attribute_path:
|
||||
attribute = getattr(attribute, path_part)
|
||||
return attribute
|
||||
|
||||
def dict_by(
|
||||
self, attribute_path: str, container_class: typing.Type[T]
|
||||
) -> typing.Dict[typing.Any, T]:
|
||||
data_dict = collections.defaultdict(list)
|
||||
path_parts = attribute_path.split(".")
|
||||
|
||||
for score in self._scores:
|
||||
data_dict[self._get_attribute(score, path_parts)].append(score)
|
||||
|
||||
return {k: container_class(v) for k, v in data_dict.items()}
|
||||
|
||||
|
||||
class ScoreRow(ScoreContainer):
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return sum(s.score for s in self._scores)
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
return len(self._scores)
|
||||
|
||||
@property
|
||||
def average(self) -> float:
|
||||
return round(self.total / len(self._scores), 2)
|
||||
|
||||
|
||||
class UserRow(ScoreRow):
|
||||
def __init__(self, scores, username=None):
|
||||
super().__init__(scores)
|
||||
self.username = username or scores[0].user_id.username
|
||||
|
||||
@property
|
||||
def golf_score(self) -> int:
|
||||
return self.total - (self.count * 4)
|
||||
|
||||
@property
|
||||
def progressive_score_list(self) -> typing.List[int]:
|
||||
score_progress = list(
|
||||
itertools.accumulate(
|
||||
self.sorted_scores(), func=lambda t, e: t + (e.score - 4), initial=0
|
||||
)
|
||||
)[1:]
|
||||
return score_progress
|
||||
|
||||
def sorted_scores(self):
|
||||
yield from sorted(self._scores, key=lambda s: s.hole_id.hole)
|
||||
|
||||
def raw_values(self):
|
||||
yield from (s.score for s in self.sorted_scores())
|
||||
|
||||
def _present_format(self, score):
|
||||
if score.tweet_id:
|
||||
return (
|
||||
f"[{score.score}]"
|
||||
f"(https://twitter.com/{self.username}/status/{score.tweet_id})"
|
||||
)
|
||||
return score.score
|
||||
|
||||
def presentation_values(self, hole_no=None):
|
||||
res = {s.hole_id.hole: self._present_format(s) for s in self.sorted_scores()}
|
||||
if hole_no:
|
||||
for i in range(1, hole_no + 1):
|
||||
if i not in res:
|
||||
res[i] = ""
|
||||
return res
|
||||
|
||||
def user_row(self, hole_no=None):
|
||||
return {
|
||||
"Name": self.username,
|
||||
"Score": self.golf_score,
|
||||
**self.presentation_values(hole_no=hole_no),
|
||||
}
|
||||
|
||||
def get_changes(
|
||||
self,
|
||||
sheets_scores: typing.List[int],
|
||||
twitter_score: typing.Optional[WordleTweet],
|
||||
db_user: User,
|
||||
db_holes: typing.List[Hole],
|
||||
) -> typing.Dict[str, typing.List[Score]]:
|
||||
current_scores = list(self.sorted_scores())
|
||||
|
||||
results: typing.Dict[str, typing.List[typing.Any]] = {
|
||||
"update": [],
|
||||
"create": [],
|
||||
}
|
||||
|
||||
for day, score in enumerate(sheets_scores, start=1):
|
||||
try:
|
||||
score = int(score)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
hole = [h for h in db_holes if h.hole == day][0]
|
||||
score_match = [s for s in current_scores if s.hole_id.hole == day]
|
||||
|
||||
tweet_id = None
|
||||
if twitter_score and twitter_score.wordle_day.golf_hole.hole_no == day:
|
||||
tweet_id = twitter_score.tweet_id
|
||||
|
||||
if not score_match:
|
||||
results["create"].append(
|
||||
{
|
||||
"score": score,
|
||||
"user_id": db_user.user_id,
|
||||
"game_id": hole.game_id.game_id,
|
||||
"hole_id": hole.hole_id,
|
||||
"tweet_id": tweet_id,
|
||||
}
|
||||
)
|
||||
else:
|
||||
saved_score = score_match[0]
|
||||
if saved_score.score != score or (
|
||||
tweet_id and saved_score.tweet_id != tweet_id
|
||||
):
|
||||
saved_score.score = score
|
||||
if tweet_id:
|
||||
saved_score.tweet_id = tweet_id
|
||||
results["update"].append(saved_score)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class ScoreMatrix(ScoreContainer):
|
||||
def __init__(self, *args, usernames=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.usernames = usernames or []
|
||||
|
||||
def by_user(self, usernames: typing.List[str] = []):
|
||||
res = self.dict_by("user_id.username", UserRow)
|
||||
for username in usernames or self.usernames:
|
||||
if username not in res:
|
||||
res[username] = UserRow([], username)
|
||||
return res
|
||||
|
||||
def for_user(self, username):
|
||||
user_scores = [s for s in self._scores if s.user_id.username == username]
|
||||
return UserRow(scores=user_scores, username=username)
|
||||
|
||||
def by_hole(self):
|
||||
return self.dict_by("hole_id.hole", ScoreRow)
|
||||
|
||||
def for_hole(self, hole_no):
|
||||
hole_scores = [s for s in self._scores if s.hole_id.hole == hole_no]
|
||||
return ScoreRow(hole_scores)
|
||||
|
||||
def _level_counts(self, level_scores: "ScoreMatrix"):
|
||||
hole_dict = level_scores.by_hole()
|
||||
return {k: v.count for k, v in sorted(hole_dict.items())}
|
||||
|
||||
def score_breakdown(self):
|
||||
by_score_dict = self.dict_by("score", ScoreMatrix)
|
||||
return {
|
||||
SCORE_NAME_MAP[k]: self._level_counts(v)
|
||||
for k, v in sorted(by_score_dict.items())
|
||||
}
|
||||
|
||||
def user_rows(self, wordle_day):
|
||||
hole_no = wordle_day.golf_hole.hole_no
|
||||
return [u.user_row(hole_no=hole_no) for u in self.by_user().values()]
|
||||
|
||||
def top_by_day(self):
|
||||
user_dict = {u: r.progressive_score_list for u, r in self.by_user().items()}
|
||||
days = max(map(len, user_dict.values()))
|
||||
|
||||
rankings = collections.defaultdict(list)
|
||||
for day_idx in range(days):
|
||||
day_scores = {
|
||||
u: v[day_idx] for u, v in user_dict.items() if len(v) >= day_idx + 1
|
||||
}
|
||||
tops = [(u, v) for u, v in sorted(day_scores.items(), key=lambda t: t[1])][
|
||||
:20
|
||||
]
|
||||
for (user, score) in tops:
|
||||
rankings[user].append((day_idx + 1, score))
|
||||
return rankings
|
||||
@@ -1,137 +1,34 @@
|
||||
import collections
|
||||
import typing
|
||||
|
||||
import wordlinator.db.pg
|
||||
|
||||
############
|
||||
# Mappings #
|
||||
############
|
||||
|
||||
SCORE_NAME_MAP = {
|
||||
1: "Hole-in-1",
|
||||
2: "Eagle",
|
||||
3: "Birdie",
|
||||
4: "Par",
|
||||
5: "Bogey",
|
||||
6: "Double Bogey",
|
||||
7: "Fail",
|
||||
}
|
||||
|
||||
from dash import dcc
|
||||
|
||||
###############
|
||||
# ScoreMatrix #
|
||||
# Date Helper #
|
||||
###############
|
||||
|
||||
T = typing.TypeVar("T", bound="ScoreContainer")
|
||||
|
||||
def _date_range(game):
|
||||
return f"{game.start_date} to {game.end_date}"
|
||||
|
||||
|
||||
class ScoreContainer:
|
||||
def __init__(self, scores: typing.List[wordlinator.db.pg.Score]):
|
||||
self._scores = scores
|
||||
def get_date_dropdown(dates, wordle_day=None):
|
||||
options = [
|
||||
{"label": f"Round {d.game} ({_date_range(d)})", "value": d.game_id}
|
||||
for d in dates
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _get_attribute(
|
||||
score: wordlinator.db.pg.Score, attribute_path: typing.List[str]
|
||||
):
|
||||
attribute = score
|
||||
for path_part in attribute_path:
|
||||
attribute = getattr(attribute, path_part)
|
||||
return attribute
|
||||
value = dates[-1].game_id
|
||||
if wordle_day:
|
||||
if wordle_day.golf_hole:
|
||||
match = [d for d in dates if d.game == wordle_day.golf_hole.game_no]
|
||||
else:
|
||||
match = [d for d in dates[::-1] if d.end_date <= wordle_day.date]
|
||||
value = match[0].game_id
|
||||
|
||||
def dict_by(
|
||||
self, attribute_path: str, container_class: typing.Type[T]
|
||||
) -> typing.Dict[typing.Any, T]:
|
||||
data_dict = collections.defaultdict(list)
|
||||
path_parts = attribute_path.split(".")
|
||||
|
||||
for score in self._scores:
|
||||
data_dict[self._get_attribute(score, path_parts)].append(score)
|
||||
|
||||
return {k: container_class(v) for k, v in data_dict.items()}
|
||||
|
||||
|
||||
class ScoreRow(ScoreContainer):
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return sum(s.score for s in self._scores)
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
return len(self._scores)
|
||||
|
||||
@property
|
||||
def average(self) -> float:
|
||||
return round(self.total / len(self._scores), 2)
|
||||
|
||||
|
||||
class UserRow(ScoreRow):
|
||||
def __init__(self, scores, username=None):
|
||||
super().__init__(scores)
|
||||
self.username = username or scores[0].user_id.username
|
||||
|
||||
@property
|
||||
def golf_score(self) -> int:
|
||||
return self.total - (self.count * 4)
|
||||
|
||||
def sorted_scores(self):
|
||||
yield from sorted(self._scores, key=lambda s: s.hole_id.hole)
|
||||
|
||||
def raw_values(self):
|
||||
yield from (s.score for s in self.sorted_scores())
|
||||
|
||||
def _present_format(self, score):
|
||||
if score.tweet_id:
|
||||
return (
|
||||
f"[{score.score}]"
|
||||
f"(https://twitter.com/{self.username}/status/{score.tweet_id})"
|
||||
)
|
||||
return score.score
|
||||
|
||||
def presentation_values(self, hole_no=None):
|
||||
res = {s.hole_id.hole: self._present_format(s) for s in self.sorted_scores()}
|
||||
if hole_no:
|
||||
for i in range(1, hole_no + 1):
|
||||
if i not in res:
|
||||
res[i] = ""
|
||||
return res
|
||||
|
||||
def user_row(self, hole_no=None):
|
||||
return {
|
||||
"Name": self.username,
|
||||
"Score": self.golf_score,
|
||||
**self.presentation_values(hole_no=hole_no),
|
||||
}
|
||||
|
||||
|
||||
class ScoreMatrix(ScoreContainer):
|
||||
def by_user(self):
|
||||
return self.dict_by("user_id.username", UserRow)
|
||||
|
||||
def for_user(self, username):
|
||||
user_scores = [s for s in self._scores if s.user_id.username == username]
|
||||
return UserRow(scores=user_scores, username=username)
|
||||
|
||||
def by_hole(self):
|
||||
return self.dict_by("hole_id.hole", ScoreRow)
|
||||
|
||||
def for_hole(self, hole_no):
|
||||
hole_scores = [s for s in self._scores if s.hole_id.hole == hole_no]
|
||||
return ScoreRow(hole_scores)
|
||||
|
||||
def _level_counts(self, level_scores: "ScoreMatrix"):
|
||||
hole_dict = level_scores.by_hole()
|
||||
return {k: v.count for k, v in sorted(hole_dict.items())}
|
||||
|
||||
def score_breakdown(self):
|
||||
by_score_dict = self.dict_by("score", ScoreMatrix)
|
||||
return {
|
||||
SCORE_NAME_MAP[k]: self._level_counts(v)
|
||||
for k, v in sorted(by_score_dict.items())
|
||||
}
|
||||
|
||||
def user_rows(self, wordle_day):
|
||||
hole_no = wordle_day.golf_hole.hole_no
|
||||
return [u.user_row(hole_no=hole_no) for u in self.by_user().values()]
|
||||
return dcc.Dropdown(
|
||||
id="round-selector-dropdown",
|
||||
options=options,
|
||||
value=value,
|
||||
clearable=False,
|
||||
)
|
||||
|
||||
|
||||
######################
|
||||
@@ -151,6 +48,7 @@ def column_formats(col, pct):
|
||||
"width": f"{pct}%",
|
||||
"minWidth": f"{pct}%",
|
||||
},
|
||||
# Plain and markdown over-par
|
||||
{
|
||||
"if": {
|
||||
"column_id": col["id"],
|
||||
@@ -158,6 +56,28 @@ def column_formats(col, pct):
|
||||
},
|
||||
"backgroundColor": "red",
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"column_id": col["id"],
|
||||
"filter_query": format_string(col, 'contains "[5]"'),
|
||||
},
|
||||
"backgroundColor": "red",
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"column_id": col["id"],
|
||||
"filter_query": format_string(col, 'contains "[6]"'),
|
||||
},
|
||||
"backgroundColor": "red",
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"column_id": col["id"],
|
||||
"filter_query": format_string(col, 'contains "[7]"'),
|
||||
},
|
||||
"backgroundColor": "red",
|
||||
},
|
||||
# Plain and Markdown par
|
||||
{
|
||||
"if": {
|
||||
"column_id": col["id"],
|
||||
@@ -165,6 +85,14 @@ def column_formats(col, pct):
|
||||
},
|
||||
"backgroundColor": "orange",
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"column_id": col["id"],
|
||||
"filter_query": format_string(col, 'contains "[4]"'),
|
||||
},
|
||||
"backgroundColor": "orange",
|
||||
},
|
||||
# Plain and markdown under par
|
||||
{
|
||||
"if": {
|
||||
"column_id": col["id"],
|
||||
@@ -172,6 +100,28 @@ def column_formats(col, pct):
|
||||
},
|
||||
"backgroundColor": "green",
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"column_id": col["id"],
|
||||
"filter_query": format_string(col, 'contains "[3]"'),
|
||||
},
|
||||
"backgroundColor": "green",
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"column_id": col["id"],
|
||||
"filter_query": format_string(col, 'contains "[2]"'),
|
||||
},
|
||||
"backgroundColor": "green",
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"column_id": col["id"],
|
||||
"filter_query": format_string(col, 'contains "[1]"'),
|
||||
},
|
||||
"backgroundColor": "green",
|
||||
},
|
||||
# Plain no score
|
||||
{
|
||||
"if": {
|
||||
"column_id": col["id"],
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import datetime
|
||||
import collections
|
||||
import functools
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import time
|
||||
|
||||
import dash
|
||||
@@ -13,19 +16,27 @@ import plotly.graph_objs
|
||||
import wordlinator.db.pg as db
|
||||
import wordlinator.twitter
|
||||
import wordlinator.utils
|
||||
import wordlinator.utils.scores
|
||||
import wordlinator.utils.web
|
||||
|
||||
TTL_TIME = 30 if os.getenv("DEBUG") else 90
|
||||
LEADERBOARD_COUNT = 20
|
||||
VALUE_RE = re.compile(r"\[(?P<value>-?\d+)\]")
|
||||
|
||||
###################
|
||||
# Setup Functions #
|
||||
###################
|
||||
|
||||
assets_dir = pathlib.Path(__file__).parent / "assets"
|
||||
app = dash.Dash(
|
||||
name="WordleGolf", title="#WordleGolf", assets_folder=str(assets_dir.resolve())
|
||||
name="WordleGolf",
|
||||
title="#WordleGolf",
|
||||
assets_folder=str(assets_dir.resolve()),
|
||||
suppress_callback_exceptions=True,
|
||||
)
|
||||
|
||||
|
||||
def get_ttl_hash(seconds=600):
|
||||
def get_ttl_hash(seconds=TTL_TIME):
|
||||
return round(time.time() / seconds)
|
||||
|
||||
|
||||
@@ -35,31 +46,73 @@ long_callback_manager = dash.long_callback.DiskcacheLongCallbackManager(
|
||||
)
|
||||
|
||||
|
||||
@functools.lru_cache()
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _games_from_db(ttl_hash=None):
|
||||
return db.WordleDb().get_rounds()
|
||||
|
||||
|
||||
def games_from_db():
|
||||
return _games_from_db()
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _wordle_today(ttl_hash=None):
|
||||
today = wordlinator.utils.get_wordle_today()
|
||||
if today.golf_hole:
|
||||
return today
|
||||
last_completed_round = [
|
||||
dt for dt in wordlinator.utils.WORDLE_GOLF_ROUND_DATES[::-1] if dt <= today.date
|
||||
game for game in games_from_db()[::-1] if game.start_date <= today.date
|
||||
]
|
||||
last_round_start = last_completed_round[0]
|
||||
last_round_end = last_round_start + datetime.timedelta(days=17)
|
||||
return wordlinator.utils.WordleDay.from_date(last_round_end)
|
||||
last_round = last_completed_round[0]
|
||||
return wordlinator.utils.WordleDay.from_date(last_round.end_date)
|
||||
|
||||
|
||||
def wordle_today():
|
||||
return _wordle_today(get_ttl_hash())
|
||||
|
||||
|
||||
@functools.lru_cache()
|
||||
def _scores_from_db(ttl_hash=None):
|
||||
wordle_day = wordle_today()
|
||||
return db.WordleDb().get_scores(wordle_day.golf_hole.game_no)
|
||||
def round_wordle_day(round_id):
|
||||
wt = wordle_today()
|
||||
rounds = games_from_db()
|
||||
|
||||
matching_round = [r for r in rounds if r.game_id == round_id][0]
|
||||
if matching_round.game == wt.golf_hole.game_no:
|
||||
return wt
|
||||
return wordlinator.utils.WordleDay.from_date(matching_round.end_date)
|
||||
|
||||
|
||||
def scores_from_db():
|
||||
return wordlinator.utils.web.ScoreMatrix(_scores_from_db(get_ttl_hash()))
|
||||
@functools.lru_cache(maxsize=3)
|
||||
def _scores_from_db(round_id, ttl_hash=None):
|
||||
wordle_db = db.WordleDb()
|
||||
scores = wordle_db.get_scores(round_id=round_id)
|
||||
users = wordle_db.get_users_by_round(round_id=round_id)
|
||||
usernames = [u.username for u in users]
|
||||
return wordlinator.utils.scores.ScoreMatrix(scores, usernames=usernames)
|
||||
|
||||
|
||||
def scores_from_db(round_id):
|
||||
return _scores_from_db(round_id, get_ttl_hash())
|
||||
|
||||
|
||||
#######################
|
||||
# Leaderboard helpers #
|
||||
#######################
|
||||
|
||||
|
||||
def get_leaderboard(round_id):
|
||||
score_matrix = scores_from_db(round_id)
|
||||
user_scores = score_matrix.by_user()
|
||||
top_20 = dict(
|
||||
list(sorted(user_scores.items(), key=lambda u: u[1].golf_score))[
|
||||
:LEADERBOARD_COUNT
|
||||
]
|
||||
)
|
||||
return dash.dash_table.DataTable(
|
||||
[{"Name": k, "Score": v.golf_score} for k, v in top_20.items()],
|
||||
style_as_list_view=True,
|
||||
style_table={"width": "40%", "margin": "auto"},
|
||||
style_cell={"textAlign": "center"},
|
||||
)
|
||||
|
||||
|
||||
#################
|
||||
@@ -67,13 +120,20 @@ def scores_from_db():
|
||||
#################
|
||||
|
||||
|
||||
def get_scores():
|
||||
score_matrix = scores_from_db()
|
||||
table_rows = score_matrix.user_rows(wordle_today())
|
||||
def _get_scores(round_id):
|
||||
score_matrix = scores_from_db(round_id)
|
||||
round_day = round_wordle_day(round_id)
|
||||
table_rows = score_matrix.user_rows(round_day)
|
||||
return table_rows
|
||||
|
||||
|
||||
def get_scores(round_id):
|
||||
round_day = round_wordle_day(round_id)
|
||||
table_rows = _get_scores(round_id)
|
||||
|
||||
hole_columns = [
|
||||
{"name": f"{i}", "id": f"{i}", "type": "text", "presentation": "markdown"}
|
||||
for i in range(1, wordle_today().golf_hole.hole_no + 1)
|
||||
for i in range(1, round_day.golf_hole.hole_no + 1)
|
||||
]
|
||||
columns = [
|
||||
{"name": "Name", "id": "Name", "type": "text"},
|
||||
@@ -102,6 +162,7 @@ def get_scores():
|
||||
return dash.dash_table.DataTable(
|
||||
table_rows,
|
||||
columns,
|
||||
id="user-scores-table",
|
||||
style_table={
|
||||
"width": "80%",
|
||||
"margin": "auto",
|
||||
@@ -110,11 +171,14 @@ def get_scores():
|
||||
},
|
||||
fixed_rows={"headers": True, "data": 0},
|
||||
filter_action="native",
|
||||
filter_options={"case": "insensitive"},
|
||||
style_cell={"textAlign": "center"},
|
||||
style_data={"width": "10%"},
|
||||
style_as_list_view=True,
|
||||
style_data_conditional=formatting,
|
||||
sort_action="native",
|
||||
sort_action="custom",
|
||||
sort_mode="single",
|
||||
sort_by=[{"column_id": "Name", "direction": "asc"}],
|
||||
)
|
||||
|
||||
|
||||
@@ -139,21 +203,24 @@ def _get_summary_rows(score_matrix):
|
||||
return [totals, averages]
|
||||
|
||||
|
||||
def _stats_dict():
|
||||
score_matrix = scores_from_db()
|
||||
def _stats_dict(round_id):
|
||||
score_matrix = scores_from_db(round_id)
|
||||
table_rows = [{"Score": k, **v} for k, v in score_matrix.score_breakdown().items()]
|
||||
table_rows.extend(_get_summary_rows(score_matrix))
|
||||
return table_rows
|
||||
|
||||
|
||||
def get_daily_stats():
|
||||
table_rows = _stats_dict()
|
||||
def get_daily_stats(round_id):
|
||||
table_rows = _stats_dict(round_id)
|
||||
|
||||
columns = [
|
||||
{"name": n, "id": n}
|
||||
for n in (
|
||||
"Score",
|
||||
*[f"{i}" for i in range(1, wordle_today().golf_hole.hole_no + 1)],
|
||||
*[
|
||||
f"{i}"
|
||||
for i in range(1, round_wordle_day(round_id).golf_hole.hole_no + 1)
|
||||
],
|
||||
)
|
||||
]
|
||||
return dash.dash_table.DataTable(
|
||||
@@ -184,8 +251,8 @@ SCORE_COLOR_DICT = {
|
||||
}
|
||||
|
||||
|
||||
def get_line_graph():
|
||||
rows = _stats_dict()
|
||||
def get_line_graph(round_id):
|
||||
rows = _stats_dict(round_id)
|
||||
figure = plotly.graph_objs.Figure()
|
||||
total = [r for r in rows if r["Score"] == "Total"][0]
|
||||
rows = [r for r in rows if r["Score"] not in ("Total", "Daily Average")]
|
||||
@@ -214,6 +281,41 @@ def get_line_graph():
|
||||
return dash.dcc.Graph(figure=figure)
|
||||
|
||||
|
||||
#####################
|
||||
# Line Race Helpers #
|
||||
#####################
|
||||
|
||||
|
||||
def line_race_graph(round_id):
|
||||
score_matrix = scores_from_db(round_id)
|
||||
tops_by_day = score_matrix.top_by_day()
|
||||
round_day = round_wordle_day(round_id)
|
||||
hole_no = round_day.golf_hole.hole_no
|
||||
|
||||
figure = plotly.graph_objs.Figure()
|
||||
figure.update_yaxes(autorange="reversed")
|
||||
figure.update_xaxes(tickmode="linear", tick0=1, dtick=1)
|
||||
annotation_names = collections.defaultdict(list)
|
||||
for name, entries in tops_by_day.items():
|
||||
figure.add_trace(
|
||||
plotly.graph_objs.Scatter(
|
||||
name=name,
|
||||
mode="lines+markers",
|
||||
x=[e[0] for e in entries],
|
||||
y=[e[1] for e in entries],
|
||||
)
|
||||
)
|
||||
if entries[-1][0] == hole_no:
|
||||
annotation_names[entries[-1]].append(name)
|
||||
|
||||
annotations = [
|
||||
{"x": k[0], "y": k[1], "text": ", ".join(v)}
|
||||
for k, v in annotation_names.items()
|
||||
]
|
||||
figure.update_layout(annotations=annotations)
|
||||
return dash.dcc.Graph(figure=figure)
|
||||
|
||||
|
||||
#############
|
||||
# App Setup #
|
||||
#############
|
||||
@@ -222,52 +324,120 @@ app.layout = dash.html.Div(
|
||||
children=[
|
||||
dash.html.H1("#WordleGolf", style={"textAlign": "center"}, id="title"),
|
||||
dash.html.Div(
|
||||
[
|
||||
dash.html.H2("User Scores", style={"textAlign": "center"}),
|
||||
dash.html.Div("Loading...", id="user-scores"),
|
||||
]
|
||||
wordlinator.utils.web.get_date_dropdown(
|
||||
games_from_db(), wordle_day=wordle_today()
|
||||
),
|
||||
id="round-selector",
|
||||
style={"maxWidth": "300px"},
|
||||
),
|
||||
dash.html.Div(
|
||||
[
|
||||
dash.html.H2("Score Graph", style={"textAlign": "center"}),
|
||||
dash.html.Div("Loading...", id="stats-graph"),
|
||||
]
|
||||
),
|
||||
dash.html.Div(
|
||||
[
|
||||
dash.html.H2("Daily Stats", style={"textAlign": "center"}),
|
||||
dash.html.Div("Loading...", id="daily-stats"),
|
||||
]
|
||||
dash.dcc.Tabs(
|
||||
id="main-tabs",
|
||||
value="leaderboard",
|
||||
children=[
|
||||
dash.dcc.Tab(label="Leaderboard", value="leaderboard"),
|
||||
dash.dcc.Tab(label="Statistics", value="statistics"),
|
||||
dash.dcc.Tab(label="User Scores", value="user-scores"),
|
||||
],
|
||||
),
|
||||
dash.dcc.Loading(dash.html.Div(id="tab-content"), id="tab-content-loading"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@app.long_callback(
|
||||
output=dash.dependencies.Output("user-scores", "children"),
|
||||
inputs=dash.dependencies.Input("title", "children"),
|
||||
manager=long_callback_manager,
|
||||
@app.callback(
|
||||
dash.dependencies.Output("tab-content", "children"),
|
||||
[
|
||||
dash.dependencies.Input("main-tabs", "value"),
|
||||
dash.dependencies.Input("round-selector-dropdown", "value"),
|
||||
],
|
||||
)
|
||||
def get_scores_chart(_):
|
||||
return get_scores()
|
||||
def render_tab(tab, round_id):
|
||||
if tab == "leaderboard":
|
||||
return [
|
||||
dash.html.Div(
|
||||
[
|
||||
dash.html.H2(
|
||||
f"Leaderboard - Top {LEADERBOARD_COUNT}",
|
||||
style={"textAlign": "center"},
|
||||
),
|
||||
dash.dcc.Loading(
|
||||
id="leaderboard-race-loading",
|
||||
children=dash.html.Div(
|
||||
line_race_graph(round_id), id="leaderboard-race"
|
||||
),
|
||||
),
|
||||
dash.dcc.Loading(
|
||||
id="leaderboard-loading",
|
||||
children=dash.html.Div(
|
||||
get_leaderboard(round_id), id="leaderboard"
|
||||
),
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
elif tab == "user-scores":
|
||||
return [
|
||||
dash.html.Div(
|
||||
[
|
||||
dash.html.H2("User Scores", style={"textAlign": "center"}),
|
||||
dash.dcc.Loading(
|
||||
id="user-scores-loading",
|
||||
children=dash.html.Div(get_scores(round_id), id="user-scores"),
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
elif tab == "statistics":
|
||||
return [
|
||||
dash.html.Div(
|
||||
[
|
||||
dash.html.H2("Score Graph", style={"textAlign": "center"}),
|
||||
dash.dcc.Loading(
|
||||
id="stats-graph-loading",
|
||||
children=dash.html.Div(
|
||||
get_line_graph(round_id), id="stats-graph"
|
||||
),
|
||||
),
|
||||
]
|
||||
),
|
||||
dash.html.Div(
|
||||
[
|
||||
dash.html.H2("Daily Stats", style={"textAlign": "center"}),
|
||||
dash.dcc.Loading(
|
||||
id="daily-stats-loading",
|
||||
children=dash.html.Div(
|
||||
get_daily_stats(round_id), id="daily-stats"
|
||||
),
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@app.long_callback(
|
||||
output=dash.dependencies.Output("daily-stats", "children"),
|
||||
inputs=dash.dependencies.Input("title", "children"),
|
||||
manager=long_callback_manager,
|
||||
@app.callback(
|
||||
dash.dependencies.Output("user-scores-table", "data"),
|
||||
dash.dependencies.Input("user-scores-table", "sort_by"),
|
||||
dash.dependencies.State("user-scores-table", "data"),
|
||||
)
|
||||
def get_stats_chart(_):
|
||||
return get_daily_stats()
|
||||
def sort_scores(sort_by, data):
|
||||
if not sort_by:
|
||||
return data
|
||||
sort_by = sort_by[0]
|
||||
|
||||
def _sort_val(entry):
|
||||
col_id = sort_by["column_id"]
|
||||
raw_val = entry.get(col_id)
|
||||
if raw_val is None or raw_val == "":
|
||||
return math.inf
|
||||
if col_id == "Name":
|
||||
return raw_val
|
||||
if isinstance(raw_val, int) or raw_val.isdigit():
|
||||
return int(raw_val)
|
||||
match = VALUE_RE.match(raw_val).groupdict()["value"]
|
||||
return int(match)
|
||||
|
||||
@app.long_callback(
|
||||
output=dash.dependencies.Output("stats-graph", "children"),
|
||||
inputs=dash.dependencies.Input("title", "children"),
|
||||
manager=long_callback_manager,
|
||||
)
|
||||
def get_stats_graph(_):
|
||||
return get_line_graph()
|
||||
data = sorted(data, key=_sort_val, reverse=sort_by["direction"] == "desc")
|
||||
return data
|
||||
|
||||
|
||||
server = app.server
|
||||
|
||||
Reference in New Issue
Block a user