Compare commits

...

53 Commits

Author SHA1 Message Date
326934ca9b fix sorting 2022-07-26 15:31:07 -05:00
077dc25a5d Fix update logic 2022-07-05 15:57:34 -05:00
69fe72512c ensure added user is added to round 2022-06-29 13:28:57 -05:00
cefa490f5f use ttl hash on score pull 2022-06-28 08:25:26 -05:00
28271623be better leaderboard formatting 2022-06-27 15:25:00 -05:00
5f350f1884 only label lines of currently ranked 2022-06-27 15:22:15 -05:00
ff54e2b053 tabulated presentation 2022-06-27 15:17:58 -05:00
244bed2c51 ensure historical tables show full length 2022-06-27 15:07:55 -05:00
7dea88a1b3 add leaderboard race lines 2022-06-27 14:18:11 -05:00
dd2ffced4c drop txn wrapper on raw sql 2022-06-24 20:15:23 -05:00
0427dbe6bd source dates from DB 2022-06-24 14:45:28 -05:00
87477820ec fix bulk insert 2022-06-24 07:01:50 -05:00
ff51dd7f8b verbose regex for cleaner human-reading 2022-06-22 15:50:52 -05:00
dc7e5abf4e fix add user, add sync round users from gsheets 2022-06-22 10:12:40 -05:00
228e0043d5 fix debug in docker cmd 2022-06-21 18:33:09 -05:00
7d57f00e5d memory management and loading animations 2022-06-21 18:31:05 -05:00
0090862359 atomicity fixes 2022-06-21 18:30:54 -05:00
3d17b6ef75 handle upcoming game, fix player selection 2022-06-21 17:16:06 -05:00
0dd78f1507 new cli utils 2022-06-21 16:14:32 -05:00
c689fcb4e7 add leaderboard 2022-06-20 11:37:57 -05:00
2f7b0af20c add user management cli 2022-06-20 11:13:17 -05:00
f5bf263635 data load improvements
* support given date for pull
* support load for user score not in game db yet
2022-06-20 10:33:49 -05:00
e94d0a7714 add multi-round display with dropdown 2022-06-20 10:05:33 -05:00
feeaba2c65 Skip update on non wordle golf days 2022-06-17 17:03:52 -05:00
48d42c2a4b Update __init__.py 2022-06-14 20:36:02 -05:00
4b4dfa69fd add uncommitted file 2022-06-14 14:56:17 -05:00
1d865e605c store twitter links 2022-06-14 14:04:02 -05:00
d79a6be274 style markdown cells 2022-06-13 17:29:57 -05:00
d8240aeae0 speed up load and display logic 2022-06-13 17:19:47 -05:00
2df45a15f8 return tweet id from twitter 2022-06-13 15:16:29 -05:00
c1a13d71f7 add new db column 2022-06-13 15:15:51 -05:00
aa5d55124d filter score getter to active players 2022-06-10 09:00:34 -05:00
936f2e2fe3 span down columns for size 2022-06-08 17:23:51 -05:00
c4aac9740f refactor some utils, new columns, better missing user logic 2022-06-08 17:09:01 -05:00
cd714ae430 limit user list to current round players 2022-06-08 08:13:26 -05:00
46d0b2009e add tweet link web endpoint 2022-06-07 15:47:05 -05:00
475ef1c7bf better bulk update 2022-06-07 10:32:50 -05:00
49baa96bcc ensure web date always corresponds to a round 2022-06-07 08:55:31 -05:00
c4a1387460 db laoder fixes 2022-06-06 08:30:16 -05:00
98a9e2012a revised pull schedule 2022-06-06 08:16:19 -05:00
de747811d5 scrollable score list 2022-06-06 08:12:54 -05:00
9d0823721d skip db update for not-yet-entered score 2022-06-05 15:23:36 -05:00
dd4515fd5b batch update/create for db score updates 2022-06-05 14:42:38 -05:00
aa00260c17 refresh wordle day in cache 2022-06-05 14:02:53 -05:00
912a7aa6ae add token file to last pull workflow 2022-06-04 08:52:11 -05:00
f5006e629e Merge branch 'main' of github.com:bradsbrown/wordlinator 2022-06-04 08:30:24 -05:00
93ddf8359f Tweak run schedule 2022-06-03 21:57:45 -05:00
01ec0b54d3 title fix 2022-06-03 16:28:29 -05:00
5cd0d2b3e6 stop y axis at 100 2022-06-03 15:54:14 -05:00
c4b2e11f68 Add breakout graph and local nas-hosted deploy 2022-06-03 13:37:25 -05:00
6c703f616e Refactor for long callbacks on data collection 2022-06-03 11:41:23 -05:00
6577fe4396 set up docker image 2022-06-03 08:49:09 -05:00
1164e1e66c Better today calculation, corrected schedule 2022-06-03 08:19:18 -05:00
18 changed files with 1379 additions and 283 deletions

View File

@@ -2,7 +2,7 @@ name: Last Pull for Yesterday
on:
workflow_dispatch:
schedule:
- cron: '0 2 * * *'
- cron: '0 10 * * *'
env:
TWITTER_TOKEN: ${{ secrets.TWITTER_TOKEN }}
TWITTER_API_KEY: ${{ secrets.TWITTER_API_KEY }}
@@ -13,6 +13,7 @@ env:
DB_HOST: ${{ secrets.DB_HOST }}
DB_PASS: ${{ secrets.DB_PASS }}
DB_PORT: ${{ secrets.DB_PORT }}
TOKEN_FILE: ${{ secrets.TOKEN_FILE }}
jobs:
pull-updates:
runs-on: ubuntu-latest

View File

@@ -2,7 +2,8 @@ name: Update Scores
on:
workflow_dispatch:
schedule:
- cron: '0 4,8,10,16,18 * * *'
- cron: '0 11-23/2 * * *'
- cron: '0 0-4/2 * * *'
env:
TWITTER_TOKEN: ${{ secrets.TWITTER_TOKEN }}
TWITTER_API_KEY: ${{ secrets.TWITTER_API_KEY }}

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
token.json
wordle.db
cache/

17
Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM python:3.10
RUN apt update && apt install libpq-dev
COPY pyproject.toml pyproject.toml
COPY poetry.lock poetry.lock
RUN python -m pip install poetry && \
poetry config virtualenvs.create false && \
poetry install
COPY wordlinator/ wordlinator/
EXPOSE 8050
CMD gunicorn --workers 8 -b "0.0.0.0:8050" "wordlinator.web:server"

11
docker.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
docker build -t wordlinator:latest .
if [ "$1" == "--debug" ]; then
shift
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

4
docker_nas.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
DOCKER_CONTEXT=nas docker ps | grep wordlinator && DOCKER_CONTEXT=nas docker stop wordlinator || true
DOCKER_CONTEXT=nas DB_PORT=49420 DB_HOST="localhost" ./docker.sh "$@" --network=host

179
poetry.lock generated
View File

@@ -227,6 +227,25 @@ category = "dev"
optional = false
python-versions = ">=3.5"
[[package]]
name = "dill"
version = "0.3.5.1"
description = "serialize all of python"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*"
[package.extras]
graph = ["objgraph (>=1.7.2)"]
[[package]]
name = "diskcache"
version = "5.4.0"
description = "Disk Cache -- Disk and file backed persistent cache."
category = "main"
optional = false
python-versions = ">=3"
[[package]]
name = "executing"
version = "0.8.3"
@@ -565,6 +584,17 @@ category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "multiprocess"
version = "0.70.13"
description = "better multiprocessing and multithreading in python"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*"
[package.dependencies]
dill = ">=0.3.5.1"
[[package]]
name = "mypy"
version = "0.960"
@@ -694,7 +724,18 @@ optional = false
python-versions = ">=3.7"
[[package]]
name = "psycopg2-binary"
name = "psutil"
version = "5.9.1"
description = "Cross-platform lib for process and system monitoring in Python."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[package.extras]
test = ["ipaddress", "mock", "enum34", "pywin32", "wmi"]
[[package]]
name = "psycopg2"
version = "2.9.3"
description = "psycopg2 - Python-PostgreSQL Database Adapter"
category = "main"
@@ -979,7 +1020,7 @@ watchdog = ["watchdog"]
[metadata]
lock-version = "1.1"
python-versions = "^3.10"
content-hash = "dc9abe945b8d4f6344adf01af71c979dca1d0f9ad9def03496a9868823d885f8"
content-hash = "dfea9fcb2c9b4d8e4fc586969ffe408dc992dcb97f1fd2d37d1cac8404eea5fa"
[metadata.files]
anyio = [
@@ -1193,6 +1234,14 @@ decorator = [
{file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"},
{file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"},
]
dill = [
{file = "dill-0.3.5.1-py2.py3-none-any.whl", hash = "sha256:33501d03270bbe410c72639b350e941882a8b0fd55357580fbc873fba0c59302"},
{file = "dill-0.3.5.1.tar.gz", hash = "sha256:d75e41f3eff1eee599d738e76ba8f4ad98ea229db8b085318aa2b3333a208c86"},
]
diskcache = [
{file = "diskcache-5.4.0-py3-none-any.whl", hash = "sha256:af3ec6d7f167bbef7b6c33d9ee22f86d3e8f2dd7131eb7c4703d8d91ccdc0cc4"},
{file = "diskcache-5.4.0.tar.gz", hash = "sha256:8879eb8c9b4a2509a5e633d2008634fb2b0b35c2b36192d89655dbde02419644"},
]
executing = [
{file = "executing-0.8.3-py2.py3-none-any.whl", hash = "sha256:d1eef132db1b83649a3905ca6dd8897f71ac6f8cac79a7e58a1a09cf137546c9"},
{file = "executing-0.8.3.tar.gz", hash = "sha256:c6554e21c6b060590a6d3be4b82fb78f8f0194d809de5ea7df1c093763311501"},
@@ -1327,6 +1376,29 @@ mccabe = [
{file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
{file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
]
multiprocess = [
{file = "multiprocess-0.70.13-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:b9a3be43ecee6776a9e7223af96914a0164f306affcf4624b213885172236b77"},
{file = "multiprocess-0.70.13-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:7e6a689da3490412caa7b3e27c3385d8aaa49135f3a353ace94ca47e4c926d37"},
{file = "multiprocess-0.70.13-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:17cb4229aa43e6973679d67c66a454cbf8b6b0d038425cba3220ea5a06d61b58"},
{file = "multiprocess-0.70.13-cp27-cp27m-win32.whl", hash = "sha256:99bb68dd0d5b3d30fe104721bee26e4637667112d5951b51feb81479fd560876"},
{file = "multiprocess-0.70.13-cp27-cp27m-win_amd64.whl", hash = "sha256:6cdde49defcb933062df382ebc9b5299beebcd157a98b3a65291c1c94a2edc41"},
{file = "multiprocess-0.70.13-pp27-pypy_73-macosx_10_7_x86_64.whl", hash = "sha256:92003c247436f8699b7692e95346a238446710f078500eb364bc23bb0503dd4f"},
{file = "multiprocess-0.70.13-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:3ec1c8015e19182bfa01b5887a9c25805c48df3c71863f48fe83803147cde5d6"},
{file = "multiprocess-0.70.13-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b7415f61bddfffdade73396904551be8124a4a363322aa9c72d42e349c5fca39"},
{file = "multiprocess-0.70.13-pp37-pypy37_pp73-manylinux_2_24_i686.whl", hash = "sha256:5436d1cd9f901f7ddc4f20b6fd0b462c87dcc00d941cc13eeb2401fc5bd00e42"},
{file = "multiprocess-0.70.13-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:34e9703bd5b9fee5455c93a74e44dbabe55481c214d03be1e65f037be9d0c520"},
{file = "multiprocess-0.70.13-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:af0a48440aa8f793d8bb100f20102c12f192de5a608638819a998f2cc59e1fcd"},
{file = "multiprocess-0.70.13-pp38-pypy38_pp73-manylinux_2_24_i686.whl", hash = "sha256:c4a97216e8319039c69a266252cc68a392b96f9e67e3ed02ad88be9e6f2d2969"},
{file = "multiprocess-0.70.13-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:48315eefe02c35dd7560da3fa8af66d9f4a61b9dc8f7c40801c5f972ab4604b1"},
{file = "multiprocess-0.70.13-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5a6dca5f29f0224c855d0d5cad963476175cfc8de112d3eebe85914cb735f130"},
{file = "multiprocess-0.70.13-pp39-pypy39_pp73-manylinux_2_24_i686.whl", hash = "sha256:5974bdad390ba466cc130288d2ef1048fdafedd01cf4641fc024f6088af70bfe"},
{file = "multiprocess-0.70.13-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:01c1137d2f18d0cd262d0fdb7294b1fe9fc3e8dc8b126e506085434ae8eb3677"},
{file = "multiprocess-0.70.13-py310-none-any.whl", hash = "sha256:0f4faf4811019efdb2f91db09240f893ee40cbfcb06978f3b8ed8c248e73babe"},
{file = "multiprocess-0.70.13-py37-none-any.whl", hash = "sha256:62e556a0c31ec7176e28aa331663ac26c276ee3536b5e9bb5e850681e7a00f11"},
{file = "multiprocess-0.70.13-py38-none-any.whl", hash = "sha256:7be9e320a41d2d0d0eddacfe693cfb07b4cb9c0d3d10007f4304255c15215778"},
{file = "multiprocess-0.70.13-py39-none-any.whl", hash = "sha256:00ef48461d43d1e30f8f4b2e1b287ecaaffec325a37053beb5503e0d69e5a3cd"},
{file = "multiprocess-0.70.13.tar.gz", hash = "sha256:2e096dd618a84d15aa369a9cf6695815e5539f853dc8fa4f4b9153b11b1d0b32"},
]
mypy = [
{file = "mypy-0.960-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3a3e525cd76c2c4f90f1449fd034ba21fcca68050ff7c8397bb7dd25dd8b8248"},
{file = "mypy-0.960-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7a76dc4f91e92db119b1be293892df8379b08fd31795bb44e0ff84256d34c251"},
@@ -1417,63 +1489,52 @@ protobuf = [
{file = "protobuf-3.20.1-py2.py3-none-any.whl", hash = "sha256:adfc6cf69c7f8c50fd24c793964eef18f0ac321315439d94945820612849c388"},
{file = "protobuf-3.20.1.tar.gz", hash = "sha256:adc31566d027f45efe3f44eeb5b1f329da43891634d61c75a5944e9be6dd42c9"},
]
psycopg2-binary = [
{file = "psycopg2-binary-2.9.3.tar.gz", hash = "sha256:761df5313dc15da1502b21453642d7599d26be88bff659382f8f9747c7ebea4e"},
{file = "psycopg2_binary-2.9.3-cp310-cp310-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:539b28661b71da7c0e428692438efbcd048ca21ea81af618d845e06ebfd29478"},
{file = "psycopg2_binary-2.9.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e82d38390a03da28c7985b394ec3f56873174e2c88130e6966cb1c946508e65"},
{file = "psycopg2_binary-2.9.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57804fc02ca3ce0dbfbef35c4b3a4a774da66d66ea20f4bda601294ad2ea6092"},
{file = "psycopg2_binary-2.9.3-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:083a55275f09a62b8ca4902dd11f4b33075b743cf0d360419e2051a8a5d5ff76"},
{file = "psycopg2_binary-2.9.3-cp310-cp310-manylinux_2_24_ppc64le.whl", hash = "sha256:0a29729145aaaf1ad8bafe663131890e2111f13416b60e460dae0a96af5905c9"},
{file = "psycopg2_binary-2.9.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3a79d622f5206d695d7824cbf609a4f5b88ea6d6dab5f7c147fc6d333a8787e4"},
{file = "psycopg2_binary-2.9.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:090f3348c0ab2cceb6dfbe6bf721ef61262ddf518cd6cc6ecc7d334996d64efa"},
{file = "psycopg2_binary-2.9.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:a9e1f75f96ea388fbcef36c70640c4efbe4650658f3d6a2967b4cc70e907352e"},
{file = "psycopg2_binary-2.9.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c3ae8e75eb7160851e59adc77b3a19a976e50622e44fd4fd47b8b18208189d42"},
{file = "psycopg2_binary-2.9.3-cp310-cp310-win32.whl", hash = "sha256:7b1e9b80afca7b7a386ef087db614faebbf8839b7f4db5eb107d0f1a53225029"},
{file = "psycopg2_binary-2.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:8b344adbb9a862de0c635f4f0425b7958bf5a4b927c8594e6e8d261775796d53"},
{file = "psycopg2_binary-2.9.3-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:e847774f8ffd5b398a75bc1c18fbb56564cda3d629fe68fd81971fece2d3c67e"},
{file = "psycopg2_binary-2.9.3-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:68641a34023d306be959101b345732360fc2ea4938982309b786f7be1b43a4a1"},
{file = "psycopg2_binary-2.9.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3303f8807f342641851578ee7ed1f3efc9802d00a6f83c101d21c608cb864460"},
{file = "psycopg2_binary-2.9.3-cp36-cp36m-manylinux_2_24_aarch64.whl", hash = "sha256:e3699852e22aa68c10de06524a3721ade969abf382da95884e6a10ff798f9281"},
{file = "psycopg2_binary-2.9.3-cp36-cp36m-manylinux_2_24_ppc64le.whl", hash = "sha256:526ea0378246d9b080148f2d6681229f4b5964543c170dd10bf4faaab6e0d27f"},
{file = "psycopg2_binary-2.9.3-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:b1c8068513f5b158cf7e29c43a77eb34b407db29aca749d3eb9293ee0d3103ca"},
{file = "psycopg2_binary-2.9.3-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:15803fa813ea05bef089fa78835118b5434204f3a17cb9f1e5dbfd0b9deea5af"},
{file = "psycopg2_binary-2.9.3-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:152f09f57417b831418304c7f30d727dc83a12761627bb826951692cc6491e57"},
{file = "psycopg2_binary-2.9.3-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:404224e5fef3b193f892abdbf8961ce20e0b6642886cfe1fe1923f41aaa75c9d"},
{file = "psycopg2_binary-2.9.3-cp36-cp36m-win32.whl", hash = "sha256:1f6b813106a3abdf7b03640d36e24669234120c72e91d5cbaeb87c5f7c36c65b"},
{file = "psycopg2_binary-2.9.3-cp36-cp36m-win_amd64.whl", hash = "sha256:2d872e3c9d5d075a2e104540965a1cf898b52274a5923936e5bfddb58c59c7c2"},
{file = "psycopg2_binary-2.9.3-cp37-cp37m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:10bb90fb4d523a2aa67773d4ff2b833ec00857f5912bafcfd5f5414e45280fb1"},
{file = "psycopg2_binary-2.9.3-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:874a52ecab70af13e899f7847b3e074eeb16ebac5615665db33bce8a1009cf33"},
{file = "psycopg2_binary-2.9.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a29b3ca4ec9defec6d42bf5feb36bb5817ba3c0230dd83b4edf4bf02684cd0ae"},
{file = "psycopg2_binary-2.9.3-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:12b11322ea00ad8db8c46f18b7dfc47ae215e4df55b46c67a94b4effbaec7094"},
{file = "psycopg2_binary-2.9.3-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:53293533fcbb94c202b7c800a12c873cfe24599656b341f56e71dd2b557be063"},
{file = "psycopg2_binary-2.9.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c381bda330ddf2fccbafab789d83ebc6c53db126e4383e73794c74eedce855ef"},
{file = "psycopg2_binary-2.9.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9d29409b625a143649d03d0fd7b57e4b92e0ecad9726ba682244b73be91d2fdb"},
{file = "psycopg2_binary-2.9.3-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:183a517a3a63503f70f808b58bfbf962f23d73b6dccddae5aa56152ef2bcb232"},
{file = "psycopg2_binary-2.9.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:15c4e4cfa45f5a60599d9cec5f46cd7b1b29d86a6390ec23e8eebaae84e64554"},
{file = "psycopg2_binary-2.9.3-cp37-cp37m-win32.whl", hash = "sha256:adf20d9a67e0b6393eac162eb81fb10bc9130a80540f4df7e7355c2dd4af9fba"},
{file = "psycopg2_binary-2.9.3-cp37-cp37m-win_amd64.whl", hash = "sha256:2f9ffd643bc7349eeb664eba8864d9e01f057880f510e4681ba40a6532f93c71"},
{file = "psycopg2_binary-2.9.3-cp38-cp38-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:def68d7c21984b0f8218e8a15d514f714d96904265164f75f8d3a70f9c295667"},
{file = "psycopg2_binary-2.9.3-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dffc08ca91c9ac09008870c9eb77b00a46b3378719584059c034b8945e26b272"},
{file = "psycopg2_binary-2.9.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:280b0bb5cbfe8039205c7981cceb006156a675362a00fe29b16fbc264e242834"},
{file = "psycopg2_binary-2.9.3-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:af9813db73395fb1fc211bac696faea4ca9ef53f32dc0cfa27e4e7cf766dcf24"},
{file = "psycopg2_binary-2.9.3-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:63638d875be8c2784cfc952c9ac34e2b50e43f9f0a0660b65e2a87d656b3116c"},
{file = "psycopg2_binary-2.9.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ffb7a888a047696e7f8240d649b43fb3644f14f0ee229077e7f6b9f9081635bd"},
{file = "psycopg2_binary-2.9.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:0c9d5450c566c80c396b7402895c4369a410cab5a82707b11aee1e624da7d004"},
{file = "psycopg2_binary-2.9.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:d1c1b569ecafe3a69380a94e6ae09a4789bbb23666f3d3a08d06bbd2451f5ef1"},
{file = "psycopg2_binary-2.9.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8fc53f9af09426a61db9ba357865c77f26076d48669f2e1bb24d85a22fb52307"},
{file = "psycopg2_binary-2.9.3-cp38-cp38-win32.whl", hash = "sha256:6472a178e291b59e7f16ab49ec8b4f3bdada0a879c68d3817ff0963e722a82ce"},
{file = "psycopg2_binary-2.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:35168209c9d51b145e459e05c31a9eaeffa9a6b0fd61689b48e07464ffd1a83e"},
{file = "psycopg2_binary-2.9.3-cp39-cp39-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:47133f3f872faf28c1e87d4357220e809dfd3fa7c64295a4a148bcd1e6e34ec9"},
{file = "psycopg2_binary-2.9.3-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91920527dea30175cc02a1099f331aa8c1ba39bf8b7762b7b56cbf54bc5cce42"},
{file = "psycopg2_binary-2.9.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:887dd9aac71765ac0d0bac1d0d4b4f2c99d5f5c1382d8b770404f0f3d0ce8a39"},
{file = "psycopg2_binary-2.9.3-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:1f14c8b0942714eb3c74e1e71700cbbcb415acbc311c730370e70c578a44a25c"},
{file = "psycopg2_binary-2.9.3-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:7af0dd86ddb2f8af5da57a976d27cd2cd15510518d582b478fbb2292428710b4"},
{file = "psycopg2_binary-2.9.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:93cd1967a18aa0edd4b95b1dfd554cf15af657cb606280996d393dadc88c3c35"},
{file = "psycopg2_binary-2.9.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bda845b664bb6c91446ca9609fc69f7db6c334ec5e4adc87571c34e4f47b7ddb"},
{file = "psycopg2_binary-2.9.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:01310cf4cf26db9aea5158c217caa92d291f0500051a6469ac52166e1a16f5b7"},
{file = "psycopg2_binary-2.9.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:99485cab9ba0fa9b84f1f9e1fef106f44a46ef6afdeec8885e0b88d0772b49e8"},
{file = "psycopg2_binary-2.9.3-cp39-cp39-win32.whl", hash = "sha256:46f0e0a6b5fa5851bbd9ab1bc805eef362d3a230fbdfbc209f4a236d0a7a990d"},
{file = "psycopg2_binary-2.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:accfe7e982411da3178ec690baaceaad3c278652998b2c45828aaac66cd8285f"},
psutil = [
{file = "psutil-5.9.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:799759d809c31aab5fe4579e50addf84565e71c1dc9f1c31258f159ff70d3f87"},
{file = "psutil-5.9.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:9272167b5f5fbfe16945be3db475b3ce8d792386907e673a209da686176552af"},
{file = "psutil-5.9.1-cp27-cp27m-win32.whl", hash = "sha256:0904727e0b0a038830b019551cf3204dd48ef5c6868adc776e06e93d615fc5fc"},
{file = "psutil-5.9.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e7e10454cb1ab62cc6ce776e1c135a64045a11ec4c6d254d3f7689c16eb3efd2"},
{file = "psutil-5.9.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:56960b9e8edcca1456f8c86a196f0c3d8e3e361320071c93378d41445ffd28b0"},
{file = "psutil-5.9.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:44d1826150d49ffd62035785a9e2c56afcea66e55b43b8b630d7706276e87f22"},
{file = "psutil-5.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7be9d7f5b0d206f0bbc3794b8e16fb7dbc53ec9e40bbe8787c6f2d38efcf6c9"},
{file = "psutil-5.9.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd9246e4cdd5b554a2ddd97c157e292ac11ef3e7af25ac56b08b455c829dca8"},
{file = "psutil-5.9.1-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29a442e25fab1f4d05e2655bb1b8ab6887981838d22effa2396d584b740194de"},
{file = "psutil-5.9.1-cp310-cp310-win32.whl", hash = "sha256:20b27771b077dcaa0de1de3ad52d22538fe101f9946d6dc7869e6f694f079329"},
{file = "psutil-5.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:58678bbadae12e0db55186dc58f2888839228ac9f41cc7848853539b70490021"},
{file = "psutil-5.9.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3a76ad658641172d9c6e593de6fe248ddde825b5866464c3b2ee26c35da9d237"},
{file = "psutil-5.9.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6a11e48cb93a5fa606306493f439b4aa7c56cb03fc9ace7f6bfa21aaf07c453"},
{file = "psutil-5.9.1-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:068935df39055bf27a29824b95c801c7a5130f118b806eee663cad28dca97685"},
{file = "psutil-5.9.1-cp36-cp36m-win32.whl", hash = "sha256:0f15a19a05f39a09327345bc279c1ba4a8cfb0172cc0d3c7f7d16c813b2e7d36"},
{file = "psutil-5.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:db417f0865f90bdc07fa30e1aadc69b6f4cad7f86324b02aa842034efe8d8c4d"},
{file = "psutil-5.9.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:91c7ff2a40c373d0cc9121d54bc5f31c4fa09c346528e6a08d1845bce5771ffc"},
{file = "psutil-5.9.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fea896b54f3a4ae6f790ac1d017101252c93f6fe075d0e7571543510f11d2676"},
{file = "psutil-5.9.1-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3054e923204b8e9c23a55b23b6df73a8089ae1d075cb0bf711d3e9da1724ded4"},
{file = "psutil-5.9.1-cp37-cp37m-win32.whl", hash = "sha256:d2d006286fbcb60f0b391741f520862e9b69f4019b4d738a2a45728c7e952f1b"},
{file = "psutil-5.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:b14ee12da9338f5e5b3a3ef7ca58b3cba30f5b66f7662159762932e6d0b8f680"},
{file = "psutil-5.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:19f36c16012ba9cfc742604df189f2f28d2720e23ff7d1e81602dbe066be9fd1"},
{file = "psutil-5.9.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:944c4b4b82dc4a1b805329c980f270f170fdc9945464223f2ec8e57563139cf4"},
{file = "psutil-5.9.1-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b6750a73a9c4a4e689490ccb862d53c7b976a2a35c4e1846d049dcc3f17d83b"},
{file = "psutil-5.9.1-cp38-cp38-win32.whl", hash = "sha256:a8746bfe4e8f659528c5c7e9af5090c5a7d252f32b2e859c584ef7d8efb1e689"},
{file = "psutil-5.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:79c9108d9aa7fa6fba6e668b61b82facc067a6b81517cab34d07a84aa89f3df0"},
{file = "psutil-5.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:28976df6c64ddd6320d281128817f32c29b539a52bdae5e192537bc338a9ec81"},
{file = "psutil-5.9.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b88f75005586131276634027f4219d06e0561292be8bd6bc7f2f00bdabd63c4e"},
{file = "psutil-5.9.1-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:645bd4f7bb5b8633803e0b6746ff1628724668681a434482546887d22c7a9537"},
{file = "psutil-5.9.1-cp39-cp39-win32.whl", hash = "sha256:32c52611756096ae91f5d1499fe6c53b86f4a9ada147ee42db4991ba1520e574"},
{file = "psutil-5.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:f65f9a46d984b8cd9b3750c2bdb419b2996895b005aefa6cbaba9a143b1ce2c5"},
{file = "psutil-5.9.1.tar.gz", hash = "sha256:57f1819b5d9e95cdfb0c881a8a5b7d542ed0b7c522d575706a80bedc848c8954"},
]
psycopg2 = [
{file = "psycopg2-2.9.3-cp310-cp310-win32.whl", hash = "sha256:083707a696e5e1c330af2508d8fab36f9700b26621ccbcb538abe22e15485362"},
{file = "psycopg2-2.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:d3ca6421b942f60c008f81a3541e8faf6865a28d5a9b48544b0ee4f40cac7fca"},
{file = "psycopg2-2.9.3-cp36-cp36m-win32.whl", hash = "sha256:9572e08b50aed176ef6d66f15a21d823bb6f6d23152d35e8451d7d2d18fdac56"},
{file = "psycopg2-2.9.3-cp36-cp36m-win_amd64.whl", hash = "sha256:a81e3866f99382dfe8c15a151f1ca5fde5815fde879348fe5a9884a7c092a305"},
{file = "psycopg2-2.9.3-cp37-cp37m-win32.whl", hash = "sha256:cb10d44e6694d763fa1078a26f7f6137d69f555a78ec85dc2ef716c37447e4b2"},
{file = "psycopg2-2.9.3-cp37-cp37m-win_amd64.whl", hash = "sha256:4295093a6ae3434d33ec6baab4ca5512a5082cc43c0505293087b8a46d108461"},
{file = "psycopg2-2.9.3-cp38-cp38-win32.whl", hash = "sha256:34b33e0162cfcaad151f249c2649fd1030010c16f4bbc40a604c1cb77173dcf7"},
{file = "psycopg2-2.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:0762c27d018edbcb2d34d51596e4346c983bd27c330218c56c4dc25ef7e819bf"},
{file = "psycopg2-2.9.3-cp39-cp39-win32.whl", hash = "sha256:8cf3878353cc04b053822896bc4922b194792df9df2f1ad8da01fb3043602126"},
{file = "psycopg2-2.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:06f32425949bd5fe8f625c49f17ebb9784e1e4fe928b7cce72edc36fb68e4c0c"},
{file = "psycopg2-2.9.3.tar.gz", hash = "sha256:8e841d1bf3434da985cc5ef13e6f75c8981ced601fd70cc6bf33351b91562981"},
]
ptyprocess = [
{file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"},

View File

@@ -15,9 +15,12 @@ python-dateutil = "^2.8.2"
httpx = "^0.23.0"
Authlib = "^1.0.1"
peewee = "^3.14.10"
psycopg2-binary = "^2.9.3"
dash = "^2.4.1"
gunicorn = "^20.1.0"
psycopg2 = "^2.9.3"
diskcache = "^5.4.0"
multiprocess = "^0.70.13"
psutil = "^5.9.1"
[tool.poetry.dev-dependencies]
black = "^22.3.0"
@@ -33,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

View File

@@ -1,3 +1,7 @@
#!/bin/bash
poetry run gunicorn -b "127.0.0.1:8050" "wordlinator.web:server"
RELOAD=""
if $DEBUG; then
RELOAD="--reload"
fi
poetry run gunicorn $RELOAD -b "0.0.0.0:8050" "wordlinator.web:server"

View File

@@ -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(
@@ -69,22 +70,57 @@ def print_score_table(wordle_day, scores):
print_missing_names(wordle_day, scoreless_names)
def _save_db_scores(wordle_day: wordlinator.utils.WordleDay, scores: dict):
def _save_db_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:
return
game_no = hole_data.game_no
db_users = db.get_users()
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():
if not db.get_user(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):
db.add_score(user, game_no, day, score_entry)
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)
if to_create:
db.bulk_insert_scores(to_create)
return
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)
@@ -95,7 +131,7 @@ async def main_update(
updated_scores = sheets_client.update_scores(today_scores)
rich.print("[green]Saving scores in db...")
_save_db_scores(wordle_day, updated_scores)
_save_db_scores(wordle_day, updated_scores, today_scores)
print_score_table(wordle_day, today_scores)
@@ -136,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:
@@ -144,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
@@ -154,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))

View File

@@ -1,4 +1,6 @@
import datetime
import os
import typing
import peewee
@@ -22,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"
@@ -29,6 +34,22 @@ class User(BaseModel):
class Game(BaseModel):
game_id = peewee.AutoField()
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):
@@ -36,12 +57,16 @@ class Hole(BaseModel):
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)
user_id = peewee.ForeignKeyField(User, "user_id", null=False)
game_id = peewee.ForeignKeyField(Game, "game_id", null=False)
hole_id = peewee.ForeignKeyField(Hole, "hole_id", null=False)
tweet_id = peewee.CharField(max_length=255, null=True)
class Meta:
primary_key = peewee.CompositeKey("user_id", "game_id", "hole_id")
@@ -54,57 +79,198 @@ class WordleDb:
except peewee.DoesNotExist:
return None
def get_users(self):
return list(User.select())
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 get_user_id(self, username):
user = self.get_user(username)
return user.twitter_id if user else None
with db.atomic():
user = self.get_user(username)
return user.twitter_id if user else None
def add_user(self, username, user_id):
return User.create(username=username, twitter_id=user_id)
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_or_create_round(self, round_no):
try:
return Game.get(Game.game == round_no)
except peewee.DoesNotExist:
return Game.create(game=round_no)
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):
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, 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=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)
)
return list(res) if res else []
def get_scores(self, round_no):
round = self.get_or_create_round(round_no)
return Score.select().filter(Score.game_id == round.game_id)
def bulk_insert_scores(self, scores: typing.List[typing.Dict]):
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]):
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)
# Find users who *have* played in this round,
# but have no score on the current hole
query_str = """SELECT u.username, player.game_id
FROM user_tbl u
JOIN player ON player.user_id = u.user_id
WHERE (
player.game_id = {}
) AND NOT EXISTS (
SELECT FROM score WHERE score.user_id = u.user_id AND score.hole_id = {}
)
""".format(
hole.game_id, hole.hole_id
)
if tweetable:
# Restrict to users who post scores on twitter
query_str += " AND u.check_twitter = true"
res = db.execute_sql(query_str)
return [r[0] for r in res]

View File

@@ -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")
@@ -60,6 +68,7 @@ class WordleTweet:
wordle_day: wordlinator.utils.WordleDay
raw_score: int
user: TwitterUser
tweet_id: str
@property
def score(self):
@@ -86,6 +95,7 @@ class WordleTweet:
return cls(
created_at=created,
text=tweet["text"],
tweet_id=tweet["id"],
wordle_day=wordlinator.utils.WordleDay.from_wordle_no(wordle_no),
raw_score=score,
user=twitter_user,
@@ -131,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):
@@ -196,17 +211,28 @@ class TwitterClient(httpx.AsyncClient):
async def get_wordlegolf_tweets(self):
return self._build_wordle_tweets(await self.search_tweets("#WordleGolf"))
def open_tweet(self, msg):
@classmethod
def open_tweet(cls, msg):
param = urllib.parse.urlencode({"text": msg})
webbrowser.open(f"{self.TWEET_INTENT_URL}?{param}")
webbrowser.open(f"{cls.TWEET_INTENT_URL}?{param}")
async def notify_missing(self, names):
@classmethod
def full_notify_link(cls, names):
header = "Still missing a few #WordleGolf Players today!"
msg = header
while names:
while len(msg) < self.MAX_TWEET_LENGTH and names:
msg += f" @{names.pop()}"
param = urllib.parse.urlencode({"text": msg})
return f"{cls.TWEET_INTENT_URL}?{param}"
@classmethod
async def notify_missing(cls, names):
header = "Still missing a few #WordleGolf Players today!"
msg = header
while names:
while len(msg) < cls.MAX_TWEET_LENGTH and names:
msg += f" @{names.pop()}"
self.open_tweet(msg)
cls.open_tweet(msg)
async def main():

View File

@@ -1,47 +0,0 @@
import dataclasses
import datetime
import typing
WORDLE_DAY_ZERO = datetime.date(2021, 6, 19)
WORDLE_GOLF_ROUND_DATES = [datetime.date(2022, 5, 9), datetime.date(2022, 5, 31)]
@dataclasses.dataclass
class GolfHole:
game_no: int
hole_no: int
@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)
return None
@dataclasses.dataclass
class WordleDay:
wordle_no: int
date: datetime.date
golf_hole: typing.Optional[GolfHole]
@classmethod
def from_wordle_no(cls, wordle_no: int):
wordle_no = int(wordle_no)
date = WORDLE_DAY_ZERO + datetime.timedelta(days=wordle_no)
golf_hole = GolfHole.from_date(date)
return cls(wordle_no=wordle_no, date=date, golf_hole=golf_hole)
@classmethod
def from_date(cls, date: datetime.date):
wordle_no = (date - WORDLE_DAY_ZERO).days
golf_hole = GolfHole.from_date(date)
return cls(wordle_no=wordle_no, date=date, golf_hole=golf_hole)
def __eq__(self, other):
return self.wordle_no == other.wordle_no
WORDLE_TODAY = WordleDay.from_date(datetime.date.today())

View File

@@ -0,0 +1,73 @@
import argparse
import dataclasses
import datetime
import typing
import wordlinator.db.pg
WORDLE_DAY_ZERO = datetime.date(2021, 6, 19)
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
class GolfHole:
game_no: int
hole_no: int
@classmethod
def from_date(cls, date: datetime.date):
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
@dataclasses.dataclass
class WordleDay:
wordle_no: int
date: datetime.date
golf_hole: typing.Optional[GolfHole]
@classmethod
def from_wordle_no(cls, wordle_no: int):
wordle_no = int(wordle_no)
date = WORDLE_DAY_ZERO + datetime.timedelta(days=wordle_no)
golf_hole = GolfHole.from_date(date)
return cls(wordle_no=wordle_no, date=date, golf_hole=golf_hole)
@classmethod
def from_date(cls, date: datetime.date):
wordle_no = (date - WORDLE_DAY_ZERO).days
golf_hole = GolfHole.from_date(date)
return cls(wordle_no=wordle_no, date=date, golf_hole=golf_hole)
def __eq__(self, other):
return self.wordle_no == other.wordle_no
# Designed so that "today" will be the current date in CST
# Regardless of where the code is run
def get_today_central():
today = (
datetime.datetime.now(datetime.timezone.utc)
.astimezone(datetime.timezone(datetime.timedelta(hours=-5), name="US Central"))
.date()
)
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
View 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

148
wordlinator/utils/web.py Normal file
View File

@@ -0,0 +1,148 @@
from dash import dcc
###############
# Date Helper #
###############
def _date_range(game):
return f"{game.start_date} to {game.end_date}"
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
]
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
return dcc.Dropdown(
id="round-selector-dropdown",
options=options,
value=value,
clearable=False,
)
######################
# Formatting Helpers #
######################
def format_string(col, condition):
return "{" + col["id"] + "}" + f" {condition}"
def column_formats(col, pct):
return [
{
"if": {"column_id": col["id"]},
"maxWidth": f"{pct}%",
"width": f"{pct}%",
"minWidth": f"{pct}%",
},
# Plain and markdown over-par
{
"if": {
"column_id": col["id"],
"filter_query": format_string(col, "> 4"),
},
"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"],
"filter_query": format_string(col, "= 4"),
},
"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"],
"filter_query": format_string(col, "< 4"),
},
"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"],
"filter_query": format_string(col, "is nil"),
},
"backgroundColor": "white",
},
{
"if": {
"column_id": col["id"],
"filter_query": format_string(col, "= ''"),
},
"backgroundColor": "white",
},
]
def column_formatting(hole_columns):
pct = round((100 - (10 + 5)) / len(hole_columns), 2)
return [
entry
for format_list in [column_formats(hole, pct) for hole in hole_columns]
for entry in format_list
]

View File

@@ -1,95 +1,139 @@
import collections
import functools
import math
import os
import pathlib
import re
import time
import dash
import dash.long_callback
import diskcache
import flask
import flask.views
import plotly.graph_objs
import wordlinator.db.pg as db
import wordlinator.twitter
import wordlinator.utils
import wordlinator.utils.scores
import wordlinator.utils.web
app = dash.Dash(name="WordleGolf")
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()),
suppress_callback_exceptions=True,
)
@functools.lru_cache()
def _scores_from_db(ttl_hash=None):
return db.WordleDb().get_scores(wordlinator.utils.WORDLE_TODAY.golf_hole.game_no)
def get_ttl_hash(seconds=600):
def get_ttl_hash(seconds=TTL_TIME):
return round(time.time() / seconds)
def scores_from_db():
return _scores_from_db(get_ttl_hash())
cache = diskcache.Cache("./cache")
long_callback_manager = dash.long_callback.DiskcacheLongCallbackManager(
cache, cache_by=get_ttl_hash
)
def _golf_score(score_list):
scores = [s.score for s in score_list]
score_count = len(scores)
score = sum(scores) - (score_count * 4)
return score
@functools.lru_cache(maxsize=1)
def _games_from_db(ttl_hash=None):
return db.WordleDb().get_rounds()
def _get_user_scorelist(username, scores):
scores = list(sorted(scores, key=lambda s: s.hole_id.hole))
return {
"Name": username,
"Score": _golf_score(scores),
**{f"Hole {s.hole_id.hole}": s.score for s in scores},
}
def games_from_db():
return _games_from_db()
def _format_string(col, condition):
return "{" + col["id"] + "}" + f" {condition}"
def _column_formats(col):
return [
{
"if": {
"column_id": col["id"],
"filter_query": _format_string(col, "> 4"),
},
"backgroundColor": "red",
},
{
"if": {
"column_id": col["id"],
"filter_query": _format_string(col, "= 4"),
},
"backgroundColor": "orange",
},
{
"if": {
"column_id": col["id"],
"filter_query": _format_string(col, "< 4"),
},
"backgroundColor": "green",
},
{
"if": {
"column_id": col["id"],
"filter_query": _format_string(col, "is nil"),
},
"backgroundColor": "white",
},
@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 = [
game for game in games_from_db()[::-1] if game.start_date <= today.date
]
last_round = last_completed_round[0]
return wordlinator.utils.WordleDay.from_date(last_round.end_date)
def get_scores():
score_list = scores_from_db()
scores_by_user = collections.defaultdict(list)
for score in score_list:
scores_by_user[score.user_id.username].append(score)
def wordle_today():
return _wordle_today(get_ttl_hash())
table_rows = [
_get_user_scorelist(username, scores)
for username, scores in scores_by_user.items()
]
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)
@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"},
)
#################
# Score Helpers #
#################
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"Hole {i}", "id": f"Hole {i}", "type": "numeric"}
for i in range(1, wordlinator.utils.WORDLE_TODAY.golf_hole.hole_no + 1)
{"name": f"{i}", "id": f"{i}", "type": "text", "presentation": "markdown"}
for i in range(1, round_day.golf_hole.hole_no + 1)
]
columns = [
{"name": "Name", "id": "Name", "type": "text"},
@@ -97,78 +141,77 @@ def get_scores():
*hole_columns,
]
color_formatting = [
format_entry
for column_formats in [_column_formats(col) for col in hole_columns]
for format_entry in column_formats
]
color_formatting = wordlinator.utils.web.column_formatting(hole_columns)
formatting = [
{"if": {"column_id": "Name"}, "textAlign": "center"},
{
"if": {"column_id": "Name"},
"textAlign": "center",
"width": "10%",
"maxWidth": "10%",
"minWidth": "10%",
},
{
"if": {"column_id": "Score"},
"textAlign": "center",
"width": "5%",
"maxWidth": "5%",
"minWidth": "5%",
},
*color_formatting,
]
return dash.dash_table.DataTable(
table_rows,
columns,
style_table={"width": "80%", "margin": "auto"},
id="user-scores-table",
style_table={
"width": "80%",
"margin": "auto",
"height": "600px",
"overflowY": "auto",
},
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"}],
)
SCORE_NAME_MAP = {
1: "Hole-in-1",
2: "Eagle",
3: "Birdie",
4: "Par",
5: "Bogey",
6: "Double Bogey",
7: "Fail",
}
#################
# Stats Helpers #
#################
def _get_score_breakdown(score, holes):
score_row = {"Score": SCORE_NAME_MAP[score]}
days = sorted(set(holes))
for day in days:
score_row[day] = holes.count(day)
return score_row
def _get_summary_rows(score_matrix):
day_dict = score_matrix.by_hole()
def _get_summary_rows(score_list):
days = list(sorted(set((score.hole_id.hole for score in score_list))))
day_dict = {
day: [score.score for score in score_list if score.hole_id.hole == day]
for day in days
}
totals = {
"Score": "Total",
**{day: len(scores) for day, scores in day_dict.items()},
**{day: scores.count for day, scores in day_dict.items()},
}
averages = {
"Score": "Daily Average",
**{
day: round(sum(scores) / len(scores), 2) for day, scores in day_dict.items()
},
**{day: scores.average for day, scores in day_dict.items()},
}
return [totals, averages]
def get_daily_stats():
score_list = scores_from_db()
scores_by_value = collections.defaultdict(list)
for score in score_list:
scores_by_value[score.score].append(score.hole_id.hole)
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
table_rows = []
for score in sorted(scores_by_value.keys()):
table_rows.append(_get_score_breakdown(score, scores_by_value[score]))
table_rows.extend(_get_summary_rows(score_list))
def get_daily_stats(round_id):
table_rows = _stats_dict(round_id)
columns = [
{"name": n, "id": n}
@@ -176,7 +219,7 @@ def get_daily_stats():
"Score",
*[
f"{i}"
for i in range(1, wordlinator.utils.WORDLE_TODAY.golf_hole.hole_no + 1)
for i in range(1, round_wordle_day(round_id).golf_hole.hole_no + 1)
],
)
]
@@ -192,27 +235,231 @@ def get_daily_stats():
)
#################
# Graph Helpers #
#################
SCORE_COLOR_DICT = {
"Hole-in-1": "black",
"Eagle": "darkgreen",
"Birdie": "lightgreen",
"Par": "white",
"Bogey": "palevioletred",
"Double Bogey": "orangered",
"Fail": "darkred",
}
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")]
total.pop("Score")
for row in rows:
score = row.pop("Score")
y_values = []
for k in row.keys():
row_val = row.get(k)
total_val = total.get(k)
pct = row_val / total_val * 100
y_values.append(pct)
figure.add_trace(
plotly.graph_objs.Scatter(
x=list(row.keys()),
y=y_values,
fill="tonexty",
name=score,
line={"color": SCORE_COLOR_DICT[score]},
stackgroup="dailies",
)
)
figure.update_xaxes(tickvals=list(total.keys()), title_text="Days")
figure.update_yaxes(title_text="Percent")
figure.update_layout(yaxis_range=[0, 100])
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 #
#############
app.layout = dash.html.Div(
children=[
dash.html.H1("#WordleGolf", style={"textAlign": "center"}),
dash.html.H1("#WordleGolf", style={"textAlign": "center"}, id="title"),
dash.html.Div(
[dash.html.H2("User Scores", style={"textAlign": "center"}), get_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("Daily Stats", style={"textAlign": "center"}),
get_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.callback(
dash.dependencies.Output("tab-content", "children"),
[
dash.dependencies.Input("main-tabs", "value"),
dash.dependencies.Input("round-selector-dropdown", "value"),
],
)
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.callback(
dash.dependencies.Output("user-scores-table", "data"),
dash.dependencies.Input("user-scores-table", "sort_by"),
dash.dependencies.State("user-scores-table", "data"),
)
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)
data = sorted(data, key=_sort_val, reverse=sort_by["direction"] == "desc")
return data
server = app.server
class GetLinkView(flask.views.View):
methods = ["GET"]
def dispatch_request(self):
today = wordle_today()
missing_users = db.WordleDb().get_users_without_score(
today.golf_hole.game_no, today.golf_hole.hole_no
)
link = wordlinator.twitter.TwitterClient.full_notify_link(missing_users)
return flask.redirect(link)
server.add_url_rule("/tweet_link", view_func=GetLinkView.as_view("tweet_link"))
def serve(debug=True):
app.run_server(debug=debug)
app.run(debug=debug)
if __name__ == "__main__":

View File

@@ -0,0 +1,4 @@
p {
margin-bottom: 0;
text-align: center;
}